159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
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<void>;
|
|
routeExists(): Promise<boolean>;
|
|
apply(resource: Record<string, unknown>): Promise<void>;
|
|
deleteRoute(): Promise<void>;
|
|
}
|
|
|
|
export interface MaintenanceLeaseProvider {
|
|
acquire(
|
|
name: string,
|
|
holder: string,
|
|
ttlMs?: number,
|
|
): Promise<{ release(): Promise<void> } | 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(/\.$/, "");
|
|
console.log(host)
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<MaintenanceStatus> {
|
|
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<MaintenanceStatus> {
|
|
return this.update(host);
|
|
}
|
|
|
|
/** Setting the desired state makes a retried mutation safe after a timeout. */
|
|
async set(host: string, enabled: boolean): Promise<MaintenanceStatus> {
|
|
return this.update(host, enabled);
|
|
}
|
|
|
|
private async update(
|
|
host: string,
|
|
enabled?: boolean,
|
|
): Promise<MaintenanceStatus> {
|
|
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<void> {
|
|
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();
|
|
}
|
|
}
|