From 3a63da0fa8a2d55cb021c5b2de23884083e8a59c Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Mon, 13 Jul 2026 00:25:44 +0000 Subject: [PATCH] fix: complete Release A migration execution --- config/hwlab-web-probe-monitor/runtime.yaml | 1 + scripts/src/cicd-delivery-authority.test.ts | 8 +++++ .../src/hwlab-node-web-probe-monitor.test.ts | 7 ++++- scripts/src/hwlab-node-web-probe-monitor.ts | 8 ++--- .../hwlab-node-web-sentinel-cicd-shared.ts | 4 ++- scripts/src/hwlab-node-web-sentinel-cicd.ts | 31 ++++++++++++++++++- scripts/src/hwlab-node/web-probe-observe.ts | 7 +++-- scripts/src/monitor-sqlite-migration.ts | 13 +++++--- .../monitor-terminal-ingest-migration.test.ts | 24 +++++++++++--- 9 files changed, 83 insertions(+), 20 deletions(-) diff --git a/config/hwlab-web-probe-monitor/runtime.yaml b/config/hwlab-web-probe-monitor/runtime.yaml index ed6651eb..057fa8eb 100644 --- a/config/hwlab-web-probe-monitor/runtime.yaml +++ b/config/hwlab-web-probe-monitor/runtime.yaml @@ -15,6 +15,7 @@ monitor: deploymentName: hwlab-web-probe-monitor serviceName: hwlab-web-probe-monitor servicePort: 8090 + bindHost: 0.0.0.0 replicas: 1 healthPath: /health databaseSecret: diff --git a/scripts/src/cicd-delivery-authority.test.ts b/scripts/src/cicd-delivery-authority.test.ts index 4ef06656..991be5ea 100644 --- a/scripts/src/cicd-delivery-authority.test.ts +++ b/scripts/src/cicd-delivery-authority.test.ts @@ -286,6 +286,14 @@ describe("migrated CLI 执行 guard 与提示清理", () => { expect(parsed.sentinel).toMatchObject({ kind: "publish", internalExecution: "pac-controller", manualRecovery: false }); }); + test("migration one-shot Job 仅允许显式计划或双确认等待执行", () => { + const plan = parseNodeWebProbeSentinelOptions(["migration", "job-plan", "--node", "NC01", "--lane", "v03", "--sentinel", "nc01-web-probe-sentinel"]); + expect(plan.sentinel).toMatchObject({ kind: "migration", action: "job-plan", confirm: false, wait: false }); + expect(() => parseNodeWebProbeSentinelOptions(["migration", "job-run", "--node", "NC01", "--lane", "v03", "--sentinel", "nc01-web-probe-sentinel", "--confirm"])).toThrow("requires --confirm --wait"); + const execute = parseNodeWebProbeSentinelOptions(["migration", "job-run", "--node", "NC01", "--lane", "v03", "--sentinel", "nc01-web-probe-sentinel", "--confirm", "--wait"]); + expect(execute.sentinel).toMatchObject({ kind: "migration", action: "job-run", confirm: true, wait: true }); + }); + test("PaC 外层事件与内层 deterministic publish 分开归类,只有外层可驱动 delivery", () => { const consumer = { repository: "sentinel-nc01-v03", pipelineRunPrefix: "hwlab-web-probe-sentinel-nc01-", pipeline: "hwlab-web-probe-sentinel-nc01" }; const outer = { diff --git a/scripts/src/hwlab-node-web-probe-monitor.test.ts b/scripts/src/hwlab-node-web-probe-monitor.test.ts index 9473bb63..c409b507 100644 --- a/scripts/src/hwlab-node-web-probe-monitor.test.ts +++ b/scripts/src/hwlab-node-web-probe-monitor.test.ts @@ -19,7 +19,7 @@ test("Release A live manifests exclude disabled migration Job and select only mo expect(deployment.spec.template.metadata.labels["app.kubernetes.io/component"]).toBe("monitor-service"); expect(deployment.spec.template.spec.containers[0].image).toBe(imageRef); expect(deployment.spec.template.spec.containers[0].readinessProbe.httpGet.path).toBe("/health"); - expect(deployment.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(deployment.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" } } }), { name: "MONITOR_CENTRAL_HOST", value: "0.0.0.0" }])); expect(releaseAMonitorMigrationPool(spec, source)).toEqual({ poolMax: 4, idleTimeoutSeconds: 30 }); expect(releaseAMonitorManifests(spec, imageRef, source, { migrationJobEnabled: true }).map((item) => item.kind)).toEqual(["Service", "Deployment", "Job"]); }); @@ -42,6 +42,11 @@ test("explicit migration helper renders ordered read-only export import verify J 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" })])); }); +test("one-shot migration helper accepts a unique control-plane suffix", () => { + const job = releaseAMonitorMigrationJob(hwlabRuntimeLaneSpecForNode("v03", "NC01"), imageRef, source, { enabled: true, nameSuffix: "run-42" }) as any; + expect(job.metadata.name).toContain("source-commit-run-42"); +}); + test("Release A declares the controlled NC01 monitor database Secret projection", () => { const config = readYamlRecord>(rootPath("config/secrets-distribution.yaml"), "unidesk-secret-distribution"); expect(config.sources.files).toEqual(expect.arrayContaining([expect.objectContaining({ sourceRef: "hwlab/web-probe-monitor-db.env", type: "env", requiredKeys: ["DATABASE_URL"], createIfMissing: { enabled: false } })])); diff --git a/scripts/src/hwlab-node-web-probe-monitor.ts b/scripts/src/hwlab-node-web-probe-monitor.ts index 6798c996..5ef87c17 100644 --- a/scripts/src/hwlab-node-web-probe-monitor.ts +++ b/scripts/src/hwlab-node-web-probe-monitor.ts @@ -8,7 +8,7 @@ export function releaseAMonitorManifests(spec: HwlabRuntimeLaneSpec, imageRef: s const config = releaseAMonitorConfig(spec, resolvedSource); const { resolved, runtime, namespace, deploymentName, port, secret, pool, labels } = config; requireImageRef(imageRef); - const env = [{ name: "DATABASE_URL", valueFrom: { secretKeyRef: { name: textAt(secret, "name", resolved.ref), key: textAt(secret, "key", resolved.ref) } } }, { name: "MONITOR_CENTRAL_HOST", value: "0.0.0.0" }, { name: "MONITOR_CENTRAL_PORT", value: String(port) }, { name: "MONITOR_CENTRAL_PG_POOL_MAX", value: String(integerAt(pool, "max", resolved.ref)) }, { name: "MONITOR_CENTRAL_PG_IDLE_TIMEOUT_SECONDS", value: String(integerAt(pool, "idleTimeoutSeconds", resolved.ref)) }, { name: "MONITOR_CENTRAL_PAGE_SIZE", value: String(integerAt(runtime, "pageSize", resolved.ref)) }]; + const 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" }] } }, @@ -18,7 +18,7 @@ export function releaseAMonitorManifests(spec: HwlabRuntimeLaneSpec, imageRef: s return migrationJob === null ? manifests : [...manifests, migrationJob]; } -export function releaseAMonitorMigrationJob(spec: HwlabRuntimeLaneSpec, imageRef: string, resolvedSource: MonitorSqliteSource, options: { readonly enabled?: boolean } = {}): Record | null { +export function releaseAMonitorMigrationJob(spec: HwlabRuntimeLaneSpec, imageRef: string, resolvedSource: MonitorSqliteSource, options: { readonly enabled?: boolean; readonly nameSuffix?: string } = {}): Record | null { const config = releaseAMonitorConfig(spec, resolvedSource); const { resolved, runtime, job, namespace, secret, pool, labels } = config; requireImageRef(imageRef); @@ -29,7 +29,7 @@ export function releaseAMonitorMigrationJob(spec: HwlabRuntimeLaneSpec, imageRef 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: [...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: {} }] } } } }; + 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 } { @@ -69,5 +69,5 @@ function recordAt(value: Record, key: string, path: string): Re function textAt(value: Record, 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, 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } -function versionedJobName(base: string, imageRef: string): string { const suffix = imageRef.split(":").at(-1)?.replace(/[^a-z0-9-]/gu, "-").slice(0, 20) || "image"; return `${base}-${suffix}`.slice(0, 63).replace(/-+$/u, ""); } +function 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"); } diff --git a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts index f2464c2f..a4a33d1c 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts @@ -16,7 +16,7 @@ export type WebProbeSentinelPublishAction = "publish-current"; export type WebProbeSentinelMaintenanceAction = "status" | "start" | "stop"; export type WebProbeSentinelDashboardAction = "verify" | "screenshot" | "trigger"; export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame" | "auth-session-switch-summary"; -export type WebProbeSentinelMigrationAction = "plan" | "export" | "import" | "verify"; +export type WebProbeSentinelMigrationAction = "plan" | "export" | "import" | "verify" | "job-plan" | "job-run"; export type WebProbeSentinelSourceAuthority = "git-mirror-snapshot" | "gitea-snapshot"; export interface WebProbeSentinelSourceOverrideOptions { @@ -35,6 +35,8 @@ export type WebProbeSentinelOptions = readonly sentinelId: string | null; readonly snapshotPath: string | null; readonly confirm: boolean; + readonly wait: boolean; + readonly timeoutSeconds: number; readonly json: boolean; } | { diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index 261bbfad..6eba65f0 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -37,7 +37,7 @@ import { probeSentinelRuntimeHealthEndpoint, runSentinelDashboard, runSentinelEr import { runChildCli, sentinelP5Next } from "./hwlab-node-web-sentinel-p5-observe"; import { emitWebProbeSentinelSpan, webProbeSentinelOtelSummary } from "./hwlab-node-web-sentinel-otel"; import { exportMonitorSqliteSnapshot, planMonitorSqliteSources, verifyMonitorSqliteSnapshot, type MonitorSqliteSource } from "./monitor-sqlite-migration"; -import { releaseAMonitorManifests, releaseAMonitorMigrationPool } from "./hwlab-node-web-probe-monitor"; +import { releaseAMonitorManifests, releaseAMonitorMigrationJob, releaseAMonitorMigrationPool } from "./hwlab-node-web-probe-monitor"; import { arrayAt, arrayAtNullable, @@ -195,6 +195,7 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: function runSentinelMigration(spec: HwlabRuntimeLaneSpec, options: Extract): RenderedCliResult { const sources = resolvedMigrationSources(spec, options.sentinelId); const command = `web-probe sentinel migration ${options.action}`; + if (options.action === "job-plan" || options.action === "job-run") return runSentinelMigrationJob(spec, options, sources, command); if (options.action === "plan") { const projection = planMonitorSqliteSources(sources); return rendered(true, command, renderMigrationCompact(projection), projection); @@ -228,6 +229,34 @@ function runSentinelMigration(spec: HwlabRuntimeLaneSpec, options: Extract, sources: readonly MonitorSqliteSource[], command: string): RenderedCliResult { + if (sources.length !== 1) throw new Error(`web-probe sentinel migration ${options.action} requires --sentinel when more than one resolved registry source exists`); + const state = loadSentinelCicdState(spec, sources[0]!.sentinelId, options.timeoutSeconds, "cached"); + const manifest = releaseAMonitorMigrationJob(spec, state.image.ref, sources[0]!, { enabled: true, nameSuffix: Date.now().toString(36) }); + if (manifest === null) throw new Error("Release A migration Job helper did not render an enabled Job"); + const metadata = manifest.metadata as Record; + const namespace = String(metadata.namespace); + const jobName = String(metadata.name); + const plan = { ok: true, command, mutation: false, namespace, jobName, image: state.image.ref, source: sources[0], manifest: { apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { name: jobName, namespace } }, valuesRedacted: true }; + if (options.action === "job-plan") return rendered(true, command, renderMigrationCompact(plan), plan); + if (!options.confirm || !options.wait) throw new Error("web-probe sentinel migration job-run requires --confirm --wait"); + const created = runCommand(["trans", stringAt(state.controlPlaneNode, "kubeRoute"), "sh", "--", createK8sJobScript(namespace, manifest)], repoRoot, { timeoutMs: Math.min(options.timeoutSeconds, 60) * 1000 }); + if (created.exitCode !== 0) return rendered(false, command, "ok=false", { ...plan, ok: false, mutation: true, phase: "create-failed", create: compactCommand(created) }); + const startedAt = Date.now(); + let lastProbe = created; + while (Date.now() - startedAt < Math.max(5_000, Math.min(options.timeoutSeconds * 1000, 120_000))) { + lastProbe = runCommand(["trans", stringAt(state.controlPlaneNode, "kubeRoute"), "sh", "--", probeK8sJobScript(namespace, jobName)], repoRoot, { timeoutMs: Math.min(options.timeoutSeconds, 60) * 1000 }); + const probe = resolveSentinelChildJson(lastProbe, "web-probe-sentinel-migration-job-probe").parsed ?? {}; + if (probe.succeeded === true || probe.failed === true) { + const payload = sentinelPayloadFromLogs(String(probe.logsTail ?? "")); + const ok = probe.succeeded === true && payload.ok === true; + return rendered(ok, command, `ok=${ok}`, { ...plan, ok, mutation: true, phase: probe.succeeded === true ? "succeeded" : "failed", result: payload, valuesRedacted: true }); + } + runCommand(["sleep", "2"], repoRoot, { timeoutMs: 3_000 }); + } + return rendered(false, command, "ok=false", { ...plan, ok: false, mutation: true, phase: "timeout", probe: compactCommand(lastProbe), valuesRedacted: true }); +} + 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 && 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); } diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index 4e94905f..ab554273 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -220,11 +220,12 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe sentinel = { kind: "error", action: "get", node, lane, sentinelId, errorId, raw: args.includes("--raw"), full: args.includes("--full"), timeoutSeconds }; } else if (sentinelActionRaw === "migration") { const migrationAction = args[1]; - if (migrationAction !== "plan" && migrationAction !== "export" && migrationAction !== "import" && migrationAction !== "verify") { - throw new Error("web-probe sentinel migration usage: migration plan|export|import|verify --node NODE --lane vNN [--sentinel ID] [--snapshot PATH] [--confirm] [--json]"); + if (migrationAction !== "plan" && migrationAction !== "export" && migrationAction !== "import" && migrationAction !== "verify" && migrationAction !== "job-plan" && migrationAction !== "job-run") { + throw new Error("web-probe sentinel migration usage: migration plan|export|import|verify|job-plan|job-run --node NODE --lane vNN [--sentinel ID] [--snapshot PATH] [--confirm --wait] [--json]"); } if (migrationAction === "import" && !confirm) throw new Error("web-probe sentinel migration import requires --confirm"); - sentinel = { kind: "migration", action: migrationAction, node, lane, sentinelId, snapshotPath: optionValue(args, "--snapshot") ?? null, confirm, json: args.includes("--json") }; + if (migrationAction === "job-run" && (!confirm || !args.includes("--wait"))) throw new Error("web-probe sentinel migration job-run requires --confirm --wait"); + sentinel = { kind: "migration", action: migrationAction, node, lane, sentinelId, snapshotPath: optionValue(args, "--snapshot") ?? null, confirm, wait: args.includes("--wait"), timeoutSeconds, json: args.includes("--json") }; } else if (sentinelActionRaw === "report") { const view = parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary"); const latest = args.includes("--latest"); diff --git a/scripts/src/monitor-sqlite-migration.ts b/scripts/src/monitor-sqlite-migration.ts index c0abd30f..a6dfa297 100644 --- a/scripts/src/monitor-sqlite-migration.ts +++ b/scripts/src/monitor-sqlite-migration.ts @@ -1,7 +1,7 @@ // SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-12-p17-monitor-central-persistence. // Responsibility: Registry-resolved read-only legacy SQLite snapshots and one-shot canonical central-store import. import { createHash } from "node:crypto"; -import { closeSync, existsSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, openSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; @@ -118,14 +118,14 @@ function canonicalIngests(snapshot: MonitorSqliteSnapshot): readonly ReturnType< const rowFindings = findings.filter((item) => stringField(item, ["run_id"]) === runId).map(normalizeLegacyFinding); const report = metadataValue(metadata, `run.report.${runId}`); const rowLocators = locators.filter((item) => stringField(item, ["run_id"]) === runId).map((item) => locatorFromRow(item, snapshot)); - const reportLocator = reportLocatorFromRun(row, snapshot); const storedPayload = jsonField(payloads.find((item) => stringField(item, ["run_id"]) === runId), ["payload", "value_json"]); 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); - const artifactLocators = reportLocator ? [...rowLocators, reportLocator] : rowLocators; + const artifactLocators = rowLocators.length > 0 ? rowLocators : artifactLocatorsFromRun(row, snapshot); 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" }); + if (artifactCount !== null && artifactCount !== artifactLocators.length) throw Object.assign(new Error(`legacy artifact locator count mismatch for ${runId}`), { code: "monitor-migration-artifact-count-mismatch", diagnostic: { runId, stateDir: stringField(row, ["state_dir", "stateDir"]), expectedCount: artifactCount, observedCount: artifactLocators.length, valuesRedacted: true } }); + verifyReportLocator(row, artifactLocators); 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: [] }); }); } @@ -133,7 +133,8 @@ function targetReconciliation(ingests: readonly ReturnType ({ 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, 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 artifactLocatorsFromRun(row: Record, snapshot: MonitorSqliteSnapshot): readonly MonitorArtifactLocator[] { const stateDir = stringField(row, ["state_dir", "stateDir"]); if (!stateDir) return []; const source = artifactSourceForStateDir(snapshot.source, stateDir); const directory = artifactPath(source.mountPath, stateDir, ""); if (!existsSync(directory) || !statSync(directory).isDirectory()) throw Object.assign(new Error("legacy artifact stateDir is unavailable"), { code: "monitor-migration-artifact-path-invalid" }); return artifactFiles(directory).map((path) => { const bytes = readFileSync(path); const relativePath = path.slice(directory.length + 1).replaceAll("\\", "/"); return { ownerNode: snapshot.source.node, ownerLane: snapshot.source.lane, ownerPvc: source.ownerPvc, stateDir, relativePath, sha256: `sha256:${sha256(bytes)}`, sizeBytes: bytes.byteLength, kind: relativePath === "analysis/report.json" ? "report-json" : "artifact", reachable: true }; }); } +function verifyReportLocator(row: Record, locators: readonly MonitorArtifactLocator[]): void { const expectedHash = stringField(row, ["report_json_sha256", "reportJsonSha256"]); if (!expectedHash) return; const report = locators.find((locator) => locator.relativePath === "analysis/report.json"); if (!report || report.sha256 !== expectedHash) throw Object.assign(new Error("legacy report hash does not match artifact"), { code: "monitor-migration-report-locator-invalid" }); } 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; }); } @@ -143,7 +144,9 @@ function assertReadOnlySqliteSource(value: string): string { const path = resolv 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 artifactSourceForStateDir(source: MonitorSqliteSource, stateDir: string): MonitorArtifactSource { const directory = resolve(stateDir); const matches = source.artifactSources.filter((item) => directory === resolve(item.mountPath) || directory.startsWith(`${resolve(item.mountPath)}/`)).sort((left, right) => resolve(right.mountPath).length - resolve(left.mountPath).length); if (matches.length === 0) throw Object.assign(new Error(`legacy artifact stateDir is not registry-mounted: ${stateDir}`), { code: "monitor-migration-artifact-source-missing" }); return matches[0]!; } 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 artifactFiles(directory: string): readonly string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); if (entry.isDirectory()) return artifactFiles(path); return entry.isFile() ? [path] : []; }).sort(); } 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 88aed141..1546a0a2 100644 --- a/scripts/src/monitor-terminal-ingest-migration.test.ts +++ b/scripts/src/monitor-terminal-ingest-migration.test.ts @@ -162,21 +162,35 @@ test("workspace artifact source verifies locator identity hash and size", async 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'); + const artifacts = [ + ["analysis/report.json", Buffer.from('{"ok":true}\n')], + ["analysis/details.json", Buffer.from('{"details":true}\n')], + ["artifacts/request.txt", Buffer.from("request\n")], + ["artifacts/response.txt", Buffer.from("response\n")], + ["screenshots/page.png", Buffer.from([0x89, 0x50, 0x4e, 0x47])], + ] as const; + const reportBytes = artifacts[0][1]; mkdirSync(join(stateDir, "analysis"), { recursive: true }); - writeFileSync(join(stateDir, "analysis", "report.json"), reportBytes); + for (const [relativePath, bytes] of artifacts) { + const path = join(stateDir, relativePath); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, bytes); + } 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.run("CREATE TABLE runs (id TEXT, state_dir TEXT, artifact_count INTEGER, report_json_sha256 TEXT, updated_at TEXT)"); + database.run("INSERT INTO runs VALUES (?, ?, ?, ?, ?)", ["run-1", stateDir, 5, 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(run?.artifactLocators.length, 5); + assert.deepEqual(run?.artifactLocators.map((locator) => ({ ownerPvc: locator.ownerPvc, stateDir: locator.stateDir, relativePath: locator.relativePath, sha256: locator.sha256, sizeBytes: locator.sizeBytes })), artifacts.map(([relativePath, bytes]) => ({ ownerPvc: "legacy-web-observe-workspace-nc01", stateDir, relativePath, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, sizeBytes: bytes.byteLength })).sort((left, right) => left.relativePath.localeCompare(right.relativePath))); assert.equal((await verifyMonitorSqliteSnapshotAgainstStore(snapshot, store)).ok, true); + rmSync(join(stateDir, "artifacts", "response.txt")); + await assert.rejects(() => importMonitorSqliteSnapshot(snapshot, createInMemoryMonitorCentralStore()), (error: unknown) => { const migrationError = error as { code?: string; diagnostic?: Record }; return migrationError.code === "monitor-migration-artifact-count-mismatch" && migrationError.diagnostic?.runId === "run-1" && migrationError.diagnostic?.stateDir === stateDir && migrationError.diagnostic?.expectedCount === 5 && migrationError.diagnostic?.observedCount === 4; }); } finally { rmSync(directory, { recursive: true, force: true }); }