import { randomUUID } from "node:crypto"; export const MAINTENANCE_NAMESPACE = "routing"; export const MAINTENANCE_STATE_NAMESPACE = "kuber-system"; export const MAINTENANCE_ROUTE_NAME = "maintenance-override"; export const MAINTENANCE_STATE_NAME = "maintenance-override"; export type MaintenanceStatus = { host: string; enabled: boolean; hosts: string[]; }; export interface MaintenancePersistence { readState(): Promise<{ hosts: string[] } | undefined>; writeState(value: { hosts: string[] }): Promise; routeExists(): Promise; apply(resource: Record): Promise; deleteRoute(): Promise; } export interface MaintenanceLeaseProvider { acquire( name: string, holder: string, ttlMs?: number, ): Promise<{ release(): Promise } | undefined>; } /** Accept DNS hostnames only: no URL syntax, port, address literals, or wildcards. */ export function normalizeMaintenanceHost(value: unknown): string { if (typeof value !== "string") throw new Error("host is required"); const host = value.trim().toLowerCase().replace(/\.$/, ""); if ( host.length === 0 || host.length > 253 || !host.includes(".") || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(host) ) throw new Error("host must be a DNS hostname"); return host; } export function maintenanceMiddleware(): Record { return { apiVersion: "traefik.io/v1alpha1", kind: "Middleware", metadata: { name: MAINTENANCE_ROUTE_NAME, namespace: MAINTENANCE_NAMESPACE, }, spec: { replacePathRegex: { regex: "^/.*", replacement: "/__error/1001" } }, }; } export function maintenanceRoute(hosts: string[]): Record { return { apiVersion: "traefik.io/v1alpha1", kind: "IngressRoute", metadata: { name: MAINTENANCE_ROUTE_NAME, namespace: MAINTENANCE_NAMESPACE, }, spec: { routes: [ { kind: "Rule", match: hosts.map((host) => `Host(\`${host}\`)`).join(" || "), // This must override every workspace route for an affected host. priority: 1_000_000, middlewares: [ { name: MAINTENANCE_ROUTE_NAME, namespace: MAINTENANCE_NAMESPACE }, ], services: [ { name: "error-page", namespace: MAINTENANCE_NAMESPACE, port: 3000, scheme: "http", }, ], }, ], }, }; } export class MaintenanceBusyError extends Error { readonly code = "MAINTENANCE_BUSY"; } export class MaintenanceService { constructor( private readonly persistence: MaintenancePersistence, private readonly leases: MaintenanceLeaseProvider, ) {} async status(host: string): Promise { const normalized = normalizeMaintenanceHost(host); const hosts = this.hosts(await this.persistence.readState()); await this.reconcile(hosts); return { host: normalized, enabled: hosts.includes(normalized) && (await this.persistence.routeExists()), hosts, }; } async toggle(host: string): Promise { return this.update(host); } /** Setting the desired state makes a retried mutation safe after a timeout. */ async set(host: string, enabled: boolean): Promise { return this.update(host, enabled); } private async update( host: string, enabled?: boolean, ): Promise { const normalized = normalizeMaintenanceHost(host); const lease = await this.leases.acquire( "maintenance-override", randomUUID(), ); if (!lease) throw new MaintenanceBusyError("Maintenance state is busy; retry shortly"); try { const current = this.hosts(await this.persistence.readState()); const targetEnabled = enabled ?? !current.includes(normalized); const hosts = targetEnabled ? [...new Set([...current, normalized])].sort() : current.filter((item) => item !== normalized); await this.reconcile(hosts); // Persist only after Traefik has accepted the desired override. If this // write fails, the next reconciliation restores the prior desired state. await this.persistence.writeState({ hosts }); return { host: normalized, enabled: targetEnabled, hosts }; } finally { await lease.release(); } } async reconcile(hosts?: string[]): Promise { const desired = hosts ?? this.hosts(await this.persistence.readState()); await this.persistence.apply(maintenanceMiddleware()); if (desired.length) await this.persistence.apply(maintenanceRoute(desired)); else await this.persistence.deleteRoute(); } private hosts(value: { hosts: string[] } | undefined): string[] { if (!value || !Array.isArray(value.hosts)) return []; return [ ...new Set(value.hosts.map((host) => normalizeMaintenanceHost(host))), ].sort(); } }