90 lines
12 KiB
TypeScript
90 lines
12 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 { MonitorLegacyArtifactGap, 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: textAt(runtime, "bindHost", resolved.ref) }, { 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; readonly nameSuffix?: string } = {}): 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 sourceVolumes = resolvedSource.artifactSources.map((source, index) => ({ name: source.ownerPvc === resolvedSource.ownerPvc ? "legacy-sqlite" : `artifact-${index}`, source }));
|
|
const mounts = [...sourceVolumes.map((item) => ({ name: item.name, mountPath: item.source.mountPath, 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, options.nameSuffix), 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: [...sourceVolumes.map((item) => item.source.hostPath === undefined ? { name: item.name, persistentVolumeClaim: { claimName: item.source.ownerPvc, readOnly: true } } : { name: item.name, hostPath: { path: item.source.hostPath, type: "Directory" } }), { 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) };
|
|
}
|
|
|
|
export function releaseAMonitorMigrationArtifactGaps(spec: HwlabRuntimeLaneSpec, resolvedSource: MonitorSqliteSource): readonly MonitorLegacyArtifactGap[] {
|
|
const { resolved, migration } = releaseAMonitorConfig(spec, resolvedSource);
|
|
const gaps = arrayAt(migration, "legacyArtifactGaps", resolved.ref).map((item, index) => {
|
|
if (!isRecord(item)) throw new Error(`${resolved.ref}.migration.legacyArtifactGaps[${index}] must be an object`);
|
|
const gap = item;
|
|
return { runId: textAt(gap, "runId", resolved.ref), artifactCount: integerAt(gap, "artifactCount", resolved.ref), stateDir: textAt(gap, "stateDir", resolved.ref), reportSha256: textAt(gap, "reportSha256", resolved.ref) };
|
|
});
|
|
const expected = [
|
|
{ runId: "sentinel-run-mrbjo83t-ef79c71d", artifactCount: 1, stateDir: ".state/web-observe/NC01/v03/2026/07/08/20260708T035504Z_workbench_webobs-mrbjo88n-c5bed5", reportSha256: "sha256:119c9e3ef4cbc41143121e5d1af8955a8454e5efdbb6600f64a207a0f148b33" },
|
|
{ runId: "sentinel-run-mrbjsugk-764ff868", artifactCount: 4, stateDir: ".state/web-observe/NC01/v03/2026/07/08/20260708T035839Z_workbench_webobs-mrbjsul8-5952f4", reportSha256: "sha256:d2dbc0fc98f39d7373b9ae009e31fcd8d666014c2e32428f235150ee59666048" },
|
|
];
|
|
if (JSON.stringify(gaps) !== JSON.stringify(expected)) throw new Error(`${resolved.ref}.migration.legacyArtifactGaps must declare exactly the two approved pre-migration artifact gaps`);
|
|
return gaps;
|
|
}
|
|
|
|
function releaseAMonitorConfig(spec: HwlabRuntimeLaneSpec, resolvedSource: MonitorSqliteSource): { readonly resolved: ReturnType<typeof readWebProbeSentinelConfigRef>; readonly runtime: Record<string, unknown>; readonly migration: 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" || 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(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 reference 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);
|
|
textAt(secret, "name", resolved.ref);
|
|
textAt(secret, "key", resolved.ref);
|
|
textAt(secret, "sourceRef", resolved.ref);
|
|
const pool = recordAt(runtime, "pool", resolved.ref);
|
|
return { resolved, runtime, migration, 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 arrayAt(value: Record<string, unknown>, key: string, path: string): readonly unknown[] { if (!Array.isArray(value[key])) throw new Error(`${path}.${key} must be an array`); return value[key] as readonly 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, nameSuffix?: string): string { const imageSuffix = imageRef.split(":").at(-1)?.replace(/[^a-z0-9-]/gu, "-").slice(0, 20) || "image"; const suffix = nameSuffix === undefined ? imageSuffix : `${imageSuffix}-${nameSuffix.replace(/[^a-z0-9-]/gu, "-").slice(0, 12)}`; 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"); }
|