fix: bind migration failure redaction
This commit is contained in:
@@ -304,6 +304,15 @@ describe("migrated CLI 执行 guard 与提示清理", () => {
|
||||
expect(JSON.stringify(failure)).not.toMatch(/PW123|token-value|abc|stack/iu);
|
||||
});
|
||||
|
||||
test("migration Job 先脱敏长 userinfo 再截断 nested message 和日志", () => {
|
||||
const credential = "LONG-USERINFO-CREDENTIAL-MARKER";
|
||||
const longUrl = `custom+ssh://u:${credential.repeat(12)}@host.example.test/path`;
|
||||
const failure = migrationJobFailureSummary({ podPhase: "Failed", logsTail: `${longUrl} logs` }, { ok: false, error: { message: `${longUrl} nested message` } });
|
||||
expect(String(failure.message)).toContain("<redacted-url>");
|
||||
expect(String(failure.logsTail)).toContain("<redacted-url>");
|
||||
expect(JSON.stringify(failure)).not.toContain(credential);
|
||||
});
|
||||
|
||||
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> };
|
||||
|
||||
@@ -262,11 +262,15 @@ export function migrationJobFailed(command: string, plan: Record<string, unknown
|
||||
}
|
||||
|
||||
export function migrationJobFailureSummary(probe: Record<string, unknown>, payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const containers = Array.isArray(probe.containers) ? probe.containers.filter(isRecord).map((item) => ({ name: stringAtNullable(item, "name"), phase: stringAtNullable(item, "phase"), exitCode: finiteNumberOrNull(item.exitCode), reason: stringAtNullable(item, "reason"), message: short(stringAtNullable(item, "message") ?? "") })).filter((item) => item.phase === "terminated" || item.exitCode !== null || item.reason !== null) : [];
|
||||
const containers = Array.isArray(probe.containers) ? probe.containers.filter(isRecord).map((item) => ({ name: stringAtNullable(item, "name"), phase: stringAtNullable(item, "phase"), exitCode: finiteNumberOrNull(item.exitCode), reason: stringAtNullable(item, "reason"), message: redactedMigrationJobText(stringAtNullable(item, "message") ?? "") })).filter((item) => item.phase === "terminated" || item.exitCode !== null || item.reason !== null) : [];
|
||||
const failed = containers.find((item) => item.exitCode !== null && item.exitCode !== 0) ?? containers[0] ?? null;
|
||||
const logsTail = redactMigrationJobLogTail(short(String(probe.logsTail ?? "")));
|
||||
const logsTail = redactedMigrationJobText(String(probe.logsTail ?? ""));
|
||||
const nestedError = isRecord(payload.error) ? payload.error : null;
|
||||
return { phase: stringAtNullable(probe, "podPhase") ?? "Failed", container: failed?.name ?? null, exitCode: failed?.exitCode ?? null, status: failed?.reason ?? stringAtNullable(nestedError, "code") ?? stringAtNullable(probe, "reason") ?? "JobFailed", message: redactMigrationJobLogTail(short(stringAtNullable(nestedError, "message") ?? stringAtNullable(payload, "message") ?? failed?.message ?? "migration Job failed before a structured command result was emitted")), logsTail, valuesRedacted: true };
|
||||
return { phase: stringAtNullable(probe, "podPhase") ?? "Failed", container: failed?.name ?? null, exitCode: failed?.exitCode ?? null, status: failed?.reason ?? stringAtNullable(nestedError, "code") ?? stringAtNullable(probe, "reason") ?? "JobFailed", message: redactedMigrationJobText(stringAtNullable(nestedError, "message") ?? stringAtNullable(payload, "message") ?? failed?.message ?? "migration Job failed before a structured command result was emitted"), logsTail, valuesRedacted: true };
|
||||
}
|
||||
|
||||
function redactedMigrationJobText(value: string): string {
|
||||
return short(redactMigrationJobLogTail(value));
|
||||
}
|
||||
|
||||
function redactMigrationJobLogTail(value: string): string {
|
||||
|
||||
@@ -212,7 +212,10 @@ function verifySnapshotReferentialIntegrity(snapshot: MonitorSqliteSnapshot): vo
|
||||
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" });
|
||||
const locatorRows = tableRows(snapshot.tables, ["artifact_locators", "locators"]);
|
||||
const expectedLocators = locatorRows.filter((locator) => stringField(locator, ["run_id"]) === runId).map((locator) => locatorBindingFromRow(locator, snapshot));
|
||||
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 || (locatorRows.length > 0 && !sameLocatorBindings(manifest.locators, expectedLocators))) throw Object.assign(new Error("snapshot normal artifact manifest does not match run identity"), { code: "monitor-migration-snapshot-referential-invalid" });
|
||||
verifyReportLocator(row, manifest.locators);
|
||||
continue;
|
||||
}
|
||||
const declaredGap = declaredByRunId.get(runId);
|
||||
@@ -220,6 +223,8 @@ function verifySnapshotReferentialIntegrity(snapshot: MonitorSqliteSnapshot): vo
|
||||
}
|
||||
if ([...declaredByRunId.keys()].some((runId) => manifestByRunId.get(runId)?.missingBeforeMigration === undefined)) throw Object.assign(new Error("snapshot declared gap has no matching frozen manifest"), { code: "monitor-migration-snapshot-referential-invalid" });
|
||||
}
|
||||
function locatorBindingFromRow(row: Record<string, unknown>, snapshot: Pick<MonitorSqliteSnapshot, "source">): MonitorArtifactLocator { return { ownerNode: stringField(row, ["owner_node", "ownerNode"]) || snapshot.source.node, ownerLane: stringField(row, ["owner_lane", "ownerLane"]) || snapshot.source.lane, ownerPvc: stringField(row, ["owner_pvc", "ownerPvc"]), stateDir: stringField(row, ["state_dir", "stateDir"]), relativePath: stringField(row, ["relative_path", "relativePath"]), sha256: stringField(row, ["sha256"]), sizeBytes: numberField(row, ["size_bytes", "sizeBytes"]) ?? -1, kind: stringField(row, ["kind"]) || "artifact", reachable: row.reachable !== false }; }
|
||||
function sameLocatorBindings(left: readonly MonitorArtifactLocator[], right: readonly MonitorArtifactLocator[]): boolean { const key = (locator: MonitorArtifactLocator): string => [locator.ownerNode, locator.ownerLane, locator.ownerPvc, locator.stateDir, locator.relativePath, locator.sha256, locator.sizeBytes, locator.kind, locator.reachable].join("\u0000"); return left.length === right.length && left.map(key).sort().every((value, index) => value === right.map(key).sort()[index]); }
|
||||
function artifactCountMismatch(runId: string, stateDir: string, expectedCount: number, observedCount: number): Error { return Object.assign(new Error(`legacy artifact locator count mismatch for ${runId}`), { code: "monitor-migration-artifact-count-mismatch", diagnostic: { runId, stateDir, expectedCount, observedCount, valuesRedacted: true } }); }
|
||||
function artifactGapIdentityInvalid(runId: string, expected: MonitorLegacyArtifactGap, observed: Record<string, unknown>): Error { return Object.assign(new Error(`legacy artifact gap identity mismatch for ${runId}`), { code: "monitor-migration-artifact-gap-identity-invalid", diagnostic: { runId, expected, observed, valuesRedacted: true } }); }
|
||||
function sameArtifactGap(left: MonitorLegacyArtifactGap, right: MonitorLegacyArtifactGap): boolean { return left.runId === right.runId && left.artifactCount === right.artifactCount && left.stateDir === right.stateDir && left.reportSha256 === right.reportSha256; }
|
||||
|
||||
@@ -291,6 +291,36 @@ test("empty normal artifact directories freeze owner state and normal manifests
|
||||
}
|
||||
});
|
||||
|
||||
test("normal report locator hash cannot drift from its SQLite row after a rehashed snapshot", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "monitor-report-locator-binding-"));
|
||||
try {
|
||||
const sqlitePath = join(directory, "index.sqlite");
|
||||
const stateDir = ".state/run";
|
||||
const reportPath = join(directory, stateDir, "analysis", "report.json");
|
||||
const reportA = Buffer.from("report A\n");
|
||||
const reportB = Buffer.from("report B with different size\n");
|
||||
mkdirSync(join(reportPath, ".."), { recursive: true });
|
||||
writeFileSync(reportPath, reportA);
|
||||
const reportAHash = `sha256:${createHash("sha256").update(reportA).digest("hex")}`;
|
||||
const reportBHash = `sha256:${createHash("sha256").update(reportB).digest("hex")}`;
|
||||
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("CREATE TABLE artifact_locators (run_id TEXT, owner_pvc TEXT, state_dir TEXT, relative_path TEXT, sha256 TEXT, size_bytes INTEGER)");
|
||||
database.run("INSERT INTO runs VALUES (?, ?, ?, ?, ?)", ["run", stateDir, 1, reportAHash, "2026-07-12T00:00:00.000Z"]);
|
||||
database.run("INSERT INTO artifact_locators VALUES (?, ?, ?, ?, ?, ?)", ["run", "fixture-pvc", stateDir, "analysis/report.json", reportAHash, reportA.byteLength]);
|
||||
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"));
|
||||
writeFileSync(reportPath, reportB);
|
||||
const tamperedUnsigned = { ...snapshot, artifactManifests: snapshot.artifactManifests.map((manifest) => ({ ...manifest, locators: manifest.locators.map((locator) => ({ ...locator, sha256: reportBHash, sizeBytes: reportB.byteLength })) })) };
|
||||
const tampered = { ...tamperedUnsigned, manifestContentHash: monitorSnapshotContentHash(tamperedUnsigned) };
|
||||
assert.equal(verifyMonitorSqliteSnapshot(tampered).ok, false);
|
||||
await assert.rejects(() => importMonitorSqliteSnapshot(tampered, createInMemoryMonitorCentralStore()), (error: unknown) => (error as { code?: string }).code === "monitor-migration-snapshot-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