fix: harden monitor migration snapshots
This commit is contained in:
@@ -22,6 +22,7 @@ import { hwlabNodeHelp } from "./hwlab-node-help";
|
||||
import { runHwlabNodeCommand } from "./hwlab-node/entry";
|
||||
import {
|
||||
guardedSentinelDeliveryAction,
|
||||
migrationJobFailed,
|
||||
migrationJobFailureSummary,
|
||||
runWebProbeSentinelCommand,
|
||||
sameMigrationSource,
|
||||
@@ -297,12 +298,23 @@ describe("migrated CLI 执行 guard 与提示清理", () => {
|
||||
});
|
||||
|
||||
test("migration Job 失败输出容器终态、有界日志和 Secret 脱敏", () => {
|
||||
const failure = migrationJobFailureSummary({ podPhase: "Failed", containers: [{ name: "export", phase: "terminated", exitCode: 2, reason: "Error", message: "artifact source unavailable" }], logsTail: "DATABASE_URL=postgres://user:PW123@host/db Bearer token-value password=PW123" }, { ok: false, command: "export", error: { name: "Error", code: "monitor-migration-artifact-path-invalid", message: "cannot export postgres://user:PW123@host/db?token=abc", stack: "stack with password=PW123" } });
|
||||
const failure = migrationJobFailureSummary({ podPhase: "Failed", containers: [{ name: "export", phase: "terminated", exitCode: 2, reason: "Error", message: "artifact source unavailable" }], logsTail: "DATABASE_URL=https://user:PW123@host/db Bearer token-value password=PW123" }, { ok: false, command: "export", error: { name: "Error", code: "monitor-migration-artifact-path-invalid", message: "cannot export https://user:PW123@host/db?token=abc", stack: "stack with password=PW123" } });
|
||||
expect(failure).toMatchObject({ phase: "Failed", container: "export", exitCode: 2, status: "Error", valuesRedacted: true });
|
||||
expect(String(failure.message)).toContain("cannot export");
|
||||
expect(JSON.stringify(failure)).not.toMatch(/PW123|token-value|abc|stack/iu);
|
||||
});
|
||||
|
||||
test("migration Job 业务失败、创建失败和超时均只返回安全 failure 投影", () => {
|
||||
const business = migrationJobFailed("migration", { jobName: "job" }, "result-failed", { ok: false, error: { message: "https://user:PW123@host failed", stack: "secret stack" } }, { podPhase: "Succeeded", logsTail: "Bearer abc" }) as { projection?: Record<string, unknown> };
|
||||
const createFailed = migrationJobFailed("migration", { jobName: "job" }, "create-failed", {}, { podPhase: "CreateFailed", logsTail: "DATABASE_URL=https://user:PW123@host/db" }) as { projection?: Record<string, unknown> };
|
||||
const timeout = migrationJobFailed("migration", { jobName: "job" }, "timeout", {}, { podPhase: "Timeout", logsTail: "password=PW123" }) as { projection?: Record<string, unknown> };
|
||||
for (const result of [business, createFailed, timeout]) {
|
||||
expect(result.projection).toMatchObject({ ok: false, valuesRedacted: true });
|
||||
expect(result.projection?.result).toBeUndefined();
|
||||
expect(JSON.stringify(result.projection)).not.toMatch(/PW123|abc|stack/iu);
|
||||
}
|
||||
});
|
||||
|
||||
test("migration source 仅接受按 runId 重排的相同 gap 集合", () => {
|
||||
const source = { sentinelId: "fixture", node: "NC01", lane: "v03", path: "/state/index.sqlite", runtimeConfigRef: "fixture#runtime", stateRoot: "/state", ownerPvc: "fixture-pvc", artifactSources: [{ ownerPvc: "fixture-pvc", mountPath: "/state" }], legacyArtifactGaps: [{ runId: "b", artifactCount: 2, stateDir: ".state/b", reportSha256: `sha256:${"b".repeat(64)}` }, { runId: "a", artifactCount: 1, stateDir: ".state/a", reportSha256: `sha256:${"a".repeat(64)}` }] };
|
||||
expect(sameMigrationSource(source, { ...source, legacyArtifactGaps: [...source.legacyArtifactGaps].reverse() })).toBe(true);
|
||||
|
||||
@@ -48,11 +48,11 @@ test("one-shot migration helper accepts a unique control-plane suffix", () => {
|
||||
});
|
||||
|
||||
test("Release A declares exactly the two approved pre-migration artifact gaps", () => {
|
||||
expect(releaseAMonitorMigrationArtifactGaps(hwlabRuntimeLaneSpecForNode("v03", "NC01"), source)).toEqual([
|
||||
expect.objectContaining({ runId: "sentinel-run-mrbjo83t-ef79c71d", artifactCount: 1, reportSha256: "sha256:119c9e3ef4cbc41143121ee5d1af8955a8454e5efdbb6600f64a207a0f148b33" }),
|
||||
expect.objectContaining({ runId: "sentinel-run-mrbjsugk-764ff868", artifactCount: 4 }),
|
||||
]);
|
||||
expect(releaseAMonitorMigrationArtifactGaps(hwlabRuntimeLaneSpecForNode("v03", "NC01"), source).every((gap) => /^sha256:[a-f0-9]{64}$/u.test(gap.reportSha256))).toBe(true);
|
||||
const config = readYamlRecord<Record<string, any>>(rootPath("config/hwlab-web-probe-monitor/runtime.yaml"), "HwlabWebProbeMonitorRuntime");
|
||||
const declared = config.monitor.migration.legacyArtifactGaps;
|
||||
const resolved = releaseAMonitorMigrationArtifactGaps(hwlabRuntimeLaneSpecForNode("v03", "NC01"), source);
|
||||
expect(resolved).toEqual([...declared].sort((left: { runId: string }, right: { runId: string }) => left.runId.localeCompare(right.runId)));
|
||||
expect(resolved.every((gap) => gap.artifactCount > 0 && gap.stateDir.startsWith(".") && /^sha256:[a-f0-9]{64}$/u.test(gap.reportSha256))).toBe(true);
|
||||
});
|
||||
|
||||
test("Release A declares the controlled NC01 monitor database Secret projection", () => {
|
||||
|
||||
@@ -241,7 +241,7 @@ function runSentinelMigrationJob(spec: HwlabRuntimeLaneSpec, options: Extract<We
|
||||
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) });
|
||||
if (created.exitCode !== 0) return migrationJobFailed(command, plan, "create-failed", {}, { podPhase: "CreateFailed", logsTail: "migration Job creation failed" });
|
||||
const startedAt = Date.now();
|
||||
let lastProbe = created;
|
||||
while (Date.now() - startedAt < Math.max(5_000, Math.min(options.timeoutSeconds * 1000, 120_000))) {
|
||||
@@ -249,13 +249,16 @@ function runSentinelMigrationJob(spec: HwlabRuntimeLaneSpec, options: Extract<We
|
||||
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;
|
||||
const failure = probe.failed === true ? migrationJobFailureSummary(probe, payload) : null;
|
||||
return rendered(ok, command, `ok=${ok}`, { ...plan, ok, mutation: true, phase: probe.succeeded === true ? "succeeded" : "failed", result: probe.succeeded === true ? payload : undefined, failure, next: failure === null ? undefined : migrationJobFailureNext(state, namespace, jobName), valuesRedacted: true });
|
||||
if (probe.succeeded === true && payload.ok === true) return rendered(true, command, "ok=true", { ...plan, ok: true, mutation: true, phase: "succeeded", result: payload, valuesRedacted: true });
|
||||
return migrationJobFailed(command, plan, probe.succeeded === true ? "result-failed" : "failed", payload, probe, migrationJobFailureNext(state, namespace, jobName));
|
||||
}
|
||||
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 });
|
||||
return migrationJobFailed(command, plan, "timeout", {}, { podPhase: "Timeout", logsTail: "migration Job did not reach a successful result before timeout" }, migrationJobFailureNext(state, namespace, jobName));
|
||||
}
|
||||
|
||||
export function migrationJobFailed(command: string, plan: Record<string, unknown>, phase: string, payload: Record<string, unknown>, probe: Record<string, unknown>, next?: Record<string, string>): RenderedCliResult {
|
||||
return rendered(false, command, "ok=false", { ...plan, ok: false, mutation: true, phase, failure: migrationJobFailureSummary(probe, payload), next, valuesRedacted: true });
|
||||
}
|
||||
|
||||
export function migrationJobFailureSummary(probe: Record<string, unknown>, payload: Record<string, unknown>): Record<string, unknown> {
|
||||
@@ -268,7 +271,7 @@ export function migrationJobFailureSummary(probe: Record<string, unknown>, paylo
|
||||
|
||||
function redactMigrationJobLogTail(value: string): string {
|
||||
return value
|
||||
.replace(/\b(?:postgres(?:ql)?|mysql):\/\/[^\s"']+/giu, "<redacted-url>")
|
||||
.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s"']*@[^\s"']+/giu, "<redacted-url>")
|
||||
.replace(/\bBearer\s+[^\s"']+/giu, "Bearer <redacted>")
|
||||
.replace(/(["']?(?:DATABASE_URL|password|token|secret)["']?\s*[=:]\s*["']?)[^\s,"'}\]]+/giu, "$1<redacted>")
|
||||
.replace(/([?&](?:password|token|secret)=[^&\s"']+)/giu, "<redacted-query>");
|
||||
@@ -283,7 +286,7 @@ export function sameMigrationSource(left: MonitorSqliteSource, right: MonitorSql
|
||||
}
|
||||
|
||||
function monitorGapSet(gaps: readonly { readonly runId: string; readonly artifactCount: number; readonly stateDir: string; readonly reportSha256: string }[] | undefined): string {
|
||||
return JSON.stringify([...(gaps ?? [])].sort((left, right) => left.runId.localeCompare(right.runId)));
|
||||
return [...(gaps ?? [])].map((gap) => [gap.runId, gap.artifactCount, gap.stateDir, gap.reportSha256] as const).sort((left, right) => left[0].localeCompare(right[0])).map((gap) => JSON.stringify(gap)).join("\n");
|
||||
}
|
||||
|
||||
function resolvedMigrationSources(spec: HwlabRuntimeLaneSpec, sentinelId: string | null): readonly MonitorSqliteSource[] {
|
||||
|
||||
@@ -11,8 +11,8 @@ import { monitorCentralStableJson, normalizeMonitorCentralIngest } from "./monit
|
||||
export const MONITOR_SQLITE_MIGRATION_SCHEMA = "2026-07-12-p17-3";
|
||||
export interface MonitorArtifactSource { readonly ownerPvc: string; readonly mountPath: string; readonly hostPath?: string; }
|
||||
export interface MonitorLegacyArtifactGap { readonly runId: string; readonly artifactCount: number; readonly stateDir: string; readonly reportSha256: string; }
|
||||
export interface MonitorArtifactManifest { readonly runId: string; readonly locators: readonly MonitorArtifactLocator[]; readonly missingBeforeMigration?: MonitorLegacyArtifactGap; }
|
||||
function freezeArtifactManifests(snapshot: Pick<MonitorSqliteSnapshot, "source" | "tables">): readonly MonitorArtifactManifest[] { const rows = tableRows(snapshot.tables, ["runs", "run"]); const locatorRows = tableRows(snapshot.tables, ["artifact_locators", "locators"]); const declared = snapshot.source.legacyArtifactGaps ?? []; const used = new Set<string>(); const manifests = rows.map((row) => { const runId = stringField(row, ["run_id", "id"]); const gap = declared.find((item) => item.runId === runId); const expectedCount = numberField(row, ["artifact_count", "artifactCount"]); const stateDir = stringField(row, ["state_dir", "stateDir"]); const reportSha256 = stringField(row, ["report_json_sha256", "reportJsonSha256"]); const rowLocators = locatorRows.filter((item) => stringField(item, ["run_id"]) === runId).map((item) => locatorFromRow(item, snapshot)); if (gap !== undefined) { if (gap.artifactCount !== expectedCount || gap.stateDir !== stateDir || gap.reportSha256 !== reportSha256) throw artifactGapIdentityInvalid(runId, gap, { artifactCount: expectedCount, stateDir, reportSha256 }); if (rowLocators.length !== 0) throw Object.assign(new Error(`declared missing artifact gap unexpectedly has locators for ${runId}`), { code: "monitor-migration-artifact-gap-locator-unexpected" }); used.add(runId); return { runId, locators: [], missingBeforeMigration: gap }; } const locators = rowLocators.length > 0 ? rowLocators : artifactLocatorsFromRun(row, snapshot); if (expectedCount !== null && expectedCount !== locators.length) throw artifactCountMismatch(runId, stateDir, expectedCount, locators.length); verifyReportLocator(row, locators); return { runId, locators }; }); if (used.size !== declared.length) throw Object.assign(new Error("declared legacy artifact gap was not found in the SQLite snapshot"), { code: "monitor-migration-artifact-gap-unmatched" }); return manifests; }
|
||||
export interface MonitorArtifactManifest { readonly runId: string; readonly artifactCount: number; readonly stateDir: string; readonly reportSha256: string; readonly ownerPvc: string | null; readonly locators: readonly MonitorArtifactLocator[]; readonly missingBeforeMigration?: MonitorLegacyArtifactGap; }
|
||||
function freezeArtifactManifests(snapshot: Pick<MonitorSqliteSnapshot, "source" | "tables">): readonly MonitorArtifactManifest[] { const rows = tableRows(snapshot.tables, ["runs", "run"]); const locatorRows = tableRows(snapshot.tables, ["artifact_locators", "locators"]); const declared = snapshot.source.legacyArtifactGaps ?? []; const used = new Set<string>(); const manifests = rows.map((row) => { const runId = stringField(row, ["run_id", "id"]); const gap = declared.find((item) => item.runId === runId); const rowCount = numberField(row, ["artifact_count", "artifactCount"]); const stateDir = stringField(row, ["state_dir", "stateDir"]); const reportSha256 = stringField(row, ["report_json_sha256", "reportJsonSha256"]); const rowLocators = locatorRows.filter((item) => stringField(item, ["run_id"]) === runId).map((item) => locatorFromRow(item, snapshot)); if (gap !== undefined) { if (gap.artifactCount !== rowCount || gap.stateDir !== stateDir || gap.reportSha256 !== reportSha256) throw artifactGapIdentityInvalid(runId, gap, { artifactCount: rowCount, stateDir, reportSha256 }); if (rowLocators.length !== 0) throw Object.assign(new Error(`declared missing artifact gap unexpectedly has locators for ${runId}`), { code: "monitor-migration-artifact-gap-locator-unexpected" }); used.add(runId); return { runId, artifactCount: gap.artifactCount, stateDir, reportSha256, ownerPvc: null, locators: [], missingBeforeMigration: gap }; } const locators = rowLocators.length > 0 ? rowLocators : artifactLocatorsFromRun(row, snapshot); const artifactCount = rowCount ?? locators.length; if (artifactCount !== locators.length) throw artifactCountMismatch(runId, stateDir, artifactCount, locators.length); verifyReportLocator(row, locators); const ownerPvc = locators[0]?.ownerPvc ?? (stateDir ? artifactSourceForRunStateDir(snapshot.source, stateDir).ownerPvc : null); return { runId, artifactCount, stateDir, reportSha256, ownerPvc, locators }; }); if (used.size !== declared.length) throw Object.assign(new Error("declared legacy artifact gap was not found in the SQLite snapshot"), { code: "monitor-migration-artifact-gap-unmatched" }); return manifests; }
|
||||
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[]; readonly legacyArtifactGaps?: readonly MonitorLegacyArtifactGap[]; }
|
||||
export interface MonitorSqliteTable { readonly name: string; readonly sql: string; readonly rowCount: number; readonly rows: readonly Record<string, unknown>[]; }
|
||||
export interface MonitorSqliteSnapshot {
|
||||
@@ -154,7 +154,8 @@ 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 artifactSourceForStateDir(source: MonitorSqliteSource, stateDir: string): MonitorArtifactSource { if (!stateDir.startsWith("/") && source.artifactSources.length === 1) return source.artifactSources[0]!; 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 artifactSourceForRunStateDir(source: MonitorSqliteSource, stateDir: string): MonitorArtifactSource { return artifactSourceForStateDir(source, stateDir); }
|
||||
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 verifyFrozenArtifactManifests(snapshot: MonitorSqliteSnapshot): void {
|
||||
@@ -179,21 +180,21 @@ function verifyFrozenArtifactManifests(snapshot: MonitorSqliteSnapshot): void {
|
||||
if (actual.sha256 !== locator.sha256 || actual.sizeBytes !== locator.sizeBytes) throw frozenArtifactInvalid(manifest, manifest.locators.length);
|
||||
observed.set(`${locator.ownerPvc}\u0000${locator.stateDir}\u0000${locator.relativePath}`, actual);
|
||||
}
|
||||
for (const locator of manifest.locators) {
|
||||
const source = artifactSourceFor(snapshot.source, locator.ownerPvc);
|
||||
const directory = artifactPath(source.mountPath, locator.stateDir, "");
|
||||
if (!existsSync(directory) || !statSync(directory).isDirectory()) throw frozenArtifactInvalid(manifest, null);
|
||||
for (const path of artifactFiles(directory)) {
|
||||
const relativePath = path.slice(directory.length + 1).replaceAll("\\", "/");
|
||||
const key = `${locator.ownerPvc}\u0000${locator.stateDir}\u0000${relativePath}`;
|
||||
if (!expected.has(key)) throw frozenArtifactInvalid(manifest, manifest.locators.length + 1);
|
||||
}
|
||||
if (!manifest.stateDir) { if (manifest.locators.length !== 0 || manifest.artifactCount !== 0) throw frozenArtifactInvalid(manifest, null); continue; }
|
||||
if (manifest.ownerPvc === null) throw frozenArtifactInvalid(manifest, null);
|
||||
const source = artifactSourceFor(snapshot.source, manifest.ownerPvc);
|
||||
const directory = artifactPath(source.mountPath, manifest.stateDir, "");
|
||||
if (!existsSync(directory) || !statSync(directory).isDirectory()) throw frozenArtifactInvalid(manifest, null);
|
||||
for (const path of artifactFiles(directory)) {
|
||||
const relativePath = path.slice(directory.length + 1).replaceAll("\\", "/");
|
||||
const key = `${manifest.ownerPvc}\u0000${manifest.stateDir}\u0000${relativePath}`;
|
||||
if (!expected.has(key)) throw frozenArtifactInvalid(manifest, manifest.locators.length + 1);
|
||||
}
|
||||
if (observed.size !== expected.size) throw frozenArtifactInvalid(manifest, observed.size);
|
||||
}
|
||||
}
|
||||
|
||||
function frozenArtifactInvalid(manifest: MonitorArtifactManifest, observedCount: number | null): Error { return Object.assign(new Error(`frozen artifact set changed for ${manifest.runId}`), { code: "monitor-migration-frozen-artifact-invalid", diagnostic: { runId: manifest.runId, stateDir: manifest.locators[0]?.stateDir ?? manifest.missingBeforeMigration?.stateDir ?? "", expectedCount: manifest.locators.length, observedCount, valuesRedacted: true } }); }
|
||||
function frozenArtifactInvalid(manifest: MonitorArtifactManifest, observedCount: number | null): Error { return Object.assign(new Error(`frozen artifact set changed for ${manifest.runId}`), { code: "monitor-migration-frozen-artifact-invalid", diagnostic: { runId: manifest.runId, stateDir: manifest.stateDir, expectedCount: manifest.locators.length, observedCount, valuesRedacted: true } }); }
|
||||
function verifySnapshotReferentialIntegrity(snapshot: MonitorSqliteSnapshot): void {
|
||||
const rows = tableRows(snapshot.tables, ["runs", "run"]);
|
||||
const rowByRunId = new Map<string, Record<string, unknown>>();
|
||||
@@ -207,7 +208,13 @@ function verifySnapshotReferentialIntegrity(snapshot: MonitorSqliteSnapshot): vo
|
||||
for (const [runId, manifest] of manifestByRunId) {
|
||||
const row = rowByRunId.get(runId)!;
|
||||
const gap = manifest.missingBeforeMigration;
|
||||
if (gap === undefined) { if (declaredByRunId.has(runId)) throw Object.assign(new Error("declared gap is missing its frozen manifest"), { code: "monitor-migration-snapshot-referential-invalid" }); continue; }
|
||||
if (gap === undefined) {
|
||||
const rowCount = numberField(row, ["artifact_count", "artifactCount"]);
|
||||
const stateDir = stringField(row, ["state_dir", "stateDir"]);
|
||||
const reportSha256 = stringField(row, ["report_json_sha256", "reportJsonSha256"]);
|
||||
if (declaredByRunId.has(runId) || manifest.artifactCount !== (rowCount ?? manifest.locators.length) || manifest.stateDir !== stateDir || manifest.reportSha256 !== reportSha256 || (stateDir.length > 0 && manifest.ownerPvc === null) || manifest.locators.some((locator) => locator.ownerPvc !== manifest.ownerPvc || locator.stateDir !== stateDir) || manifest.locators.length !== manifest.artifactCount) throw Object.assign(new Error("snapshot normal artifact manifest does not match run identity"), { code: "monitor-migration-snapshot-referential-invalid" });
|
||||
continue;
|
||||
}
|
||||
const declaredGap = declaredByRunId.get(runId);
|
||||
if (gap.runId !== runId || declaredGap === undefined || !sameArtifactGap(gap, declaredGap) || gap.artifactCount !== numberField(row, ["artifact_count", "artifactCount"]) || gap.stateDir !== stringField(row, ["state_dir", "stateDir"]) || gap.reportSha256 !== stringField(row, ["report_json_sha256", "reportJsonSha256"])) throw Object.assign(new Error("snapshot legacy artifact gap does not match run identity"), { code: "monitor-migration-snapshot-referential-invalid" });
|
||||
}
|
||||
|
||||
@@ -207,8 +207,8 @@ test("declared pre-migration artifact gaps import empty locators and fail closed
|
||||
try {
|
||||
const sqlitePath = join(directory, "index.sqlite");
|
||||
const gaps = [
|
||||
{ runId: "sentinel-run-mrbjo83t-ef79c71d", artifactCount: 1, stateDir: ".state/web-observe/NC01/v03/2026/07/08/20260708T035504Z_workbench_webobs-mrbjo88n-c5bed5", reportSha256: "sha256:119c9e3ef4cbc41143121ee5d1af8955a8454e5efdbb6600f64a207a0f148b33" },
|
||||
{ runId: "sentinel-run-mrbjsugk-764ff868", artifactCount: 4, stateDir: ".state/web-observe/NC01/v03/2026/07/08/20260708T035839Z_workbench_webobs-mrbjsul8-5952f4", reportSha256: "sha256:d2dbc0fc98f39d7373b9ae009e31fcd8d666014c2e32428f235150ee59666048" },
|
||||
{ runId: "gap-run-a", artifactCount: 1, stateDir: ".state/missing/a", reportSha256: `sha256:${"a".repeat(64)}` },
|
||||
{ runId: "gap-run-b", artifactCount: 4, stateDir: ".state/missing/b", reportSha256: `sha256:${"b".repeat(64)}` },
|
||||
];
|
||||
const database = new Database(sqlitePath);
|
||||
database.run("CREATE TABLE runs (id TEXT, state_dir TEXT, artifact_count INTEGER, report_json_sha256 TEXT, updated_at TEXT)");
|
||||
@@ -239,7 +239,7 @@ test("declared pre-migration artifact gaps import empty locators and fail closed
|
||||
rmSync(recoveredPath);
|
||||
assert.throws(() => exportMonitorSqliteSnapshot({ ...source, legacyArtifactGaps: [{ ...gaps[0], artifactCount: 2 }, gaps[1]] }, join(directory, "changed.json")), (error: unknown) => (error as { code?: string }).code === "monitor-migration-artifact-gap-identity-invalid");
|
||||
assert.throws(() => exportMonitorSqliteSnapshot({ ...source, legacyArtifactGaps: [{ ...gaps[0], stateDir: "wrong-state-dir" }, gaps[1]] }, join(directory, "wrong-state-dir.json")), (error: unknown) => (error as { code?: string }).code === "monitor-migration-artifact-gap-identity-invalid");
|
||||
assert.throws(() => exportMonitorSqliteSnapshot({ ...source, legacyArtifactGaps: [{ ...gaps[0], reportSha256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, gaps[1]] }, join(directory, "wrong-report.json")), (error: unknown) => (error as { code?: string }).code === "monitor-migration-artifact-gap-identity-invalid");
|
||||
assert.throws(() => exportMonitorSqliteSnapshot({ ...source, legacyArtifactGaps: [{ ...gaps[0], reportSha256: `sha256:${"c".repeat(64)}` }, gaps[1]] }, join(directory, "wrong-report.json")), (error: unknown) => (error as { code?: string }).code === "monitor-migration-artifact-gap-identity-invalid");
|
||||
assert.throws(() => exportMonitorSqliteSnapshot({ ...source, legacyArtifactGaps: [...gaps, { runId: "third", artifactCount: 1, stateDir: "missing", reportSha256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }] }, join(directory, "third.json")), (error: unknown) => (error as { code?: string }).code === "monitor-migration-artifact-gap-unmatched");
|
||||
const unexpectedArtifact = Buffer.from("unexpected\n");
|
||||
const unexpectedPath = join(directory, gaps[0].stateDir, "extra.bin");
|
||||
@@ -255,6 +255,42 @@ test("declared pre-migration artifact gaps import empty locators and fail closed
|
||||
}
|
||||
});
|
||||
|
||||
test("empty normal artifact directories freeze owner state and normal manifests cannot be exchanged", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "monitor-empty-artifact-"));
|
||||
try {
|
||||
const sqlitePath = join(directory, "index.sqlite");
|
||||
const emptyStateDir = ".state/empty";
|
||||
const fullStateDir = ".state/full";
|
||||
mkdirSync(join(directory, emptyStateDir), { recursive: true });
|
||||
const fullDirectory = join(directory, fullStateDir);
|
||||
mkdirSync(join(fullDirectory, "analysis"), { recursive: true });
|
||||
const report = Buffer.from("report\n");
|
||||
writeFileSync(join(fullDirectory, "analysis", "report.json"), report);
|
||||
const database = new Database(sqlitePath);
|
||||
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 (?, ?, ?, ?, ?)", ["empty", emptyStateDir, 0, "", "2026-07-12T00:00:00.000Z"]);
|
||||
database.run("INSERT INTO runs VALUES (?, ?, ?, ?, ?)", ["full", fullStateDir, 1, `sha256:${createHash("sha256").update(report).digest("hex")}`, "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, join(directory, "snapshot.json"));
|
||||
const parsedSnapshot = JSON.parse(readFileSync(join(directory, "snapshot.json"), "utf8"));
|
||||
const roundtripStore = createInMemoryMonitorCentralStore();
|
||||
await importMonitorSqliteSnapshot(parsedSnapshot, roundtripStore);
|
||||
assert.equal((await verifyMonitorSqliteSnapshotAgainstStore(parsedSnapshot, roundtripStore)).ok, true);
|
||||
const emptyManifest = snapshot.artifactManifests.find((manifest) => manifest.runId === "empty");
|
||||
assert.deepEqual({ artifactCount: emptyManifest?.artifactCount, stateDir: emptyManifest?.stateDir, ownerPvc: emptyManifest?.ownerPvc, locatorCount: emptyManifest?.locators.length }, { artifactCount: 0, stateDir: emptyStateDir, ownerPvc: "fixture-pvc", locatorCount: 0 });
|
||||
const swappedManifests = snapshot.artifactManifests.map((manifest, index, manifests) => ({ ...manifests[(index + 1) % manifests.length], runId: manifest.runId }));
|
||||
const swappedUnsigned = { ...snapshot, artifactManifests: swappedManifests };
|
||||
assert.equal(verifyMonitorSqliteSnapshot({ ...swappedUnsigned, manifestContentHash: monitorSnapshotContentHash(swappedUnsigned) }).ok, false);
|
||||
const emptyDirectory = join(directory, emptyStateDir);
|
||||
mkdirSync(emptyDirectory, { recursive: true });
|
||||
writeFileSync(join(emptyDirectory, "added-after-export.bin"), "added\n");
|
||||
await assert.rejects(() => importMonitorSqliteSnapshot(snapshot, createInMemoryMonitorCentralStore()), (error: unknown) => (error as { code?: string }).code === "monitor-migration-frozen-artifact-invalid");
|
||||
} 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 {
|
||||
|
||||
Reference in New Issue
Block a user