151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
import { isDeepStrictEqual } from 'node:util';
|
|
|
|
import {
|
|
appliedProfile,
|
|
desiredProfile,
|
|
type RouteRule,
|
|
type StoredState,
|
|
} from '../../../shared/contracts/state.js';
|
|
import { HarborError } from '../../../shared/errors.js';
|
|
import {
|
|
normalizeRouteRules,
|
|
ROUTE_RULES_CONTRACT_VERSION,
|
|
} from '../../../shared/routingRules.js';
|
|
import type { RuntimeCommandResult } from '../connection/index.js';
|
|
import { finishRollback } from '../../services/rollback.js';
|
|
|
|
interface RouteRulesDependencies {
|
|
state: {
|
|
read(): StoredState;
|
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
|
};
|
|
subscription: { readConfig(profileId: string): unknown | null };
|
|
config: {
|
|
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown;
|
|
read(): string | null;
|
|
write(value: unknown): void;
|
|
restore(value: string): void;
|
|
remove(): void;
|
|
};
|
|
runtime: {
|
|
isRunning(): Promise<boolean>;
|
|
applyCommand(): Promise<RuntimeCommandResult>;
|
|
restoreRunning(): Promise<unknown>;
|
|
};
|
|
route?: { isGatewayDirect(): boolean };
|
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
|
runOperation<T>(operation: () => Promise<T>): Promise<T>;
|
|
afterApply?: () => Promise<unknown>;
|
|
restoreAppliedActivation?: (state: StoredState) => Promise<unknown>;
|
|
}
|
|
|
|
export function createRouteRulesService(dependencies: RouteRulesDependencies) {
|
|
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
|
|
const wasRunning = await dependencies.runtime.isRunning();
|
|
const bypassed = dependencies.route?.isGatewayDirect() === true;
|
|
const targetProfile = wasRunning
|
|
? appliedProfile(previousState)
|
|
: desiredProfile(previousState);
|
|
const targetServerId = wasRunning
|
|
? previousState.appliedServerId
|
|
: targetProfile?.desiredServerId || '';
|
|
const subscriptionConfig = targetProfile
|
|
? dependencies.subscription.readConfig(targetProfile.id)
|
|
: null;
|
|
if (bypassed || !targetServerId || !subscriptionConfig) {
|
|
let stateCommitStarted = false;
|
|
try {
|
|
stateCommitStarted = true;
|
|
dependencies.state.update((state) => ({
|
|
...state,
|
|
routeRules,
|
|
...(bypassed ? { appliedRouteRules: [] } : {}),
|
|
routeRulesRevision: state.routeRulesRevision + 1,
|
|
}));
|
|
} catch (error) {
|
|
await finishRollback(error, [
|
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
|
], 'Route rules rollback failed');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const candidateConfig = dependencies.config.build(
|
|
subscriptionConfig,
|
|
targetServerId,
|
|
routeRules,
|
|
);
|
|
const previousConfig = dependencies.config.read();
|
|
const configChanged = previousConfig !== JSON.stringify(candidateConfig, null, 2);
|
|
let configMutationStarted = false;
|
|
let runtimeMutationStarted = false;
|
|
let stateCommitStarted = false;
|
|
|
|
try {
|
|
if (configChanged) {
|
|
configMutationStarted = true;
|
|
dependencies.config.write(candidateConfig);
|
|
}
|
|
if (wasRunning && configChanged) {
|
|
const command = await dependencies.runtime.applyCommand();
|
|
runtimeMutationStarted = command.mutationStarted;
|
|
if (!command.ok) throw command.error;
|
|
}
|
|
stateCommitStarted = true;
|
|
dependencies.state.update((state) => ({
|
|
...state,
|
|
routeRules,
|
|
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
|
|
routeRulesRevision: state.routeRulesRevision + 1,
|
|
}));
|
|
await dependencies.afterApply?.();
|
|
} catch (error) {
|
|
await finishRollback(error, [
|
|
...(configMutationStarted ? [{
|
|
run: () => previousConfig === null
|
|
? dependencies.config.remove()
|
|
: dependencies.config.restore(previousConfig),
|
|
}] : []),
|
|
...(wasRunning && runtimeMutationStarted ? [{
|
|
run: async () => {
|
|
await dependencies.runtime.restoreRunning();
|
|
await dependencies.restoreAppliedActivation?.(previousState);
|
|
},
|
|
runtime: true,
|
|
}] : []),
|
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
|
], 'Route rules rollback failed');
|
|
}
|
|
};
|
|
|
|
const update = (
|
|
rules: unknown,
|
|
expectedRulesRevision: unknown,
|
|
rulesContractVersion: unknown,
|
|
) => {
|
|
if (rulesContractVersion !== ROUTE_RULES_CONTRACT_VERSION) {
|
|
throw new HarborError('REQUEST_INVALID');
|
|
}
|
|
let routeRules: RouteRule[];
|
|
try {
|
|
routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[];
|
|
} catch (cause) {
|
|
throw new HarborError('REQUEST_INVALID', { cause });
|
|
}
|
|
if (!Number.isSafeInteger(expectedRulesRevision) || Number(expectedRulesRevision) < 0) {
|
|
throw new HarborError('REQUEST_INVALID');
|
|
}
|
|
|
|
return dependencies.serialize(async () => {
|
|
const current = dependencies.state.read();
|
|
if (current.routeRulesRevision !== expectedRulesRevision) throw new HarborError('STATE_CONFLICT');
|
|
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
|
await dependencies.runOperation(() => applyRules(current, routeRules));
|
|
});
|
|
};
|
|
|
|
return { update };
|
|
}
|
|
|
|
export type RouteRulesService = ReturnType<typeof createRouteRulesService>;
|