Files
harbor-net/src/server/services/stateStore.ts
T
dokril 76a99f098a
Build and Deploy Gateway / build-and-push (push) Successful in 31s
Build and Deploy Gateway / deploy (push) Successful in 13s
Add DNS diagnostics across dataplane and client
2026-09-01 04:16:15 +03:00

303 lines
10 KiB
TypeScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
normalizeStoredState,
type PersistedState,
} from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES, normalizeRouteRules } from '../../shared/routingRules.js';
import {
normalizeServers,
resolveServerId,
serverIdentityKey,
type NormalizedServer,
} from '../../shared/serverIdentity.js';
export const STATE_SCHEMA_VERSION = 10;
export interface AtomicWriteOptions {
beforeRename?: (temporaryPath: string, filePath: string) => void;
mode?: number;
}
interface RecoveryState {
kind: 'corrupt-json';
backupPath: string;
recoveredAt: string;
}
interface MigrationState {
fromVersion: number;
toVersion: unknown;
backupPath: string;
migratedAt: string;
}
interface JsonStoreBaseOptions {
filePath: string;
initializeMissing?: boolean;
backupWhen?: (before: unknown, after: unknown) => boolean;
now?: () => Date;
}
export interface JsonStoreOptions<T> extends JsonStoreBaseOptions {
defaultValue: T;
migrate: (value: unknown) => T;
}
export interface RawJsonStoreOptions extends JsonStoreBaseOptions {
defaultValue: unknown;
migrate?: never;
}
export interface JsonStore<T> {
read(): T;
write(value: T, options?: AtomicWriteOptions): T;
update(mutator: (value: T) => T): T;
remove(): void;
readonly recovery: RecoveryState | null;
readonly migration: MigrationState | null;
}
const clone = <T>(value: T): T => structuredClone(value);
const stamp = (value: Date) => value.toISOString().replace(/[:.]/g, '-');
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function remapLegacyServerId(
previousServers: NormalizedServer[],
nextServers: NormalizedServer[],
serverId: unknown,
legacyTag: unknown = '',
) {
const direct = resolveServerId(nextServers, serverId, legacyTag);
if (direct) return direct;
const previousId = resolveServerId(previousServers, serverId, legacyTag);
const previous = previousServers.find((server) => server.id === previousId);
if (!previous) return '';
const matches = nextServers.filter((server) => (
serverIdentityKey(server) === serverIdentityKey(previous)
));
return matches.length === 1 ? matches[0].id : '';
}
function syncDirectory(directory: string) {
let descriptor: number | undefined;
try {
descriptor = fs.openSync(directory, 'r');
fs.fsyncSync(descriptor);
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(code)) throw error;
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function atomicWriteFile(
filePath: string,
contents: string | NodeJS.ArrayBufferView,
{ beforeRename, mode }: AtomicWriteOptions = {},
) {
const directory = path.dirname(filePath);
fs.mkdirSync(directory, { recursive: true });
const temporaryPath = path.join(
directory,
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
);
const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666);
let descriptor: number | undefined;
try {
descriptor = fs.openSync(temporaryPath, 'wx', fileMode);
fs.writeFileSync(descriptor, contents, 'utf8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
beforeRename?.(temporaryPath, filePath);
fs.renameSync(temporaryPath, filePath);
syncDirectory(directory);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
fs.rmSync(temporaryPath, { force: true });
}
}
export function atomicWriteJson(filePath: string, value: unknown, options?: AtomicWriteOptions) {
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
}
export function migrateStoredState(
value: unknown,
legacySubscriptionCache: unknown = null,
): PersistedState & { schemaVersion: number } {
const stored = record(value);
const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0;
if (version < 0 || version > STATE_SCHEMA_VERSION) {
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
}
const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
: stored.routeRules;
const migratedRouteRules = normalizeRouteRules(routeRules, { strict: version >= 6 });
const migratedAppliedRouteRules = normalizeRouteRules(stored.appliedRouteRules, { strict: version >= 6 });
const legacyCache = record(legacySubscriptionCache);
const storedSubscriptionUrl = String(stored.subscriptionUrl || '').trim();
const cachedSubscriptionUrl = String(legacyCache.url || '').trim();
const cacheOwnsStoredSubscription = Boolean(cachedSubscriptionUrl)
&& (!storedSubscriptionUrl || cachedSubscriptionUrl === storedSubscriptionUrl);
const previousServers = normalizeServers(stored.servers);
const cachedServers = cacheOwnsStoredSubscription
? normalizeServers(legacyCache.servers)
: [];
const migratedServers = cachedServers.length ? cachedServers : previousServers;
const selectedServerId = remapLegacyServerId(
previousServers,
migratedServers,
stored.selectedServerId,
stored.selectedTag,
);
const appliedServerId = remapLegacyServerId(
previousServers,
migratedServers,
stored.appliedServerId,
stored.appliedTag || stored.selectedTag,
);
const keepLegacyApplied = !(version < 5 && stored.connectionDesired === 'stopped');
const normalized = normalizeStoredState({
...stored,
routeRules: migratedRouteRules,
appliedRouteRules: migratedAppliedRouteRules,
...(version < 5 && !Array.isArray(stored.profiles) ? {
subscriptionUrl: storedSubscriptionUrl || (cacheOwnsStoredSubscription ? cachedSubscriptionUrl : ''),
subscriptionConfig: cacheOwnsStoredSubscription ? legacyCache.config : null,
servers: migratedServers,
selectedServerId,
selectedTag: '',
appliedServerId: keepLegacyApplied ? appliedServerId : '',
appliedServerSnapshot: keepLegacyApplied ? stored.appliedServerSnapshot : null,
appliedTag: '',
userInfo: stored.userInfo || (cacheOwnsStoredSubscription ? legacyCache.userInfo : undefined),
fetchedAt: stored.fetchedAt || (cacheOwnsStoredSubscription ? legacyCache.fetchedAt : undefined),
} : {}),
});
const canonical = { ...normalized } as Record<string, unknown>;
for (const key of [
'subscriptionUrl',
'selectedServerId',
'selectedTag',
'appliedTag',
'servers',
'userInfo',
'fetchedAt',
'subscriptionConfig',
]) delete canonical[key];
return {
...canonical,
schemaVersion: STATE_SCHEMA_VERSION,
} as PersistedState & { schemaVersion: number };
}
export function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>;
export function createJsonStore(options: RawJsonStoreOptions): JsonStore<unknown>;
export function createJsonStore(options: JsonStoreOptions<unknown> | RawJsonStoreOptions): JsonStore<unknown> {
const {
filePath,
defaultValue,
initializeMissing = false,
backupWhen = () => false,
now = () => new Date(),
} = options;
const migrate = options.migrate || ((value: unknown) => value);
let recovery: RecoveryState | null = null;
let migration: MigrationState | null = null;
function write(value: unknown, writeOptions?: AtomicWriteOptions): unknown {
const migrated = migrate(clone(value));
atomicWriteJson(filePath, migrated, writeOptions);
return clone(migrated);
}
function read(): unknown {
if (!fs.existsSync(filePath)) {
const initial = migrate(clone(defaultValue));
return initializeMissing ? write(initial) : clone(initial);
}
const raw = fs.readFileSync(filePath, 'utf8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (cause) {
const backupPath = `${filePath}.corrupt-${stamp(now())}`;
fs.renameSync(filePath, backupPath);
try {
const recovered = write(defaultValue);
recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() };
return recovered;
} catch (error) {
fs.renameSync(backupPath, filePath);
throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`);
}
}
const migrated = migrate(clone(parsed));
if (JSON.stringify(migrated) !== JSON.stringify(parsed)) {
if (backupWhen(parsed, migrated)) {
const parsedRecord = record(parsed);
const fromVersion = Number.isSafeInteger(parsedRecord.schemaVersion) ? Number(parsedRecord.schemaVersion) : 0;
const migratedRecord = record(migrated);
const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`;
atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 });
migration = {
fromVersion,
toVersion: migratedRecord.schemaVersion,
backupPath,
migratedAt: now().toISOString(),
};
}
atomicWriteJson(filePath, migrated);
}
return clone(migrated);
}
function update(mutator: (value: unknown) => unknown): unknown {
// ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O.
const next = mutator(read());
if (next && typeof next === 'object' && 'then' in next) {
throw new TypeError('State store mutator must be synchronous');
}
return write(next);
}
return {
read,
write,
update,
remove: () => fs.rmSync(filePath, { force: true }),
get recovery() { return recovery; },
get migration() { return migration; },
};
}
export function createStateStore(
filePath: string,
options: Partial<Omit<JsonStoreOptions<PersistedState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> & {
legacySubscriptionCache?: unknown;
} = {},
) {
const { legacySubscriptionCache = null, ...storeOptions } = options;
return createJsonStore<PersistedState & { schemaVersion: number }>({
filePath,
defaultValue: migrateStoredState({}, legacySubscriptionCache),
migrate: (value) => migrateStoredState(value, legacySubscriptionCache),
initializeMissing: true,
backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion,
...storeOptions,
});
}