From 9a32ead76fba59831d79eb7a3b5aa9b39b655282 Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Mon, 13 Jul 2026 00:12:35 +0000 Subject: [PATCH] fix: preserve immutable monitor migration sources --- config/hwlab-web-probe-monitor/runtime.yaml | 7 -- config/hwlab-web-probe-sentinel/profiles.yaml | 6 ++ .../src/hwlab-node-web-probe-monitor.test.ts | 5 +- scripts/src/hwlab-node-web-probe-monitor.ts | 13 +-- scripts/src/hwlab-node-web-sentinel-cicd.ts | 12 ++- .../src/monitor-central-persistence.test.ts | 8 ++ scripts/src/monitor-central-persistence.ts | 5 +- scripts/src/monitor-sqlite-migration.ts | 21 +++-- .../monitor-terminal-ingest-migration.test.ts | 82 +++++++++++++++++-- 9 files changed, 130 insertions(+), 29 deletions(-) diff --git a/config/hwlab-web-probe-monitor/runtime.yaml b/config/hwlab-web-probe-monitor/runtime.yaml index 68df1fab..ed6651eb 100644 --- a/config/hwlab-web-probe-monitor/runtime.yaml +++ b/config/hwlab-web-probe-monitor/runtime.yaml @@ -28,17 +28,10 @@ monitor: persistence: mode: host-postgres authority: central-prepared-non-authoritative - database: web_probe_monitor - schema: monitor migration: mode: legacy-authority source: registry: config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.observability.webProbe.sentinels - sentinelId: nc01-web-probe-sentinel - runtimeConfigRef: config/hwlab-web-probe-sentinel/profiles.yaml#nodes.NC01.sentinels.nc01-web-probe-sentinel.runtime - sqlitePath: /var/lib/web-probe-sentinel-nc01/index.sqlite - pvcName: hwlab-web-probe-sentinel-nc01-state - stateRoot: /var/lib/web-probe-sentinel-nc01 job: name: hwlab-web-probe-monitor-migration workflow: export-import-verify diff --git a/config/hwlab-web-probe-sentinel/profiles.yaml b/config/hwlab-web-probe-sentinel/profiles.yaml index 5e389f96..aa682282 100644 --- a/config/hwlab-web-probe-sentinel/profiles.yaml +++ b/config/hwlab-web-probe-sentinel/profiles.yaml @@ -452,6 +452,12 @@ templates: serviceName: "hwlab-web-probe-sentinel-${nodeLower}" pvcName: "hwlab-web-probe-sentinel-${nodeLower}-state" stateRoot: "/var/lib/web-probe-sentinel-${nodeLower}" + artifactSources: + - ownerPvc: "hwlab-web-probe-sentinel-${nodeLower}-state" + mountPath: "/var/lib/web-probe-sentinel-${nodeLower}" + - ownerPvc: "legacy-web-observe-workspace-${nodeLower}" + mountPath: "/root/hwlab-v03" + hostPath: "/root/hwlab-v03" imageRef: "127.0.0.1:5000/hwlab/web-probe-sentinel-${nodeLower}:source-commit" sqlite: busyTimeoutMs: 2000 diff --git a/scripts/src/hwlab-node-web-probe-monitor.test.ts b/scripts/src/hwlab-node-web-probe-monitor.test.ts index 3f65d2ba..9473bb63 100644 --- a/scripts/src/hwlab-node-web-probe-monitor.test.ts +++ b/scripts/src/hwlab-node-web-probe-monitor.test.ts @@ -5,7 +5,7 @@ import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes"; import { releaseAMonitorManifests, releaseAMonitorMigrationJob, releaseAMonitorMigrationPool } from "./hwlab-node-web-probe-monitor"; import { readYamlRecord } from "./platform-infra-ops-library"; -const source = { sentinelId: "nc01-web-probe-sentinel", node: "NC01", lane: "v03", path: "/var/lib/web-probe-sentinel-nc01/index.sqlite", runtimeConfigRef: "config/hwlab-web-probe-sentinel/profiles.yaml#nodes.NC01.sentinels.nc01-web-probe-sentinel.runtime", stateRoot: "/var/lib/web-probe-sentinel-nc01", ownerPvc: "hwlab-web-probe-sentinel-nc01-state" }; +const source = { sentinelId: "nc01-web-probe-sentinel", node: "NC01", lane: "v03", path: "/var/lib/web-probe-sentinel-nc01/index.sqlite", runtimeConfigRef: "config/hwlab-web-probe-sentinel/profiles.yaml#nodes.NC01.sentinels.nc01-web-probe-sentinel.runtime", stateRoot: "/var/lib/web-probe-sentinel-nc01", ownerPvc: "hwlab-web-probe-sentinel-nc01-state", artifactSources: [{ ownerPvc: "hwlab-web-probe-sentinel-nc01-state", mountPath: "/var/lib/web-probe-sentinel-nc01" }, { ownerPvc: "legacy-web-observe-workspace-nc01", mountPath: "/root/hwlab-v03", hostPath: "/root/hwlab-v03" }] }; const imageRef = "127.0.0.1:5000/hwlab/web-probe-sentinel-nc01:source-commit"; test("Release A live manifests exclude disabled migration Job and select only monitor service Pods", () => { @@ -37,7 +37,8 @@ test("explicit migration helper renders ordered read-only export import verify J for (const container of [...job.spec.template.spec.initContainers, ...job.spec.template.spec.containers]) { expect(container.volumeMounts).toEqual(expect.arrayContaining([expect.objectContaining({ name: "legacy-sqlite", mountPath: "/var/lib/web-probe-sentinel-nc01", readOnly: true }), expect.objectContaining({ name: "snapshot", mountPath: "/migration" })])); } - expect(job.spec.template.spec.volumes).toEqual([{ name: "legacy-sqlite", persistentVolumeClaim: { claimName: "hwlab-web-probe-sentinel-nc01-state", readOnly: true } }, { name: "snapshot", emptyDir: {} }]); + expect(job.spec.template.spec.volumes).toEqual(expect.arrayContaining([{ name: "legacy-sqlite", persistentVolumeClaim: { claimName: "hwlab-web-probe-sentinel-nc01-state", readOnly: true } }, { name: "artifact-1", hostPath: { path: "/root/hwlab-v03", type: "Directory" } }, { name: "snapshot", emptyDir: {} }])); + expect(job.spec.template.spec.containers[0].volumeMounts).toEqual(expect.arrayContaining([expect.objectContaining({ name: "artifact-1", mountPath: "/root/hwlab-v03", readOnly: true })])); expect(job.spec.template.spec.containers[0].env).toEqual(expect.arrayContaining([expect.objectContaining({ name: "DATABASE_URL", valueFrom: { secretKeyRef: { name: "hwlab-web-probe-monitor-db", key: "DATABASE_URL" } } }), expect.objectContaining({ name: "MONITOR_CENTRAL_PG_POOL_MAX", value: "4" })])); }); diff --git a/scripts/src/hwlab-node-web-probe-monitor.ts b/scripts/src/hwlab-node-web-probe-monitor.ts index 3192c139..6798c996 100644 --- a/scripts/src/hwlab-node-web-probe-monitor.ts +++ b/scripts/src/hwlab-node-web-probe-monitor.ts @@ -24,11 +24,12 @@ export function releaseAMonitorMigrationJob(spec: HwlabRuntimeLaneSpec, imageRef 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 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), 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: {} }] } } } }; + 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: [...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 } { @@ -51,13 +52,15 @@ function releaseAMonitorConfig(spec: HwlabRuntimeLaneSpec, resolvedSource: Monit 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`); + 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); - 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`); + 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, 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" } }; } diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index fa82b1cc..261bbfad 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -229,7 +229,7 @@ function runSentinelMigration(spec: HwlabRuntimeLaneSpec, options: Extract source.ownerPvc === right.artifactSources[index]?.ownerPvc && source.mountPath === right.artifactSources[index]?.mountPath && source.hostPath === right.artifactSources[index]?.hostPath); } function resolvedMigrationSources(spec: HwlabRuntimeLaneSpec, sentinelId: string | null): readonly MonitorSqliteSource[] { @@ -237,7 +237,15 @@ function resolvedMigrationSources(spec: HwlabRuntimeLaneSpec, sentinelId: string return ids.map((id) => { const sentinel = resolveWebProbeSentinel(spec, id); const runtime = recordTarget(readWebProbeSentinelConfigRefTarget(spec, sentinel.configRefs.runtime), sentinel.configRefs.runtime); - return { sentinelId: sentinel.id, node: spec.nodeId, lane: spec.lane, path: stringAt(runtime, "sqlite.path"), runtimeConfigRef: sentinel.configRefs.runtime, stateRoot: stringAt(runtime, "stateRoot"), ownerPvc: stringAt(runtime, "pvcName") }; + const stateRoot = stringAt(runtime, "stateRoot"); + const ownerPvc = stringAt(runtime, "pvcName"); + const artifactSources = arrayAt(runtime, "artifactSources").map((item, index) => { + const source = recordTarget(item, `${sentinel.configRefs.runtime}.artifactSources[${index}]`); + const hostPath = stringAtNullable(source, "hostPath"); + return { ownerPvc: stringAt(source, "ownerPvc"), mountPath: stringAt(source, "mountPath"), ...(hostPath === null ? {} : { hostPath }) }; + }); + if (!artifactSources.some((item) => item.ownerPvc === ownerPvc && item.mountPath === stateRoot)) throw new Error(`${sentinel.configRefs.runtime}.artifactSources must include the resolved index PVC and stateRoot`); + return { sentinelId: sentinel.id, node: spec.nodeId, lane: spec.lane, path: stringAt(runtime, "sqlite.path"), runtimeConfigRef: sentinel.configRefs.runtime, stateRoot, ownerPvc, artifactSources }; }); } diff --git a/scripts/src/monitor-central-persistence.test.ts b/scripts/src/monitor-central-persistence.test.ts index d5801c33..5c4a0d35 100644 --- a/scripts/src/monitor-central-persistence.test.ts +++ b/scripts/src/monitor-central-persistence.test.ts @@ -32,6 +32,14 @@ test("HTTP terminal ingest is unbound-safe, ignores caller hash, and maps confli assert.equal((await fetch(new Request("http://local/health"))).status, 200); }); +test("health returns 503 when the central store is not ready", async () => { + const store = createInMemoryMonitorCentralStore(); + const target = createMonitorCentralService({ store: { ...store, async ping() { throw new Error("postgres unavailable"); } }, pageSize: 50 }); + const response = await target.fetch(new Request("http://local/health")); + assert.equal(response.status, 503); + assert.equal((await response.json() as { ok: boolean }).ok, false); +}); + test("full terminal envelope controls hash and concurrent same identity converges", async () => { const target = service(); const base = { ...input("run-envelope"), payloadHash: "sha256:caller-controlled" }; diff --git a/scripts/src/monitor-central-persistence.ts b/scripts/src/monitor-central-persistence.ts index 6c4d9d09..e9a8c760 100644 --- a/scripts/src/monitor-central-persistence.ts +++ b/scripts/src/monitor-central-persistence.ts @@ -158,7 +158,10 @@ export function createMonitorCentralService(options: MonitorCentralServiceOption const result = await ingest(await requestRecord(request)); return json(result, result.disposition === "inserted" ? 201 : 200); } - if (request.method === "GET" && url.pathname === "/health") return json(await health()); + if (request.method === "GET" && url.pathname === "/health") { + const result = await health(); + return json(result, result.ok === true ? 200 : 503); + } if (request.method === "GET" && url.pathname === "/api/overview") return json({ ok: true, overview: await store.overview(scopeFrom(url)), valuesRedacted: true }); if (request.method === "GET" && url.pathname === "/api/runs") return json(runPage(await store.runs(scopeFrom(url), runQueryOptions(url, pageSize)))); if (request.method === "GET" && url.pathname === "/api/findings") return json(findingPage(await store.findings(scopeFrom(url), findingQueryOptions(url, pageSize)))); diff --git a/scripts/src/monitor-sqlite-migration.ts b/scripts/src/monitor-sqlite-migration.ts index 30f555c7..c0abd30f 100644 --- a/scripts/src/monitor-sqlite-migration.ts +++ b/scripts/src/monitor-sqlite-migration.ts @@ -9,7 +9,8 @@ import type { MonitorArtifactLocator, MonitorCentralIngest, MonitorCentralStore, import { monitorCentralStableJson, normalizeMonitorCentralIngest } from "./monitor-central-persistence"; export const MONITOR_SQLITE_MIGRATION_SCHEMA = "2026-07-12-p17-2"; -export interface MonitorSqliteSource { readonly sentinelId: string; readonly node: string; readonly lane: string; readonly path: string; readonly runtimeConfigRef: string; readonly stateRoot: string; readonly ownerPvc: string; } +export interface MonitorArtifactSource { readonly ownerPvc: string; readonly mountPath: string; readonly hostPath?: string; } +export interface MonitorSqliteSource { readonly sentinelId: string; readonly node: string; readonly lane: string; readonly path: string; readonly runtimeConfigRef: string; readonly stateRoot: string; readonly ownerPvc: string; readonly artifactSources: readonly MonitorArtifactSource[]; } export interface MonitorSqliteTable { readonly name: string; readonly sql: string; readonly rowCount: number; readonly rows: readonly Record[]; } export interface MonitorSqliteSnapshot { readonly schema: string; readonly createdAt: string; readonly source: MonitorSqliteSource; readonly fingerprint: string; readonly integrity: string; readonly byteLength: number; @@ -59,7 +60,6 @@ export function planMonitorSqliteSources(sources: readonly MonitorSqliteSource[] export function exportMonitorSqliteSnapshot(source: MonitorSqliteSource, outputPath: string): MonitorSqliteSnapshot { const path = assertReadOnlySqliteSource(source.path); - const sourceBytes = readFileSync(path); const database = new Database(path, { readonly: true }); let transactionOpen = false; try { @@ -68,7 +68,10 @@ export function exportMonitorSqliteSnapshot(source: MonitorSqliteSource, outputP const integrity = String(database.query("PRAGMA integrity_check").get()?.integrity_check ?? "unknown"); if (integrity !== "ok") throw new Error(`legacy SQLite integrity_check failed: ${integrity}`); const tables = (database.query("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as { name: string; sql: string }[]).map((table) => ({ name: table.name, sql: table.sql, rowCount: Number(database.query(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`).get()?.count ?? 0), rows: database.query(`SELECT * FROM ${quoteIdentifier(table.name)}`).all().map(normalizeRow) })); - const unsigned = { schema: MONITOR_SQLITE_MIGRATION_SCHEMA, createdAt: new Date().toISOString(), source: { ...source, path }, fingerprint: `sha256:${sha256(sourceBytes)}`, integrity, byteLength: sourceBytes.byteLength, tables, artifactLocator: { path, sha256: `sha256:${sha256(sourceBytes)}`, sizeBytes: sourceBytes.byteLength, kind: "legacy-sqlite" } }; + const immutablePath = sqliteSnapshotPath(outputPath); + const immutableBytes = (database as unknown as { serialize(): Uint8Array }).serialize(); + writeFileSync(immutablePath, immutableBytes); + const unsigned = { schema: MONITOR_SQLITE_MIGRATION_SCHEMA, createdAt: new Date().toISOString(), source: { ...source, path }, fingerprint: `sha256:${sha256(immutableBytes)}`, integrity, byteLength: immutableBytes.byteLength, tables, artifactLocator: { path: immutablePath, sha256: `sha256:${sha256(immutableBytes)}`, sizeBytes: immutableBytes.byteLength, kind: "legacy-sqlite" } }; const snapshot: MonitorSqliteSnapshot = { ...unsigned, manifestContentHash: monitorSnapshotContentHash(unsigned) }; writeSnapshot(outputPath, `${monitorCentralStableJson(snapshot)}\n`); database.exec("COMMIT"); @@ -120,14 +123,17 @@ function canonicalIngests(snapshot: MonitorSqliteSnapshot): readonly ReturnType< const payload = { ...(storedPayload ?? {}), ...(report ?? {}), legacy: { run: row, sourceFingerprint: snapshot.fingerprint } }; const reportFindings = Array.isArray(report?.findings) ? report.findings.filter(isRecord) : []; const canonicalFindings = dedupeFindings(reportFindings, rowFindings); - return normalizeMonitorCentralIngest({ sentinelId: stringField(row, ["sentinel_id", "sentinelId"]) || snapshot.source.sentinelId, node: stringField(row, ["node"]) || snapshot.source.node, lane: stringField(row, ["lane"]) || snapshot.source.lane, runId, updatedAt: stringField(row, ["updated_at", "updatedAt", "created_at", "createdAt"]) || snapshot.createdAt, status: stringField(row, ["status"]) || "legacy", payload, findings: canonicalFindings, artifactLocators: reportLocator ? [...rowLocators, reportLocator] : rowLocators, timeline: [] }); + const artifactLocators = reportLocator ? [...rowLocators, reportLocator] : rowLocators; + const artifactCount = numberField(row, ["artifact_count", "artifactCount"]); + if (artifactCount !== null && artifactCount !== artifactLocators.length) throw Object.assign(new Error(`legacy artifact locator count mismatch for ${runId}`), { code: "monitor-migration-artifact-count-mismatch" }); + return normalizeMonitorCentralIngest({ sentinelId: stringField(row, ["sentinel_id", "sentinelId"]) || snapshot.source.sentinelId, node: stringField(row, ["node"]) || snapshot.source.node, lane: stringField(row, ["lane"]) || snapshot.source.lane, runId, updatedAt: stringField(row, ["updated_at", "updatedAt", "created_at", "createdAt"]) || snapshot.createdAt, status: stringField(row, ["status"]) || "legacy", payload, findings: canonicalFindings, artifactLocators, timeline: [] }); }); } function targetReconciliation(ingests: readonly ReturnType[], runtimeMetadata: readonly MonitorRuntimeMetadata[]): Record { return { runs: ingests.length, findings: ingests.reduce((total, item) => total + item.findings.length, 0), payloads: ingests.length, artifactLocators: ingests.reduce((total, item) => total + item.artifactLocators.length, 0), views: ingests.reduce((total, item) => total + (isRecord(item.payload.views) ? Object.keys(item.payload.views).length : 0), 0), runtimeMetadata: runtimeMetadata.length }; } function canonicalRuntimeMetadata(snapshot: MonitorSqliteSnapshot): readonly MonitorRuntimeMetadata[] { return tableRows(snapshot.tables, ["metadata", "run_metadata"]).map((row) => ({ sentinelId: snapshot.source.sentinelId, node: snapshot.source.node, lane: snapshot.source.lane, key: stringField(row, ["key"]), value: jsonValue(row, ["value_json", "value"]), updatedAt: stringField(row, ["updated_at", "updatedAt", "created_at", "createdAt"]) || snapshot.createdAt })).filter((item) => item.key.length > 0).sort((left, right) => left.key.localeCompare(right.key)); } function sameRun(value: unknown, expected: { readonly sentinelId: string; readonly node: string; readonly lane: string; readonly runId: string; readonly updatedAt: string }): boolean { return isRecord(value) && value.sentinelId === expected.sentinelId && value.node === expected.node && value.lane === expected.lane && value.runId === expected.runId && value.updatedAt === expected.updatedAt; } -function locatorFromRow(row: Record, source: MonitorSqliteSnapshot): MonitorArtifactLocator { const ownerPvc = stringField(row, ["owner_pvc", "ownerPvc"]); const relativePath = stringField(row, ["relative_path", "relativePath"]); const hash = stringField(row, ["sha256"]); const sizeBytes = numberField(row, ["size_bytes", "sizeBytes"]); if (!ownerPvc || !relativePath || !hash || sizeBytes === null) throw Object.assign(new Error("legacy artifact locator is incomplete"), { code: "monitor-migration-locator-invalid" }); return { ownerNode: stringField(row, ["owner_node", "ownerNode"]) || source.source.node, ownerLane: stringField(row, ["owner_lane", "ownerLane"]) || source.source.lane, ownerPvc, stateDir: stringField(row, ["state_dir", "stateDir"]), relativePath, sha256: hash, sizeBytes, kind: stringField(row, ["kind"]) || "artifact", reachable: row.reachable !== false }; } -function reportLocatorFromRun(row: Record, snapshot: MonitorSqliteSnapshot): MonitorArtifactLocator | null { const stateDir = stringField(row, ["state_dir", "stateDir"]); const expectedHash = stringField(row, ["report_json_sha256", "reportJsonSha256"]); if (!stateDir && !expectedHash) return null; if (!stateDir || !expectedHash) throw Object.assign(new Error("legacy report locator is incomplete"), { code: "monitor-migration-report-locator-invalid" }); const path = join(snapshot.source.stateRoot, stateDir, "analysis", "report.json"); const bytes = readFileSync(path); const actualHash = `sha256:${sha256(bytes)}`; if (actualHash !== expectedHash) throw Object.assign(new Error("legacy report hash does not match artifact"), { code: "monitor-migration-report-locator-invalid" }); return { ownerNode: snapshot.source.node, ownerLane: snapshot.source.lane, ownerPvc: snapshot.source.ownerPvc, stateDir, relativePath: "analysis/report.json", sha256: actualHash, sizeBytes: bytes.byteLength, kind: "report-json", reachable: true }; } +function locatorFromRow(row: Record, snapshot: MonitorSqliteSnapshot): MonitorArtifactLocator { const ownerPvc = stringField(row, ["owner_pvc", "ownerPvc"]); const relativePath = stringField(row, ["relative_path", "relativePath"]); const expectedHash = stringField(row, ["sha256"]); const expectedSize = numberField(row, ["size_bytes", "sizeBytes"]); const stateDir = stringField(row, ["state_dir", "stateDir"]); if (!ownerPvc || !stateDir || !relativePath || !expectedHash || expectedSize === null) throw Object.assign(new Error("legacy artifact locator is incomplete"), { code: "monitor-migration-locator-invalid" }); const source = artifactSourceFor(snapshot.source, ownerPvc); const path = artifactPath(source.mountPath, stateDir, relativePath); const bytes = readFileSync(path); const actualHash = `sha256:${sha256(bytes)}`; if (actualHash !== expectedHash || bytes.byteLength !== expectedSize) throw Object.assign(new Error("legacy artifact locator does not match source artifact"), { code: "monitor-migration-locator-invalid" }); return { ownerNode: stringField(row, ["owner_node", "ownerNode"]) || snapshot.source.node, ownerLane: stringField(row, ["owner_lane", "ownerLane"]) || snapshot.source.lane, ownerPvc, stateDir, relativePath, sha256: actualHash, sizeBytes: bytes.byteLength, kind: stringField(row, ["kind"]) || "artifact", reachable: row.reachable !== false }; } +function reportLocatorFromRun(row: Record, snapshot: MonitorSqliteSnapshot): MonitorArtifactLocator | null { const stateDir = stringField(row, ["state_dir", "stateDir"]); const expectedHash = stringField(row, ["report_json_sha256", "reportJsonSha256"]); if (!stateDir && !expectedHash) return null; if (!stateDir || !expectedHash) throw Object.assign(new Error("legacy report locator is incomplete"), { code: "monitor-migration-report-locator-invalid" }); const ownerPvc = stringField(row, ["owner_pvc", "ownerPvc", "artifact_owner_pvc", "artifactOwnerPvc"]) || snapshot.source.ownerPvc; const source = artifactSourceFor(snapshot.source, ownerPvc); const path = artifactPath(source.mountPath, stateDir, "analysis/report.json"); const bytes = readFileSync(path); const actualHash = `sha256:${sha256(bytes)}`; if (actualHash !== expectedHash) throw Object.assign(new Error("legacy report hash does not match artifact"), { code: "monitor-migration-report-locator-invalid" }); return { ownerNode: snapshot.source.node, ownerLane: snapshot.source.lane, ownerPvc, stateDir, relativePath: "analysis/report.json", sha256: actualHash, sizeBytes: bytes.byteLength, kind: "report-json", reachable: true }; } function metadataValue(rows: readonly Record[], key: string): Record | null { return jsonField(rows.find((row) => stringField(row, ["key"]) === key), ["value_json", "value"]); } function normalizeLegacyFinding(row: Record): Record { return jsonField(row, ["finding", "value_json"]) ?? { id: stringField(row, ["finding_id", "id"]), severity: stringField(row, ["severity"]), count: numberField(row, ["count"]) ?? 1, summary: stringField(row, ["summary"]), reportJsonSha256: stringField(row, ["report_json_sha256", "reportJsonSha256"]), createdAt: stringField(row, ["created_at", "createdAt"]) }; } function dedupeFindings(primary: readonly Record[], secondary: readonly Record[]): readonly Record[] { const seen = new Set(); return [...primary, ...secondary].filter((finding) => { const id = stringField(finding, ["id"]); const key = id || monitorCentralStableJson(finding); if (seen.has(key)) return false; seen.add(key); return true; }); } @@ -135,6 +141,9 @@ function tableRows(tables: readonly MonitorSqliteTable[], names: readonly string function sourcePlan(source: MonitorSqliteSource): Record { const path = resolve(source.path); const state = existsSync(path) ? statSync(path) : null; return { sentinelId: source.sentinelId, node: source.node, lane: source.lane, path, runtimeConfigRef: source.runtimeConfigRef, exists: state !== null, isFile: state?.isFile() ?? false, byteLength: state?.size ?? null, mutation: false, valuesRedacted: true }; } function assertReadOnlySqliteSource(value: string): string { const path = resolve(value); const state = statSync(path); if (!state.isFile()) throw new Error(`legacy SQLite source is not a file: ${path}`); const descriptor = openSync(path, "r"); closeSync(descriptor); return path; } function writeSnapshot(path: string, content: string): void { const parent = dirname(resolve(path)); if (!existsSync(parent)) throw new Error(`snapshot output directory does not exist: ${parent}`); writeFileSync(path, content); } +function sqliteSnapshotPath(path: string): string { const resolved = resolve(path); return resolved.endsWith(".json") ? `${resolved.slice(0, -5)}.sqlite` : `${resolved}.sqlite`; } +function artifactSourceFor(source: MonitorSqliteSource, ownerPvc: string): MonitorArtifactSource { const artifactSource = source.artifactSources.find((item) => item.ownerPvc === ownerPvc); if (!artifactSource) throw Object.assign(new Error(`legacy artifact owner is not registry-mounted: ${ownerPvc}`), { code: "monitor-migration-artifact-source-missing" }); return artifactSource; } +function artifactPath(mountPath: string, stateDir: string, relativePath: string): string { const root = resolve(mountPath); const directory = stateDir.startsWith("/") ? resolve(stateDir) : resolve(root, stateDir); if (directory !== root && !directory.startsWith(`${root}/`)) throw Object.assign(new Error("legacy artifact stateDir escapes registry mount"), { code: "monitor-migration-artifact-path-invalid" }); return join(directory, relativePath); } function quoteIdentifier(value: string): string { return `"${value.replaceAll("\"", "\"\"")}"`; } function normalizeRow(value: unknown): Record { const row = value as Record; return Object.fromEntries(Object.entries(row).map(([key, item]) => [key, item instanceof Uint8Array ? Buffer.from(item).toString("base64") : item])); } function jsonField(row: Record | undefined, fields: readonly string[]): Record | null { if (!row) return null; const value = fields.map((field) => row[field]).find((item) => item !== undefined); if (typeof value === "string") try { const parsed = JSON.parse(value); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : null; } catch { return null; } return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } diff --git a/scripts/src/monitor-terminal-ingest-migration.test.ts b/scripts/src/monitor-terminal-ingest-migration.test.ts index 17749a5d..88aed141 100644 --- a/scripts/src/monitor-terminal-ingest-migration.test.ts +++ b/scripts/src/monitor-terminal-ingest-migration.test.ts @@ -1,5 +1,6 @@ // SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-12-p17-monitor-central-persistence. import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -50,18 +51,20 @@ test("SQLite snapshot reconciles the 228/242/234 fixture shape without productio const snapshotPath = join(directory, "snapshot.json"); const database = new Database(sqlitePath); const stateRoot = join(directory, "state"); - const reportPath = join(stateRoot, "run-0", "analysis", "report.json"); - mkdirSync(join(stateRoot, "run-0", "analysis"), { recursive: true }); + const workspaceRoot = join(directory, "hwlab-v03"); + const workspaceRun = join(workspaceRoot, "web-observe", "run-0"); + const reportPath = join(workspaceRun, "analysis", "report.json"); + mkdirSync(join(workspaceRun, "analysis"), { recursive: true }); writeFileSync(reportPath, JSON.stringify({ updatedAt: "2026-07-12T00:00:00.000Z", views: { summary: { title: "legacy summary" } }, findings: [{ id: "report-finding" }] })); const reportHash = `sha256:${Bun.CryptoHasher.hash("sha256", readFileSync(reportPath), "hex")}`; - database.run("CREATE TABLE runs (id TEXT, state_dir TEXT, report_json_sha256 TEXT, updated_at TEXT)"); + database.run("CREATE TABLE runs (id TEXT, state_dir TEXT, artifact_owner_pvc TEXT, artifact_count INTEGER, report_json_sha256 TEXT, updated_at TEXT)"); database.run("CREATE TABLE findings (run_id TEXT, sentinel_id TEXT, finding_id TEXT, severity TEXT, count INTEGER, summary TEXT, report_json_sha256 TEXT, created_at TEXT)"); database.run("CREATE TABLE metadata (id TEXT, key TEXT, value_json TEXT)"); - for (let index = 0; index < 228; index += 1) database.run("INSERT INTO runs VALUES (?, ?, ?, ?)", [`run-${index}`, index === 0 ? "run-0" : null, index === 0 ? reportHash : null, "2026-07-12T00:00:00.000Z"]); + for (let index = 0; index < 228; index += 1) database.run("INSERT INTO runs VALUES (?, ?, ?, ?, ?, ?)", [`run-${index}`, index === 0 ? workspaceRun : null, index === 0 ? "legacy-web-observe-workspace-nc01" : null, index === 0 ? 1 : 0, index === 0 ? reportHash : null, "2026-07-12T00:00:00.000Z"]); for (let index = 0; index < 242; index += 1) database.run("INSERT INTO findings VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [index === 0 ? "run-0" : `run-${index % 228}`, "fixture", index === 0 ? "report-finding" : `finding-${index}`, "warning", 1, "indexed", reportHash, "2026-07-12T00:00:00.000Z"]); for (let index = 0; index < 234; index += 1) database.run("INSERT INTO metadata VALUES (?, ?, ?)", [`metadata-${index}`, index === 0 ? "run.report.run-0" : index === 1 ? "scheduler.heartbeat" : index === 2 ? "maintenance" : index === 3 ? "manual-trigger.latest" : `metadata-${index}`, index === 0 ? JSON.stringify({ views: { summary: { title: "legacy summary" } }, findings: [{ id: "report-finding", rootCause: "complete metadata detail" }] }) : index === 2 ? JSON.stringify(false) : JSON.stringify({ index })]); database.close(); - const source = { sentinelId: "fixture", node: "NC01", lane: "v03", path: sqlitePath, runtimeConfigRef: "fixture#runtime", stateRoot, ownerPvc: "fixture-pvc" }; + const source = { sentinelId: "fixture", node: "NC01", lane: "v03", path: sqlitePath, runtimeConfigRef: "fixture#runtime", stateRoot, ownerPvc: "fixture-pvc", artifactSources: [{ ownerPvc: "fixture-pvc", mountPath: stateRoot }, { ownerPvc: "legacy-web-observe-workspace-nc01", mountPath: workspaceRoot, hostPath: workspaceRoot }] }; const plan = planMonitorSqliteSources([source]); assert.equal((plan.sources as { exists: boolean }[])[0].exists, true); const snapshot = exportMonitorSqliteSnapshot(source, snapshotPath); @@ -78,6 +81,7 @@ test("SQLite snapshot reconciles the 228/242/234 fixture shape without productio assert.equal(importedMetadata.find((item) => item.key === "scheduler.heartbeat")?.updatedAt, snapshot.createdAt); const importedRun = await store.run({ sentinelId: "fixture", node: "NC01", lane: "v03" }, "run-0"); assert.equal(importedRun?.artifactLocators[0]?.sha256, reportHash); + assert.equal(importedRun?.artifactLocators[0]?.ownerPvc, "legacy-web-observe-workspace-nc01"); const reportFindings = importedRun?.findings.filter((finding) => finding.id === "report-finding") ?? []; assert.deepEqual(reportFindings, [{ id: "report-finding", rootCause: "complete metadata detail" }]); assert.equal((await store.view({ sentinelId: "fixture", node: "NC01", lane: "v03" }, "run-0", "summary"))?.value.title, "legacy summary"); @@ -117,7 +121,7 @@ test("metadata-only snapshot verifies scoped runtime metadata and rejects extras database.run("CREATE TABLE metadata (key TEXT, value_json TEXT)"); database.run("INSERT INTO metadata VALUES (?, ?)", ["scheduler.heartbeat", JSON.stringify({ healthy: true })]); database.close(); - const source = { sentinelId: "fixture", node: "NC01", lane: "v03", path: sqlitePath, runtimeConfigRef: "fixture#runtime", stateRoot: directory, ownerPvc: "fixture-pvc" }; + const source = { sentinelId: "fixture", node: "NC01", lane: "v03", path: sqlitePath, runtimeConfigRef: "fixture#runtime", stateRoot: directory, ownerPvc: "fixture-pvc", artifactSources: [{ ownerPvc: "fixture-pvc", mountPath: directory }] }; const snapshot = exportMonitorSqliteSnapshot(source, snapshotPath); const store = createInMemoryMonitorCentralStore(); await importMonitorSqliteSnapshot(snapshot, store); @@ -128,3 +132,69 @@ test("metadata-only snapshot verifies scoped runtime metadata and rejects extras rmSync(directory, { recursive: true, force: true }); } }); + +test("WAL export freezes a serialized SQLite snapshot independent of later live writes", () => { + const directory = mkdtempSync(join(tmpdir(), "monitor-wal-snapshot-")); + try { + const sqlitePath = join(directory, "index.sqlite"); + const snapshotPath = join(directory, "snapshot.json"); + const database = new Database(sqlitePath); + database.exec("PRAGMA journal_mode = WAL"); + database.run("CREATE TABLE metadata (key TEXT, value_json TEXT)"); + database.run("INSERT INTO metadata VALUES (?, ?)", ["scheduler.heartbeat", JSON.stringify({ sequence: 1 })]); + const source = { sentinelId: "fixture", node: "NC01", lane: "v03", path: sqlitePath, runtimeConfigRef: "fixture#runtime", stateRoot: directory, ownerPvc: "fixture-pvc", artifactSources: [{ ownerPvc: "fixture-pvc", mountPath: directory }] }; + const snapshot = exportMonitorSqliteSnapshot(source, snapshotPath); + database.run("INSERT INTO metadata VALUES (?, ?)", ["scheduler.heartbeat.late", JSON.stringify({ sequence: 2 })]); + database.close(); + + assert.equal(snapshot.artifactLocator.path, join(directory, "snapshot.sqlite")); + assert.equal(snapshot.tables.find((table) => table.name === "metadata")?.rowCount, 1); + assert.equal(verifyMonitorSqliteSnapshot(snapshot).ok, true); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("workspace artifact source verifies locator identity hash and size", async () => { + const directory = mkdtempSync(join(tmpdir(), "monitor-workspace-artifact-")); + try { + const sqlitePath = join(directory, "index.sqlite"); + const snapshotPath = join(directory, "snapshot.json"); + const workspaceRoot = join(directory, "hwlab-v03"); + const stateDir = join(workspaceRoot, "web-observe", "run-1"); + const reportBytes = Buffer.from('{"ok":true}\n'); + mkdirSync(join(stateDir, "analysis"), { recursive: true }); + writeFileSync(join(stateDir, "analysis", "report.json"), reportBytes); + const reportHash = `sha256:${createHash("sha256").update(reportBytes).digest("hex")}`; + const database = new Database(sqlitePath); + database.run("CREATE TABLE runs (id TEXT, state_dir TEXT, artifact_owner_pvc TEXT, artifact_count INTEGER, report_json_sha256 TEXT, updated_at TEXT)"); + database.run("INSERT INTO runs VALUES (?, ?, ?, ?, ?, ?)", ["run-1", stateDir, "legacy-web-observe-workspace-nc01", 1, reportHash, "2026-07-12T00:00:00.000Z"]); + database.close(); + const source = { sentinelId: "fixture", node: "NC01", lane: "v03", path: sqlitePath, runtimeConfigRef: "fixture#runtime", stateRoot: directory, ownerPvc: "fixture-pvc", artifactSources: [{ ownerPvc: "fixture-pvc", mountPath: directory }, { ownerPvc: "legacy-web-observe-workspace-nc01", mountPath: workspaceRoot, hostPath: workspaceRoot }] }; + const snapshot = exportMonitorSqliteSnapshot(source, snapshotPath); + const store = createInMemoryMonitorCentralStore(); + await importMonitorSqliteSnapshot(snapshot, store); + const run = await store.run({ sentinelId: "fixture", node: "NC01", lane: "v03" }, "run-1"); + assert.deepEqual(run?.artifactLocators, [{ ownerNode: "NC01", ownerLane: "v03", ownerPvc: "legacy-web-observe-workspace-nc01", stateDir, relativePath: "analysis/report.json", sha256: reportHash, sizeBytes: reportBytes.byteLength, kind: "report-json", reachable: true }]); + assert.equal((await verifyMonitorSqliteSnapshotAgainstStore(snapshot, store)).ok, true); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("artifact count without a registry-backed locator fails closed", async () => { + const directory = mkdtempSync(join(tmpdir(), "monitor-artifact-count-")); + try { + const sqlitePath = join(directory, "index.sqlite"); + const snapshotPath = join(directory, "snapshot.json"); + const database = new Database(sqlitePath); + database.run("CREATE TABLE runs (id TEXT, artifact_count INTEGER, updated_at TEXT)"); + database.run("INSERT INTO runs VALUES (?, ?, ?)", ["run-1", 1, "2026-07-12T00:00:00.000Z"]); + database.close(); + const source = { sentinelId: "fixture", node: "NC01", lane: "v03", path: sqlitePath, runtimeConfigRef: "fixture#runtime", stateRoot: directory, ownerPvc: "fixture-pvc", artifactSources: [{ ownerPvc: "fixture-pvc", mountPath: directory }] }; + const snapshot = exportMonitorSqliteSnapshot(source, snapshotPath); + await assert.rejects(() => importMonitorSqliteSnapshot(snapshot, createInMemoryMonitorCentralStore()), (error: unknown) => (error as { code?: string }).code === "monitor-migration-artifact-count-mismatch"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +});