Update Harbor client and gateway integration workflows
Build and Deploy Gateway / build-and-push (push) Failing after 14s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-19 18:16:10 +03:00
parent 416b2b294a
commit daec12e013
63 changed files with 5016 additions and 108 deletions
+156
View File
@@ -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 },
};
}
+77 -8
View File
@@ -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' ||
+355
View File
@@ -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 };
}
+3 -3
View File
@@ -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 {