fix: preserve immutable monitor migration sources
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" })]));
|
||||
});
|
||||
|
||||
|
||||
@@ -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" } };
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ function runSentinelMigration(spec: HwlabRuntimeLaneSpec, options: Extract<WebPr
|
||||
}
|
||||
|
||||
function sameMigrationSource(left: MonitorSqliteSource, right: MonitorSqliteSource): boolean {
|
||||
return left.sentinelId === right.sentinelId && left.node === right.node && left.lane === right.lane && left.path === right.path && left.runtimeConfigRef === right.runtimeConfigRef && left.stateRoot === right.stateRoot && left.ownerPvc === right.ownerPvc;
|
||||
return left.sentinelId === right.sentinelId && left.node === right.node && left.lane === right.lane && left.path === right.path && left.runtimeConfigRef === right.runtimeConfigRef && left.stateRoot === right.stateRoot && left.ownerPvc === right.ownerPvc && left.artifactSources.length === right.artifactSources.length && left.artifactSources.every((source, index) => 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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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))));
|
||||
|
||||
@@ -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<string, unknown>[]; }
|
||||
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<typeof normalizeMonitorCentralIngest>[], runtimeMetadata: readonly MonitorRuntimeMetadata[]): Record<string, number> { 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>[], key: string): Record<string, unknown> | null { return jsonField(rows.find((row) => stringField(row, ["key"]) === key), ["value_json", "value"]); }
|
||||
function normalizeLegacyFinding(row: Record<string, unknown>): Record<string, unknown> { 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<string, unknown>[], secondary: readonly Record<string, unknown>[]): readonly Record<string, unknown>[] { const seen = new Set<string>(); 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<string, unknown> { 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<string, unknown> { const row = value as Record<string, unknown>; return Object.fromEntries(Object.entries(row).map(([key, item]) => [key, item instanceof Uint8Array ? Buffer.from(item).toString("base64") : item])); }
|
||||
function jsonField(row: Record<string, unknown> | undefined, fields: readonly string[]): Record<string, unknown> | 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<string, unknown> : null; } catch { return null; } return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null; }
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user