Update Harbor client and gateway integration workflows
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
export const ACTIVITY_JOURNAL_RETENTION_DAYS = 30;
|
||||
export const ACTIVITY_JOURNAL_MAX_EVENTS = 10_000;
|
||||
|
||||
export const ACTIVITY_EVENT_TYPES = [
|
||||
'connection.started', 'connection.stopped', 'connection.failed',
|
||||
'subscription.added', 'subscription.refreshed', 'subscription.refresh_failed', 'subscription.deleted',
|
||||
'failover.enabled', 'failover.disabled', 'failover.paused', 'failover.resumed',
|
||||
'failover.waiting_for_idle', 'failover.switched', 'failover.switch_failed',
|
||||
'failover.both_unhealthy', 'failover.recovered', 'journal.recovered',
|
||||
] as const;
|
||||
|
||||
export type ActivityEventType = typeof ACTIVITY_EVENT_TYPES[number];
|
||||
export type ActivityEventSeverity = 'info' | 'warning' | 'error';
|
||||
export type ActivityEventSource = 'connection' | 'subscription' | 'failover' | 'storage';
|
||||
|
||||
export interface ActivityJournalEvent {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
type: ActivityEventType | 'unknown';
|
||||
severity: ActivityEventSeverity;
|
||||
source: ActivityEventSource;
|
||||
dedupeKey: string | null;
|
||||
data: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
export type ActivityJournalEventInput = Omit<ActivityJournalEvent, 'id' | 'occurredAt'>;
|
||||
|
||||
export interface ActivityJournalPage {
|
||||
events: ActivityJournalEvent[];
|
||||
nextCursor: string | null;
|
||||
retentionDays: 30;
|
||||
generatedAt: string;
|
||||
storage: { status: 'ready' | 'error'; errorCode: string | null };
|
||||
}
|
||||
|
||||
const ALLOWED_DATA_KEYS: Record<ActivityEventType, readonly string[]> = {
|
||||
'connection.started': ['profileLabel', 'serverLabel'],
|
||||
'connection.stopped': [],
|
||||
'connection.failed': ['errorCode'],
|
||||
'subscription.added': ['profileId', 'profileLabel', 'host', 'serverCount'],
|
||||
'subscription.refreshed': ['profileId', 'profileLabel', 'host', 'serverCount', 'added', 'removed'],
|
||||
'subscription.refresh_failed': ['profileId', 'profileLabel', 'host', 'errorCode'],
|
||||
'subscription.deleted': ['profileId', 'profileLabel'],
|
||||
'failover.enabled': ['primaryLabel', 'reserveLabel'],
|
||||
'failover.disabled': [],
|
||||
'failover.paused': [],
|
||||
'failover.resumed': [],
|
||||
'failover.waiting_for_idle': ['fromRole', 'toRole', 'reason'],
|
||||
'failover.switched': ['fromRole', 'toRole', 'primaryLabel', 'reserveLabel', 'reason', 'manual'],
|
||||
'failover.switch_failed': ['fromRole', 'toRole', 'reason', 'errorCode'],
|
||||
'failover.both_unhealthy': ['reason'],
|
||||
'failover.recovered': ['role', 'reason'],
|
||||
'journal.recovered': [],
|
||||
};
|
||||
|
||||
const typeSet = new Set<string>(ACTIVITY_EVENT_TYPES);
|
||||
const severitySet = new Set(['info', 'warning', 'error']);
|
||||
const sourceSet = new Set(['connection', 'subscription', 'failover', 'storage']);
|
||||
const record = (value: unknown): Record<string, unknown> => (
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
);
|
||||
|
||||
const LABEL_KEYS = new Set(['profileLabel', 'serverLabel', 'primaryLabel', 'reserveLabel']);
|
||||
const ROLE_KEYS = new Set(['fromRole', 'toRole', 'role']);
|
||||
const SAFE_LABEL_FALLBACKS: Record<string, string> = {
|
||||
profileLabel: 'Подписка',
|
||||
serverLabel: 'Сервер',
|
||||
primaryLabel: 'Основной канал',
|
||||
reserveLabel: 'Резервный канал',
|
||||
};
|
||||
|
||||
function safeScalar(key: string, value: unknown) {
|
||||
if (value === null || typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value;
|
||||
if (typeof value !== 'string' || value.length > 120 || /[\r\n]/.test(value)) {
|
||||
throw new TypeError('Unsafe journal value');
|
||||
}
|
||||
if (LABEL_KEYS.has(key) && (
|
||||
!value.trim()
|
||||
|| /(?:[a-z][a-z0-9+.-]*:\/\/)|[\/?#@\\]/i.test(value)
|
||||
|| /(?:^|\D)(?:\d{1,3}\.){3}\d{1,3}(?:\D|$)/.test(value)
|
||||
|| /(?:^|[^0-9a-f])(?:[0-9a-f]{0,4}:){2,}[0-9a-f]{0,4}(?:[^0-9a-f]|$)/i.test(value)
|
||||
)) return SAFE_LABEL_FALLBACKS[key];
|
||||
if (ROLE_KEYS.has(key) && !['primary', 'reserve'].includes(value)) throw new TypeError('Unsafe journal role');
|
||||
if (key === 'errorCode' && !/^[A-Z0-9_]{1,50}$/.test(value)) throw new TypeError('Unsafe journal error code');
|
||||
if (key === 'reason' && !/^[a-z0-9-]{1,80}$/.test(value)) throw new TypeError('Unsafe journal reason');
|
||||
if (key === 'profileId' && !/^[a-zA-Z0-9_-]{1,80}$/.test(value)) throw new TypeError('Unsafe journal profile id');
|
||||
if (key === 'host' && (
|
||||
!/^[a-z0-9.-]{1,120}$/i.test(value)
|
||||
|| /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)
|
||||
|| value.includes('..')
|
||||
)) return 'Провайдер';
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeActivityEventInput(value: unknown): ActivityJournalEventInput {
|
||||
const candidate = record(value);
|
||||
const type = String(candidate.type || '') as ActivityEventType;
|
||||
const severity = String(candidate.severity || '') as ActivityEventSeverity;
|
||||
const source = String(candidate.source || '') as ActivityEventSource;
|
||||
if (!typeSet.has(type) || !severitySet.has(severity) || !sourceSet.has(source)) {
|
||||
throw new TypeError('Unknown journal event');
|
||||
}
|
||||
const inputData = record(candidate.data);
|
||||
const allowed = new Set(ALLOWED_DATA_KEYS[type]);
|
||||
if (Object.keys(inputData).some((key) => !allowed.has(key))) throw new TypeError('Unsafe journal data key');
|
||||
const data = Object.fromEntries(Object.entries(inputData).map(([key, item]) => [key, safeScalar(key, item)]));
|
||||
const dedupeKey = candidate.dedupeKey == null ? null : String(candidate.dedupeKey).trim();
|
||||
if (dedupeKey !== null && (
|
||||
!dedupeKey.startsWith(`${type}:`)
|
||||
|| !/^[a-zA-Z0-9_.:-]{1,160}$/.test(dedupeKey)
|
||||
)) {
|
||||
throw new TypeError('Invalid journal dedupe key');
|
||||
}
|
||||
return { type, severity, source, dedupeKey, data };
|
||||
}
|
||||
|
||||
export function normalizeStoredActivityEvent(value: unknown): ActivityJournalEvent | null {
|
||||
const candidate = record(value);
|
||||
const id = typeof candidate.id === 'string' && /^[a-f0-9-]{20,50}$/i.test(candidate.id) ? candidate.id : '';
|
||||
const occurredAt = typeof candidate.occurredAt === 'string' && Number.isFinite(Date.parse(candidate.occurredAt))
|
||||
? candidate.occurredAt
|
||||
: '';
|
||||
if (!id || !occurredAt) return null;
|
||||
try {
|
||||
return { id, occurredAt, ...normalizeActivityEventInput(candidate) };
|
||||
} catch {
|
||||
const severity = String(candidate.severity || '') as ActivityEventSeverity;
|
||||
const source = String(candidate.source || '') as ActivityEventSource;
|
||||
return severitySet.has(severity) && sourceSet.has(source) && typeof candidate.type === 'string'
|
||||
&& /^[a-z][a-z0-9_.-]{0,79}$/.test(candidate.type)
|
||||
? { id, occurredAt, type: 'unknown', severity, source, dedupeKey: null, data: {} }
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertActivityJournalPage(value: unknown): ActivityJournalPage {
|
||||
const candidate = record(value);
|
||||
const events = Array.isArray(candidate.events) ? candidate.events.map(normalizeStoredActivityEvent) : [];
|
||||
const storage = record(candidate.storage);
|
||||
if (
|
||||
!Array.isArray(candidate.events) || events.some((event) => event === null)
|
||||
|| !(candidate.nextCursor === null || typeof candidate.nextCursor === 'string')
|
||||
|| candidate.retentionDays !== ACTIVITY_JOURNAL_RETENTION_DAYS
|
||||
|| typeof candidate.generatedAt !== 'string' || !Number.isFinite(Date.parse(candidate.generatedAt))
|
||||
|| !['ready', 'error'].includes(String(storage.status || ''))
|
||||
|| !(storage.errorCode === null || typeof storage.errorCode === 'string')
|
||||
) throw new TypeError('Invalid activity journal page');
|
||||
return {
|
||||
events: events as ActivityJournalEvent[],
|
||||
nextCursor: candidate.nextCursor as string | null,
|
||||
retentionDays: 30,
|
||||
generatedAt: candidate.generatedAt,
|
||||
storage: { status: storage.status as 'ready' | 'error', errorCode: storage.errorCode as string | null },
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,16 @@ import {
|
||||
normalizeDiagnosticSettings,
|
||||
type DiagnosticSettings,
|
||||
} from '../connectivityDiagnostics.js';
|
||||
import {
|
||||
createIdleFailoverSnapshot,
|
||||
normalizeAppliedFailoverPolicy,
|
||||
normalizeFailoverPolicy,
|
||||
normalizeFailoverRuntimeState,
|
||||
type AppliedFailoverPolicy,
|
||||
type FailoverPolicy,
|
||||
type FailoverRuntimeState,
|
||||
type FailoverSnapshot,
|
||||
} from '../failover.js';
|
||||
|
||||
export type HarborMode = 'client' | 'gateway';
|
||||
export type ConnectionState = 'running' | 'stopped';
|
||||
@@ -83,6 +93,7 @@ export interface StateSnapshot {
|
||||
lastError: string | null;
|
||||
};
|
||||
diagnostics: DiagnosticSettings;
|
||||
failover: FailoverSnapshot;
|
||||
route: {
|
||||
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
||||
mode: string;
|
||||
@@ -122,6 +133,9 @@ export interface PersistedState extends Record<string, unknown> {
|
||||
connectionDesired?: ConnectionState;
|
||||
gatewayAutoEnabled?: boolean;
|
||||
diagnostics: DiagnosticSettings;
|
||||
failoverPolicy: FailoverPolicy;
|
||||
failoverRuntimeState: FailoverRuntimeState;
|
||||
appliedFailoverPolicy: AppliedFailoverPolicy | null;
|
||||
}
|
||||
|
||||
// Legacy fields are derived in memory for bounded callers during the v5 cutover.
|
||||
@@ -293,6 +307,9 @@ export function normalizeStoredState(value: unknown): StoredState {
|
||||
? state.routeRulesRevision
|
||||
: 0,
|
||||
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
|
||||
failoverPolicy: normalizeFailoverPolicy(state.failoverPolicy),
|
||||
failoverRuntimeState: normalizeFailoverRuntimeState(state.failoverRuntimeState),
|
||||
appliedFailoverPolicy: normalizeAppliedFailoverPolicy(state.appliedFailoverPolicy),
|
||||
subscriptionUrl: selectedProfile?.subscriptionUrl || '',
|
||||
selectedServerId,
|
||||
selectedTag: selectedServer?.label || '',
|
||||
@@ -327,6 +344,7 @@ export function createStateSnapshot({
|
||||
appMode,
|
||||
configExists,
|
||||
operation = { kind: null, status: 'idle', startedAt: null, error: null },
|
||||
failoverSnapshot,
|
||||
now = new Date(),
|
||||
}: {
|
||||
storedState: unknown;
|
||||
@@ -336,6 +354,7 @@ export function createStateSnapshot({
|
||||
configExists: boolean;
|
||||
subscriptionHost?: string;
|
||||
operation?: OperationState;
|
||||
failoverSnapshot?: FailoverSnapshot | null;
|
||||
now?: Date;
|
||||
}): StateSnapshot {
|
||||
const stored = normalizeStoredState(storedState);
|
||||
@@ -391,6 +410,7 @@ export function createStateSnapshot({
|
||||
lastError: null,
|
||||
},
|
||||
diagnostics: stored.diagnostics,
|
||||
failover: failoverSnapshot || createIdleFailoverSnapshot(stored.failoverPolicy),
|
||||
route: {
|
||||
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
|
||||
mode: routeMode,
|
||||
@@ -429,18 +449,24 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
||||
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
|
||||
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
|
||||
: rawCandidate;
|
||||
const candidate = candidateWithDiagnostics?.route?.rulesContractVersion === undefined
|
||||
&& Array.isArray(candidateWithDiagnostics?.route?.localRules)
|
||||
&& Array.isArray(candidateWithDiagnostics?.route?.activeLocalRules)
|
||||
const candidateWithFailover = candidateWithDiagnostics && candidateWithDiagnostics.failover === undefined
|
||||
? {
|
||||
...candidateWithDiagnostics,
|
||||
route: {
|
||||
...candidateWithDiagnostics.route,
|
||||
localRules: candidateWithDiagnostics.route.localRules.map(legacyRule),
|
||||
activeLocalRules: candidateWithDiagnostics.route.activeLocalRules.map(legacyRule),
|
||||
},
|
||||
failover: createIdleFailoverSnapshot(normalizeFailoverPolicy(null)),
|
||||
}
|
||||
: candidateWithDiagnostics;
|
||||
const candidate = candidateWithFailover?.route?.rulesContractVersion === undefined
|
||||
&& Array.isArray(candidateWithFailover?.route?.localRules)
|
||||
&& Array.isArray(candidateWithFailover?.route?.activeLocalRules)
|
||||
? {
|
||||
...candidateWithFailover,
|
||||
route: {
|
||||
...candidateWithFailover.route,
|
||||
localRules: candidateWithFailover.route.localRules.map(legacyRule),
|
||||
activeLocalRules: candidateWithFailover.route.activeLocalRules.map(legacyRule),
|
||||
},
|
||||
}
|
||||
: candidateWithFailover;
|
||||
const validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
const nullableDate = (value: unknown) => value === null || validDate(value);
|
||||
const nullableString = (value: unknown) => value === null || typeof value === 'string';
|
||||
@@ -486,6 +512,37 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const validFailover = (value: FailoverSnapshot) => {
|
||||
const channel = (item: FailoverSnapshot['primary']) => (
|
||||
item && typeof item.target?.profileId === 'string' && typeof item.target?.serverId === 'string'
|
||||
&& ['healthy', 'unhealthy', 'unknown', 'not-monitoring'].includes(item.health)
|
||||
&& Array.isArray(item.failingServiceIds) && item.failingServiceIds.every((id) => typeof id === 'string')
|
||||
&& nullableDate(item.checkedAt) && nullableDate(item.stateSince)
|
||||
);
|
||||
const activity = value.trafficActivity;
|
||||
try {
|
||||
normalizeFailoverPolicy(value.policy, { strict: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return channel(value.primary) && channel(value.reserve)
|
||||
&& nullableDate(value.nextDecisionAt) && nullableString(value.reason)
|
||||
&& (activity === null || (
|
||||
['active', 'quiet', 'unknown'].includes(activity.state)
|
||||
&& validDate(activity.observedAt)
|
||||
&& [activity.windowMs, activity.thresholdBytesPerSecond, activity.totalBytesPerSecond, activity.transmittingConnections]
|
||||
.every((number) => Number.isFinite(number) && number >= 0)
|
||||
&& nullableDate(activity.quietSince)
|
||||
&& (activity.switchTarget === null || ['primary', 'reserve'].includes(activity.switchTarget))
|
||||
&& Array.isArray(activity.blockers)
|
||||
&& activity.blockers.length <= 3
|
||||
&& activity.blockers.every((blocker) => (
|
||||
typeof blocker.device === 'string' && typeof blocker.service === 'string'
|
||||
&& Number.isFinite(blocker.uploadBytesPerSecond) && blocker.uploadBytesPerSecond >= 0
|
||||
&& Number.isFinite(blocker.downloadBytesPerSecond) && blocker.downloadBytesPerSecond >= 0
|
||||
))
|
||||
));
|
||||
};
|
||||
|
||||
if (
|
||||
!snapshot ||
|
||||
@@ -516,6 +573,18 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
||||
!nullableDate(candidate.connection.startedAt) ||
|
||||
!nullableString(candidate.connection.lastError) ||
|
||||
!validDiagnostics(candidate.diagnostics) ||
|
||||
!candidate.failover ||
|
||||
typeof candidate.failover.observationEpoch !== 'string' ||
|
||||
!Number.isSafeInteger(candidate.failover.observationSequence) ||
|
||||
candidate.failover.observationSequence < 0 ||
|
||||
typeof candidate.failover.enabled !== 'boolean' ||
|
||||
typeof candidate.failover.paused !== 'boolean' ||
|
||||
typeof candidate.failover.configured !== 'boolean' ||
|
||||
!candidate.failover.policy ||
|
||||
!['inactive', 'active', 'pending', 'passive-loaded'].includes(candidate.failover.activation) ||
|
||||
!['primary', 'reserve', 'other', 'none'].includes(candidate.failover.currentRole) ||
|
||||
!['idle', 'observing', 'primary', 'reserve', 'waiting-for-idle', 'blocked', 'switching', 'error'].includes(candidate.failover.status) ||
|
||||
!validFailover(candidate.failover) ||
|
||||
!candidate.route ||
|
||||
![undefined, ROUTE_RULES_CONTRACT_VERSION].includes(candidate.route.rulesContractVersion) ||
|
||||
typeof candidate.route.mode !== 'string' ||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
export type FailoverRole = 'primary' | 'reserve';
|
||||
export type FailoverHealth = 'healthy' | 'unhealthy' | 'unknown' | 'not-monitoring';
|
||||
|
||||
export interface FailoverTarget {
|
||||
profileId: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export interface FailoverCheck {
|
||||
serviceId: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface FailoverPolicy {
|
||||
version: 1;
|
||||
enabled: boolean;
|
||||
paused: boolean;
|
||||
primary: FailoverTarget;
|
||||
reserve: FailoverTarget;
|
||||
checks: FailoverCheck[];
|
||||
intervalMs: number;
|
||||
failureWindowMs: number;
|
||||
recoveryWindowMs: number;
|
||||
trafficGuard: {
|
||||
enabled: boolean;
|
||||
thresholdBytesPerSecond: number;
|
||||
quietWindowMs: number;
|
||||
};
|
||||
minimumReserveMs: number;
|
||||
flapProtection: {
|
||||
count: number;
|
||||
windowMs: number;
|
||||
quarantineMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FailoverRuntimeState {
|
||||
lastSwitchAt: string | null;
|
||||
holdUntil: string | null;
|
||||
primaryQuarantineUntil: string | null;
|
||||
failoverHistory: string[];
|
||||
reasonCode: string | null;
|
||||
}
|
||||
|
||||
export interface AppliedFailoverPolicy {
|
||||
primary: FailoverTarget;
|
||||
reserve: FailoverTarget;
|
||||
primaryConfigFingerprint: string;
|
||||
reserveConfigFingerprint: string;
|
||||
}
|
||||
|
||||
export interface FailoverActivity {
|
||||
state: 'active' | 'quiet' | 'unknown';
|
||||
observedAt: string;
|
||||
windowMs: number;
|
||||
thresholdBytesPerSecond: number;
|
||||
totalBytesPerSecond: number;
|
||||
transmittingConnections: number;
|
||||
quietSince: string | null;
|
||||
switchTarget: FailoverRole | null;
|
||||
blockers: Array<{
|
||||
device: string;
|
||||
service: string;
|
||||
uploadBytesPerSecond: number;
|
||||
downloadBytesPerSecond: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FailoverSnapshot {
|
||||
observationEpoch: string;
|
||||
observationSequence: number;
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
paused: boolean;
|
||||
activation: 'inactive' | 'active' | 'pending' | 'passive-loaded';
|
||||
currentRole: FailoverRole | 'other' | 'none';
|
||||
status: 'idle' | 'observing' | 'primary' | 'reserve' | 'waiting-for-idle' | 'blocked' | 'switching' | 'error';
|
||||
primary: { target: FailoverTarget; health: FailoverHealth; failingServiceIds: string[]; checkedAt: string | null; stateSince: string | null };
|
||||
reserve: { target: FailoverTarget; health: FailoverHealth; failingServiceIds: string[]; checkedAt: string | null; stateSince: string | null };
|
||||
nextDecisionAt: string | null;
|
||||
reason: string | null;
|
||||
trafficActivity: FailoverActivity | null;
|
||||
policy: FailoverPolicy;
|
||||
}
|
||||
|
||||
export interface FailoverDecisionMemory {
|
||||
primaryFailedSince: number | null;
|
||||
primaryRecoveredSince: number | null;
|
||||
quietSince: number | null;
|
||||
}
|
||||
|
||||
export interface FailoverDecisionInput {
|
||||
now: number;
|
||||
policy: FailoverPolicy;
|
||||
currentRole: FailoverRole;
|
||||
primaryHealth: Exclude<FailoverHealth, 'not-monitoring'>;
|
||||
reserveHealth: Exclude<FailoverHealth, 'not-monitoring'>;
|
||||
activity: 'active' | 'quiet' | 'unknown';
|
||||
holdUntil?: number | null;
|
||||
primaryQuarantineUntil?: number | null;
|
||||
memory?: FailoverDecisionMemory;
|
||||
}
|
||||
|
||||
export interface FailoverDecision {
|
||||
status: FailoverSnapshot['status'];
|
||||
reason: string;
|
||||
switchTo: FailoverRole | null;
|
||||
memory: FailoverDecisionMemory;
|
||||
nextDecisionAt: number | null;
|
||||
}
|
||||
|
||||
const text = (value: unknown) => typeof value === 'string' ? value.trim() : '';
|
||||
const record = (value: unknown): Record<string, unknown> => (
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
);
|
||||
const bounded = (value: unknown, fallback: number, minimum: number, maximum: number) => {
|
||||
const candidate = Number(value);
|
||||
return Number.isFinite(candidate) ? Math.min(maximum, Math.max(minimum, Math.round(candidate))) : fallback;
|
||||
};
|
||||
const equivalent = (left: unknown, right: unknown): boolean => {
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left) && Array.isArray(right)
|
||||
&& left.length === right.length
|
||||
&& left.every((value, index) => equivalent(value, right[index]));
|
||||
}
|
||||
if (left && right && typeof left === 'object' && typeof right === 'object') {
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const leftKeys = Object.keys(leftRecord).sort();
|
||||
const rightKeys = Object.keys(rightRecord).sort();
|
||||
return leftKeys.length === rightKeys.length
|
||||
&& leftKeys.every((key, index) => key === rightKeys[index] && equivalent(leftRecord[key], rightRecord[key]));
|
||||
}
|
||||
return Object.is(left, right);
|
||||
};
|
||||
|
||||
export const DEFAULT_FAILOVER_POLICY: FailoverPolicy = Object.freeze({
|
||||
version: 1,
|
||||
enabled: false,
|
||||
paused: false,
|
||||
primary: { profileId: '', serverId: '' },
|
||||
reserve: { profileId: '', serverId: '' },
|
||||
checks: [{ serviceId: 'youtube', timeoutMs: 6_000 }, { serviceId: 'google', timeoutMs: 6_000 }],
|
||||
intervalMs: 60_000,
|
||||
failureWindowMs: 120_000,
|
||||
recoveryWindowMs: 900_000,
|
||||
trafficGuard: { enabled: true, thresholdBytesPerSecond: 32 * 1024, quietWindowMs: 30_000 },
|
||||
minimumReserveMs: 600_000,
|
||||
flapProtection: { count: 3, windowMs: 86_400_000, quarantineMs: 21_600_000 },
|
||||
});
|
||||
|
||||
export const DEFAULT_FAILOVER_RUNTIME_STATE: FailoverRuntimeState = Object.freeze({
|
||||
lastSwitchAt: null,
|
||||
holdUntil: null,
|
||||
primaryQuarantineUntil: null,
|
||||
failoverHistory: [],
|
||||
reasonCode: null,
|
||||
});
|
||||
|
||||
function target(value: unknown): FailoverTarget {
|
||||
const candidate = record(value);
|
||||
return { profileId: text(candidate.profileId), serverId: text(candidate.serverId) };
|
||||
}
|
||||
|
||||
export function normalizeFailoverPolicy(value: unknown, { strict = false } = {}): FailoverPolicy {
|
||||
const candidate = record(value);
|
||||
const requestedChecks = Array.isArray(candidate.checks) ? candidate.checks : DEFAULT_FAILOVER_POLICY.checks;
|
||||
const checks = requestedChecks.map((value) => {
|
||||
const check = record(value);
|
||||
return {
|
||||
serviceId: text(check.serviceId).slice(0, 100),
|
||||
timeoutMs: bounded(check.timeoutMs, 6_000, 2_000, 30_000),
|
||||
};
|
||||
}).filter(({ serviceId }, index, all) => serviceId && all.findIndex((item) => item.serviceId === serviceId) === index).slice(0, 10);
|
||||
const trafficGuard = record(candidate.trafficGuard);
|
||||
const flapProtection = record(candidate.flapProtection);
|
||||
const policy: FailoverPolicy = {
|
||||
version: 1,
|
||||
enabled: candidate.enabled === true,
|
||||
paused: candidate.paused === true,
|
||||
primary: target(candidate.primary),
|
||||
reserve: target(candidate.reserve),
|
||||
checks,
|
||||
intervalMs: bounded(candidate.intervalMs, 60_000, 15_000, 900_000),
|
||||
failureWindowMs: 0,
|
||||
recoveryWindowMs: bounded(candidate.recoveryWindowMs, 900_000, 60_000, 86_400_000),
|
||||
trafficGuard: {
|
||||
enabled: trafficGuard.enabled !== false,
|
||||
thresholdBytesPerSecond: bounded(trafficGuard.thresholdBytesPerSecond, 32 * 1024, 1024, 100 * 1024 * 1024),
|
||||
quietWindowMs: bounded(trafficGuard.quietWindowMs, 30_000, 5_000, 600_000),
|
||||
},
|
||||
minimumReserveMs: bounded(candidate.minimumReserveMs, 600_000, 60_000, 86_400_000),
|
||||
flapProtection: {
|
||||
count: bounded(flapProtection.count, 3, 2, 10),
|
||||
windowMs: bounded(flapProtection.windowMs, 86_400_000, 3_600_000, 72 * 3_600_000),
|
||||
quarantineMs: bounded(flapProtection.quarantineMs, 21_600_000, 600_000, 7 * 86_400_000),
|
||||
},
|
||||
};
|
||||
policy.failureWindowMs = bounded(
|
||||
candidate.failureWindowMs,
|
||||
120_000,
|
||||
policy.intervalMs * 2,
|
||||
1_800_000,
|
||||
);
|
||||
if (strict && !equivalent(policy, value)) {
|
||||
throw new TypeError('Invalid failover policy');
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
export function normalizeFailoverRuntimeState(value: unknown): FailoverRuntimeState {
|
||||
const candidate = record(value);
|
||||
const date = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null;
|
||||
return {
|
||||
lastSwitchAt: date(candidate.lastSwitchAt),
|
||||
holdUntil: date(candidate.holdUntil),
|
||||
primaryQuarantineUntil: date(candidate.primaryQuarantineUntil),
|
||||
failoverHistory: (Array.isArray(candidate.failoverHistory) ? candidate.failoverHistory : [])
|
||||
.map(date).filter((item): item is string => Boolean(item)).slice(-20),
|
||||
reasonCode: text(candidate.reasonCode) || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAppliedFailoverPolicy(value: unknown): AppliedFailoverPolicy | null {
|
||||
if (!value) return null;
|
||||
const candidate = record(value);
|
||||
const primary = target(candidate.primary);
|
||||
const reserve = target(candidate.reserve);
|
||||
const primaryConfigFingerprint = text(candidate.primaryConfigFingerprint);
|
||||
const reserveConfigFingerprint = text(candidate.reserveConfigFingerprint);
|
||||
return primary.profileId && primary.serverId && reserve.profileId && reserve.serverId
|
||||
&& /^[a-f0-9]{64}$/.test(primaryConfigFingerprint)
|
||||
&& /^[a-f0-9]{64}$/.test(reserveConfigFingerprint)
|
||||
? { primary, reserve, primaryConfigFingerprint, reserveConfigFingerprint }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isFailoverConfigured(policy: FailoverPolicy) {
|
||||
return Boolean(
|
||||
policy.primary.profileId && policy.primary.serverId
|
||||
&& policy.reserve.profileId && policy.reserve.serverId
|
||||
&& policy.checks.length
|
||||
&& (policy.primary.profileId !== policy.reserve.profileId
|
||||
|| policy.primary.serverId !== policy.reserve.serverId)
|
||||
);
|
||||
}
|
||||
|
||||
export function createIdleFailoverSnapshot(
|
||||
policy: FailoverPolicy,
|
||||
observationEpoch = '',
|
||||
observationSequence = 0,
|
||||
): FailoverSnapshot {
|
||||
const channel = (target: FailoverTarget) => ({
|
||||
target,
|
||||
health: 'not-monitoring' as const,
|
||||
failingServiceIds: [],
|
||||
checkedAt: null,
|
||||
stateSince: null,
|
||||
});
|
||||
return {
|
||||
observationEpoch,
|
||||
observationSequence,
|
||||
configured: isFailoverConfigured(policy),
|
||||
enabled: policy.enabled,
|
||||
paused: policy.paused,
|
||||
activation: 'inactive',
|
||||
currentRole: 'none',
|
||||
status: 'idle',
|
||||
primary: channel(policy.primary),
|
||||
reserve: channel(policy.reserve),
|
||||
nextDecisionAt: null,
|
||||
reason: null,
|
||||
trafficActivity: null,
|
||||
policy,
|
||||
};
|
||||
}
|
||||
|
||||
export function nextFailoverDecision(input: FailoverDecisionInput): FailoverDecision {
|
||||
const memory = input.memory || {
|
||||
primaryFailedSince: null,
|
||||
primaryRecoveredSince: null,
|
||||
quietSince: null,
|
||||
};
|
||||
const next = { ...memory };
|
||||
if (!input.policy.enabled || input.policy.paused) {
|
||||
return { status: 'idle', reason: input.policy.paused ? 'paused' : 'disabled', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
if (input.primaryHealth === 'unhealthy' && input.reserveHealth === 'unhealthy') {
|
||||
next.quietSince = null;
|
||||
return { status: 'blocked', reason: 'both-unhealthy', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
|
||||
let target: FailoverRole | null = null;
|
||||
let readyAt: number | null = null;
|
||||
if (input.currentRole === 'primary') {
|
||||
next.primaryRecoveredSince = null;
|
||||
if (input.primaryHealth !== 'unhealthy') {
|
||||
next.primaryFailedSince = null;
|
||||
next.quietSince = null;
|
||||
return { status: 'primary', reason: input.primaryHealth === 'healthy' ? 'primary-healthy' : 'health-unknown', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
next.primaryFailedSince ??= input.now;
|
||||
readyAt = next.primaryFailedSince + input.policy.failureWindowMs;
|
||||
if (input.now < readyAt || input.reserveHealth !== 'healthy') {
|
||||
next.quietSince = null;
|
||||
return { status: 'observing', reason: input.reserveHealth === 'healthy' ? 'failure-window' : 'reserve-not-healthy', switchTo: null, memory: next, nextDecisionAt: readyAt };
|
||||
}
|
||||
target = 'reserve';
|
||||
} else {
|
||||
next.primaryFailedSince = null;
|
||||
if (input.primaryHealth !== 'healthy') {
|
||||
next.primaryRecoveredSince = null;
|
||||
next.quietSince = null;
|
||||
return { status: 'reserve', reason: 'primary-not-recovered', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
if ((input.primaryQuarantineUntil || 0) > input.now) {
|
||||
next.primaryRecoveredSince = null;
|
||||
next.quietSince = null;
|
||||
return {
|
||||
status: 'reserve',
|
||||
reason: 'recovery-hold',
|
||||
switchTo: null,
|
||||
memory: next,
|
||||
nextDecisionAt: input.primaryQuarantineUntil || null,
|
||||
};
|
||||
}
|
||||
next.primaryRecoveredSince ??= input.now;
|
||||
const recoveredAt = next.primaryRecoveredSince + input.policy.recoveryWindowMs;
|
||||
readyAt = input.reserveHealth === 'unhealthy'
|
||||
? recoveredAt
|
||||
: Math.max(recoveredAt, input.holdUntil || 0, input.primaryQuarantineUntil || 0);
|
||||
if (input.now < readyAt) {
|
||||
next.quietSince = null;
|
||||
return { status: 'reserve', reason: 'recovery-hold', switchTo: null, memory: next, nextDecisionAt: readyAt };
|
||||
}
|
||||
target = 'primary';
|
||||
}
|
||||
|
||||
if (input.policy.trafficGuard.enabled) {
|
||||
if (input.activity === 'unknown') {
|
||||
next.quietSince = null;
|
||||
return { status: 'blocked', reason: 'activity-unknown', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
if (input.activity === 'active') {
|
||||
next.quietSince = null;
|
||||
return { status: 'waiting-for-idle', reason: 'active-traffic', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
next.quietSince ??= input.now;
|
||||
readyAt = next.quietSince + input.policy.trafficGuard.quietWindowMs;
|
||||
if (input.now < readyAt) {
|
||||
return { status: 'waiting-for-idle', reason: 'quiet-window', switchTo: null, memory: next, nextDecisionAt: readyAt };
|
||||
}
|
||||
}
|
||||
return { status: 'switching', reason: target === 'reserve' ? 'primary-failed' : 'primary-recovered', switchTo: target, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.27.0',
|
||||
gatewayClient: '0.28.0',
|
||||
gatewayBackend: '0.28.0',
|
||||
macClient: '0.28.0',
|
||||
gatewayClient: '0.29.0',
|
||||
gatewayBackend: '0.29.0',
|
||||
});
|
||||
|
||||
export interface ParsedVersion {
|
||||
|
||||
Reference in New Issue
Block a user