Files
pikasTech-unidesk/scripts/src/hwlab-node-web-probe-monitor.ts
T
2026-07-12 23:35:21 +00:00

71 lines
10 KiB
TypeScript

// SPEC: PJ2026-01060508 Web哨兵 Release A.
// Responsibility: YAML-first PG-capable central Monitor manifests while SQLite remains authoritative.
import { readWebProbeSentinelConfigRef } from "./hwlab-node-web-sentinel-config-ref";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import type { MonitorSqliteSource } from "./monitor-sqlite-migration";
export function releaseAMonitorManifests(spec: HwlabRuntimeLaneSpec, imageRef: string, resolvedSource: MonitorSqliteSource, options: { readonly migrationJobEnabled?: boolean } = {}): readonly Record<string, unknown>[] {
const config = releaseAMonitorConfig(spec, resolvedSource);
const { resolved, runtime, namespace, deploymentName, port, secret, pool, labels } = config;
requireImageRef(imageRef);
const env = [{ name: "DATABASE_URL", valueFrom: { secretKeyRef: { name: textAt(secret, "name", resolved.ref), key: textAt(secret, "key", resolved.ref) } } }, { name: "MONITOR_CENTRAL_HOST", value: "0.0.0.0" }, { name: "MONITOR_CENTRAL_PORT", value: String(port) }, { name: "MONITOR_CENTRAL_PG_POOL_MAX", value: String(integerAt(pool, "max", resolved.ref)) }, { name: "MONITOR_CENTRAL_PG_IDLE_TIMEOUT_SECONDS", value: String(integerAt(pool, "idleTimeoutSeconds", resolved.ref)) }, { name: "MONITOR_CENTRAL_PAGE_SIZE", value: String(integerAt(runtime, "pageSize", resolved.ref)) }];
const monitorLabels = { ...labels, "app.kubernetes.io/component": "monitor-service" };
const manifests = [
{ apiVersion: "v1", kind: "Service", metadata: { name: textAt(runtime, "serviceName", resolved.ref), namespace, labels: monitorLabels }, spec: { type: "ClusterIP", selector: { "app.kubernetes.io/name": deploymentName, "app.kubernetes.io/component": "monitor-service" }, ports: [{ name: "http", port, targetPort: "http" }] } },
{ apiVersion: "apps/v1", kind: "Deployment", metadata: { name: deploymentName, namespace, labels: monitorLabels }, spec: { replicas: integerAt(runtime, "replicas", resolved.ref), selector: { matchLabels: { "app.kubernetes.io/name": deploymentName, "app.kubernetes.io/component": "monitor-service" } }, template: { metadata: { labels: monitorLabels }, spec: { containers: [{ name: "monitor", image: imageRef, command: ["bun"], args: ["scripts/monitor-central-service.ts"], env, ports: [{ name: "http", containerPort: port }], readinessProbe: { httpGet: { path: textAt(runtime, "healthPath", resolved.ref), port: "http" } } }] } } } },
];
const migrationJob = releaseAMonitorMigrationJob(spec, imageRef, resolvedSource, { enabled: options.migrationJobEnabled });
return migrationJob === null ? manifests : [...manifests, migrationJob];
}
export function releaseAMonitorMigrationJob(spec: HwlabRuntimeLaneSpec, imageRef: string, resolvedSource: MonitorSqliteSource, options: { readonly enabled?: boolean } = {}): Record<string, unknown> | null {
const config = releaseAMonitorConfig(spec, resolvedSource);
const { resolved, runtime, job, namespace, secret, pool, labels } = config;
requireImageRef(imageRef);
if ((options.enabled ?? job.enabled) !== true) return null;
const migrationLabels = { ...labels, "app.kubernetes.io/component": "migration" };
const mounts = [{ name: "legacy-sqlite", mountPath: resolvedSource.stateRoot, readOnly: true }, { name: "snapshot", mountPath: "/migration" }];
const env = [{ name: "DATABASE_URL", valueFrom: { secretKeyRef: { name: textAt(secret, "name", resolved.ref), key: textAt(secret, "key", resolved.ref) } } }, { name: "MONITOR_CENTRAL_PG_POOL_MAX", value: String(integerAt(pool, "max", resolved.ref)) }, { name: "MONITOR_CENTRAL_PG_IDLE_TIMEOUT_SECONDS", value: String(integerAt(pool, "idleTimeoutSeconds", resolved.ref)) }];
const cli = ["scripts/cli.ts", "web-probe", "sentinel", "migration"];
const sourceArgs = ["--node", spec.nodeId, "--lane", spec.lane, "--sentinel", resolvedSource.sentinelId, "--snapshot", "/migration/snapshot.json"];
return { apiVersion: "batch/v1", kind: "Job", metadata: { name: versionedJobName(textAt(job, "name", resolved.ref), imageRef), namespace, labels: migrationLabels }, spec: { backoffLimit: 0, template: { metadata: { labels: migrationLabels }, spec: { restartPolicy: "Never", initContainers: [{ name: "export", image: imageRef, command: ["bun"], args: [...cli, "export", ...sourceArgs], volumeMounts: mounts }, { name: "import", image: imageRef, command: ["bun"], args: [...cli, "import", ...sourceArgs, "--confirm"], env, volumeMounts: mounts }], containers: [{ name: "verify", image: imageRef, command: ["bun"], args: [...cli, "verify", ...sourceArgs], env, volumeMounts: mounts }], volumes: [{ name: "legacy-sqlite", persistentVolumeClaim: { claimName: resolvedSource.ownerPvc, readOnly: true } }, { name: "snapshot", emptyDir: {} }] } } } };
}
export function releaseAMonitorMigrationPool(spec: HwlabRuntimeLaneSpec, resolvedSource: MonitorSqliteSource): { readonly poolMax: number; readonly idleTimeoutSeconds: number } {
const { resolved, pool } = releaseAMonitorConfig(spec, resolvedSource);
return { poolMax: integerAt(pool, "max", resolved.ref), idleTimeoutSeconds: integerAt(pool, "idleTimeoutSeconds", resolved.ref) };
}
function releaseAMonitorConfig(spec: HwlabRuntimeLaneSpec, resolvedSource: MonitorSqliteSource): { readonly resolved: ReturnType<typeof readWebProbeSentinelConfigRef>; readonly runtime: Record<string, unknown>; readonly job: Record<string, unknown>; readonly namespace: string; readonly deploymentName: string; readonly port: number; readonly secret: Record<string, unknown>; readonly pool: Record<string, unknown>; readonly labels: Record<string, string> } {
const configRef = spec.observability.webProbe?.monitor?.configRef;
if (!configRef) throw new Error(`lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.monitor.configRef is required`);
const resolved = readWebProbeSentinelConfigRef(spec, configRef);
if (!isRecord(resolved.target)) throw new Error(`${resolved.ref} must resolve to an object`);
const monitor = resolved.target;
const runtime = recordAt(monitor, "runtime", resolved.ref);
const persistence = recordAt(monitor, "persistence", resolved.ref);
const migration = recordAt(monitor, "migration", resolved.ref);
const source = recordAt(migration, "source", resolved.ref);
const job = recordAt(migration, "job", resolved.ref);
const exposure = recordAt(monitor, "publicExposure", resolved.ref);
const cicd = recordAt(monitor, "cicd", resolved.ref);
const expectedRegistry = `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels`;
const expectedConsumer = `sentinel-${spec.nodeId.toLowerCase()}-${spec.lane}`;
if (textAt(runtime, "node", resolved.ref) !== spec.nodeId || textAt(runtime, "lane", resolved.ref) !== spec.lane || textAt(persistence, "mode", resolved.ref) !== "host-postgres" || textAt(persistence, "authority", resolved.ref) !== "central-prepared-non-authoritative" || textAt(persistence, "database", resolved.ref) !== "web_probe_monitor" || textAt(persistence, "schema", resolved.ref) !== "monitor" || exposure.enabled !== false || textAt(exposure, "mode", resolved.ref) !== "prepared-not-routed" || textAt(exposure, "hostname", resolved.ref).length === 0 || textAt(cicd, "mode", resolved.ref) !== "existing-pac-consumer" || textAt(cicd, "consumer", resolved.ref) !== expectedConsumer || textAt(migration, "mode", resolved.ref) !== "legacy-authority") throw new Error(`${resolved.ref} must remain a non-authoritative Release A configuration`);
if (textAt(source, "registry", resolved.ref) !== expectedRegistry || textAt(source, "sentinelId", resolved.ref) !== resolvedSource.sentinelId || textAt(source, "runtimeConfigRef", resolved.ref) !== resolvedSource.runtimeConfigRef || textAt(source, "sqlitePath", resolved.ref) !== resolvedSource.path || textAt(source, "pvcName", resolved.ref) !== resolvedSource.ownerPvc || textAt(source, "stateRoot", resolved.ref) !== resolvedSource.stateRoot || textAt(job, "workflow", resolved.ref) !== "export-import-verify" || job.readOnlySource !== true || typeof job.enabled !== "boolean" || !/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(textAt(job, "name", resolved.ref))) throw new Error(`${resolved.ref}.migration must match the resolved active registry and declare an export-import-verify read-only workflow`);
const namespace = textAt(runtime, "namespace", resolved.ref);
const deploymentName = textAt(runtime, "deploymentName", resolved.ref);
const port = integerAt(runtime, "servicePort", resolved.ref);
const secret = recordAt(runtime, "databaseSecret", resolved.ref);
if (textAt(secret, "name", resolved.ref) !== "hwlab-web-probe-monitor-db" || textAt(secret, "key", resolved.ref) !== "DATABASE_URL" || textAt(secret, "sourceRef", resolved.ref) !== "hwlab/web-probe-monitor-db.env") throw new Error(`${resolved.ref}.runtime.databaseSecret must be the controlled monitor DATABASE_URL projection`);
const pool = recordAt(runtime, "pool", resolved.ref);
return { resolved, runtime, job, namespace, deploymentName, port, secret, pool, labels: { "app.kubernetes.io/name": deploymentName, "app.kubernetes.io/part-of": "hwlab-web-probe-monitor", "app.kubernetes.io/managed-by": "unidesk", "unidesk.ai/migration-mode": "legacy-authority" } };
}
function recordAt(value: Record<string, unknown>, key: string, path: string): Record<string, unknown> { if (!isRecord(value[key])) throw new Error(`${path}.${key} must be an object`); return value[key] as Record<string, unknown>; }
function textAt(value: Record<string, unknown>, key: string, path: string): string { const item = value[key]; if (typeof item !== "string" || item.length === 0) throw new Error(`${path}.${key} must be a non-empty string`); return item; }
function integerAt(value: Record<string, unknown>, key: string, path: string): number { const item = value[key]; if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 1) throw new Error(`${path}.${key} must be a positive integer`); return item; }
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
function versionedJobName(base: string, imageRef: string): string { const suffix = imageRef.split(":").at(-1)?.replace(/[^a-z0-9-]/gu, "-").slice(0, 20) || "image"; return `${base}-${suffix}`.slice(0, 63).replace(/-+$/u, ""); }
function requireImageRef(imageRef: string): void { if (typeof imageRef !== "string" || imageRef.trim().length === 0) throw new Error("Release A monitor imageRef must be a non-empty current PaC image reference"); }