Merge pull request #1886 from pikasTech/fix/web-sentinel-migration-job-json
修复迁移 Job 失败原因可见性
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
guardedSentinelDeliveryAction,
|
||||
migrationJobFailed,
|
||||
migrationJobFailureSummary,
|
||||
migrationJobPayloadFromProbe,
|
||||
runWebProbeSentinelCommand,
|
||||
sameMigrationSource,
|
||||
webProbeSentinelDeliveryAuthorityFromCatalog,
|
||||
@@ -313,6 +314,38 @@ describe("migrated CLI 执行 guard 与提示清理", () => {
|
||||
expect(JSON.stringify(failure)).not.toContain(credential);
|
||||
});
|
||||
|
||||
test("migration Job verify 的结构化失败直接投影语义 code 和 message", () => {
|
||||
const failure = migrationJobFailed("web-probe sentinel migration job-run", { jobName: "migration-job" }, "failed", { ok: false, command: "web-probe sentinel migration verify", error: { code: "monitor-migration-pg-verify-mismatch", message: "timeline count does not match snapshot", stack: "DATABASE_URL=postgres://user:PW123@host/db" } }, { podPhase: "Failed", containers: [{ name: "verify", phase: "terminated", exitCode: 1, reason: "Error", message: "-" }], logsTail: '{"ok":false,"command":"web-probe sentinel migration verify","error":{"code":"monitor-migration-pg-verify-mismatch","message":"timeline count does not match snapshot","stack":"secret stack"}}' }) as { projection?: Record<string, unknown> };
|
||||
expect(failure.projection?.phase).toBe("failed");
|
||||
expect(failure.projection?.result).toBeUndefined();
|
||||
expect(failure.projection?.failure).toMatchObject({ phase: "Failed", container: "verify", exitCode: 1, code: "monitor-migration-pg-verify-mismatch", message: "timeline count does not match snapshot", valuesRedacted: true });
|
||||
expect(JSON.stringify(failure.projection)).not.toMatch(/PW123|stack|runs=-|findings=-/iu);
|
||||
});
|
||||
|
||||
test("migration Job 只从目标容器读取多行 CLI envelope,忽略合并日志尾随结果", () => {
|
||||
const verifyFailure = JSON.stringify({ ok: false, command: "web-probe sentinel migration verify --node NC01 --lane v03 --json", error: { code: "monitor-migration-pg-verify-mismatch", message: "verify saw {braces} and an escaped \"quote\"" } }, null, 2);
|
||||
const importSuccess = JSON.stringify({ ok: true, data: { ok: true, command: "web-probe sentinel migration import" } }, null, 2);
|
||||
const probe = { failed: true, podPhase: "Failed", containers: [{ name: "verify", phase: "terminated", exitCode: 1, reason: "Error", message: "-" }], containerLogs: { verify: `verify prefix\n${verifyFailure}`, import: importSuccess }, logsTail: `${verifyFailure}\n${importSuccess}` };
|
||||
const payload = migrationJobPayloadFromProbe(probe);
|
||||
expect(payload).toMatchObject({ ok: false, command: "web-probe sentinel migration verify --node NC01 --lane v03 --json", error: { code: "monitor-migration-pg-verify-mismatch", message: "verify saw {braces} and an escaped \"quote\"" } });
|
||||
const failure = migrationJobFailed("migration", { jobName: "job" }, "failed", payload, probe) as { projection?: Record<string, unknown> };
|
||||
expect(failure.projection?.failure).toMatchObject({ container: "verify", code: "monitor-migration-pg-verify-mismatch", message: "verify saw {braces} and an escaped \"quote\"" });
|
||||
expect(failure.projection?.result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("migration Job 成功固定读取 verify,缺结构化 verify 结果显式失败", () => {
|
||||
const importSuccess = JSON.stringify({ ok: true, data: { ok: true, command: "web-probe sentinel migration import" } }, null, 2);
|
||||
expect(migrationJobPayloadFromProbe({ succeeded: true, containers: [{ name: "verify", phase: "terminated", exitCode: 0 }], containerLogs: { import: importSuccess }, logsTail: importSuccess })).toMatchObject({ ok: false, error: { code: "monitor-migration-job-result-missing" } });
|
||||
expect(migrationJobPayloadFromProbe({ succeeded: true, containers: [{ name: "verify", phase: "terminated", exitCode: 0 }], containerLogs: { verify: JSON.stringify({ ok: true, data: { ok: true, command: "web-probe sentinel migration verify" } }), import: importSuccess } })).toMatchObject({ ok: true, command: "web-probe sentinel migration verify" });
|
||||
});
|
||||
|
||||
test("migration Job 接受带参数的 direct export 包络并拒绝相似 command 前缀", () => {
|
||||
const direct = (command: string) => JSON.stringify({ ok: false, command, error: { code: "monitor-migration-artifact-path-invalid", message: "export artifact is unavailable" } }, null, 2);
|
||||
const failedExport = (command: string) => migrationJobPayloadFromProbe({ failed: true, containers: [{ name: "export", phase: "terminated", exitCode: 1 }], containerLogs: { export: direct(command) } });
|
||||
expect(failedExport("web-probe sentinel migration export --node NC01 --lane v03 --json")).toMatchObject({ ok: false, command: "web-probe sentinel migration export --node NC01 --lane v03 --json", error: { code: "monitor-migration-artifact-path-invalid" } });
|
||||
for (const command of ["web-probe sentinel migration export-other --json", "web-probe sentinel migration exportfoo --json", "web-probe sentinel migration import --json"]) expect(failedExport(command)).toMatchObject({ ok: false, error: { code: "monitor-migration-job-result-missing" } });
|
||||
});
|
||||
|
||||
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> };
|
||||
|
||||
@@ -31,9 +31,12 @@ test("explicit migration helper renders ordered read-only export import verify J
|
||||
expect(job.spec.template.metadata.labels["app.kubernetes.io/component"]).toBe("migration");
|
||||
expect(job.spec.template.spec.initContainers.map((container: any) => container.name)).toEqual(["export", "import"]);
|
||||
expect(job.spec.template.spec.initContainers[0].args).toEqual(expect.arrayContaining(["export", "--snapshot", "/migration/snapshot.json"]));
|
||||
expect(job.spec.template.spec.initContainers[0].args).toContain("--json");
|
||||
expect(job.spec.template.spec.initContainers[0].args).not.toContain("--confirm");
|
||||
expect(job.spec.template.spec.initContainers[1].args).toEqual(expect.arrayContaining(["scripts/cli.ts", "import", "--snapshot", "/migration/snapshot.json", "--confirm"]));
|
||||
expect(job.spec.template.spec.initContainers[1].args).toContain("--json");
|
||||
expect(job.spec.template.spec.containers[0].args).toEqual(expect.arrayContaining(["scripts/cli.ts", "verify", "--snapshot", "/migration/snapshot.json"]));
|
||||
expect(job.spec.template.spec.containers[0].args).toContain("--json");
|
||||
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" })]));
|
||||
}
|
||||
|
||||
@@ -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, 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: {} }] } } } };
|
||||
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, "--json"], volumeMounts: mounts }, { name: "import", image: imageRef, command: ["bun"], args: [...cli, "import", ...sourceArgs, "--confirm", "--json"], env, volumeMounts: mounts }], containers: [{ name: "verify", image: imageRef, command: ["bun"], args: [...cli, "verify", ...sourceArgs, "--json"], 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 } {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
arrayAt,
|
||||
compactCommand,
|
||||
finiteNumberOrNull,
|
||||
isRecord,
|
||||
monitorWebBuildkitStatePlan,
|
||||
nonEmptyString,
|
||||
numberAt,
|
||||
@@ -941,11 +942,13 @@ export function probeK8sJobScript(namespace: string, jobName: string): string {
|
||||
"containers_b64=''",
|
||||
"if [ -n \"$pod\" ]; then pod_phase=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.status.phase}' 2>/dev/null); reason=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.status.reason}' 2>/dev/null); containers_b64=$(kubectl -n \"$namespace\" get pod \"$pod\" -o json 2>/dev/null | node -e 'let body=\"\";process.stdin.on(\"data\",c=>body+=c).on(\"end\",()=>{try{const pod=JSON.parse(body);const all=[...(pod.status?.initContainerStatuses||[]),...(pod.status?.containerStatuses||[])];console.log(Buffer.from(JSON.stringify(all.map(item=>({name:item.name,phase:item.state?.terminated?\"terminated\":item.state?.waiting?\"waiting\":item.state?.running?\"running\":\"unknown\",exitCode:item.state?.terminated?.exitCode??null,reason:item.state?.terminated?.reason??item.state?.waiting?.reason??null,message:item.state?.terminated?.message??item.state?.waiting?.message??null})))).toString(\"base64\"))}catch{console.log(\"\")}})' | tr -d '\\n'); fi",
|
||||
"logs_tail=''",
|
||||
"if [ -n \"$pod\" ]; then logs_tail=$({ kubectl -n \"$namespace\" logs \"$pod\" --all-containers=true --tail=80 2>/dev/null || true; for container in $(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.spec.initContainers[*].name}' 2>/dev/null); do kubectl -n \"$namespace\" logs \"$pod\" -c \"$container\" --tail=60 2>/dev/null || true; done; } | tail -c 6000 | base64 | tr -d '\\n'); fi",
|
||||
"node - \"$succeeded\" \"$failed\" \"$active\" \"$pod\" \"$pod_phase\" \"$reason\" \"$containers_b64\" \"$logs_tail\" <<'NODE'",
|
||||
"const [succeeded, failed, active, pod, podPhase, reason, containersB64, logsB64] = process.argv.slice(2);",
|
||||
"container_logs_b64=''",
|
||||
"if [ -n \"$pod\" ]; then logs_tail=$(kubectl -n \"$namespace\" logs \"$pod\" --all-containers=true --tail=80 2>/dev/null | tail -c 6000 | base64 | tr -d '\\n'); container_logs_b64=$(for container in $(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.spec.initContainers[*].name} {.spec.containers[*].name}' 2>/dev/null); do printf '%s\\t%s\\n' \"$container\" \"$(kubectl -n \"$namespace\" logs \"$pod\" -c \"$container\" --tail=80 2>/dev/null | tail -c 6000 | base64 | tr -d '\\n')\"; done | base64 | tr -d '\\n'); fi",
|
||||
"node - \"$succeeded\" \"$failed\" \"$active\" \"$pod\" \"$pod_phase\" \"$reason\" \"$containers_b64\" \"$logs_tail\" \"$container_logs_b64\" <<'NODE'",
|
||||
"const [succeeded, failed, active, pod, podPhase, reason, containersB64, logsB64, containerLogsB64] = process.argv.slice(2);",
|
||||
"let containers=[];try{containers=JSON.parse(Buffer.from(containersB64 || '', 'base64').toString('utf8'))}catch{}",
|
||||
"console.log(JSON.stringify({ succeeded: Number(succeeded || 0) > 0, failed: Number(failed || 0) > 0, active: Number(active || 0) > 0, pod: pod || null, podPhase: podPhase || null, reason: reason || null, containers, logsTail: Buffer.from(logsB64 || '', 'base64').toString('utf8'), valuesRedacted: true }));",
|
||||
"const containerLogs={};for(const line of Buffer.from(containerLogsB64 || '', 'base64').toString('utf8').split(/\\r?\\n/)){const tab=line.indexOf('\\t');if(tab>0)containerLogs[line.slice(0,tab)]=Buffer.from(line.slice(tab+1),'base64').toString('utf8')}",
|
||||
"console.log(JSON.stringify({ succeeded: Number(succeeded || 0) > 0, failed: Number(failed || 0) > 0, active: Number(active || 0) > 0, pod: pod || null, podPhase: podPhase || null, reason: reason || null, containers, logsTail: Buffer.from(logsB64 || '', 'base64').toString('utf8'), containerLogs, valuesRedacted: true }));",
|
||||
"NODE",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -977,14 +980,38 @@ function probeTektonPipelineRunScript(namespace: string, pipelineRunName: string
|
||||
}
|
||||
|
||||
export function sentinelPayloadFromLogs(logsTail: string): Record<string, unknown> {
|
||||
const lines = logsTail.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index];
|
||||
if (!line.startsWith("{") || !line.endsWith("}")) continue;
|
||||
const parsed = parseJsonObject(line);
|
||||
if (parsed !== null && (parsed.ok === true || parsed.ok === false)) return parsed;
|
||||
const parsed = lastJsonObjectFromText(logsTail);
|
||||
if (parsed === null) return {};
|
||||
const data = isRecord(parsed.data) ? parsed.data : null;
|
||||
return data !== null && (data.ok === true || data.ok === false) ? data : parsed.ok === true || parsed.ok === false ? parsed : {};
|
||||
}
|
||||
|
||||
function lastJsonObjectFromText(text: string): Record<string, unknown> | null {
|
||||
let last: Record<string, unknown> | null = null;
|
||||
for (let start = text.indexOf("{"); start >= 0; start = text.indexOf("{", start + 1)) {
|
||||
const candidate = jsonObjectAt(text, start);
|
||||
if (candidate !== null && (candidate.ok === true || candidate.ok === false)) last = candidate;
|
||||
}
|
||||
return {};
|
||||
return last;
|
||||
}
|
||||
|
||||
function jsonObjectAt(text: string, start: number): Record<string, unknown> | null {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let index = start; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === "\"") inString = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\"") { inString = true; continue; }
|
||||
if (char === "{") { depth += 1; continue; }
|
||||
if (char === "}" && --depth === 0) return parseJsonObject(text.slice(start, index + 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function withSentinelRemoteJobDiagnostics(state: SentinelCicdState, result: SentinelRemoteJobResult, domain: "source-mirror" | "publish"): SentinelRemoteJobResult {
|
||||
|
||||
@@ -248,7 +248,7 @@ function runSentinelMigrationJob(spec: HwlabRuntimeLaneSpec, options: Extract<We
|
||||
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 payload = migrationJobPayloadFromProbe(probe);
|
||||
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));
|
||||
}
|
||||
@@ -257,6 +257,25 @@ function runSentinelMigrationJob(spec: HwlabRuntimeLaneSpec, options: Extract<We
|
||||
return migrationJobFailed(command, plan, "timeout", {}, { podPhase: "Timeout", logsTail: "migration Job did not reach a successful result before timeout" }, migrationJobFailureNext(state, namespace, jobName));
|
||||
}
|
||||
|
||||
export function migrationJobPayloadFromProbe(probe: Record<string, unknown>): Record<string, unknown> {
|
||||
const failedContainer = migrationFailedContainerName(probe);
|
||||
const container = probe.succeeded === true ? "verify" : failedContainer;
|
||||
const expectedCommand = container === "export" ? "web-probe sentinel migration export" : container === "import" ? "web-probe sentinel migration import" : container === "verify" ? "web-probe sentinel migration verify" : null;
|
||||
const containerLogs = isRecord(probe.containerLogs) ? probe.containerLogs : {};
|
||||
const payload = container === null ? {} : sentinelPayloadFromLogs(String(containerLogs[container] ?? ""));
|
||||
if (expectedCommand !== null && migrationPayloadCommandMatches(payload.command, expectedCommand) && (payload.ok === true || payload.ok === false)) return payload;
|
||||
return { ok: false, error: { code: "monitor-migration-job-result-missing", message: expectedCommand === null ? "migration Job failed without an identifiable terminated container result" : `migration Job ${container} container did not emit the expected structured result` }, valuesRedacted: true };
|
||||
}
|
||||
|
||||
function migrationPayloadCommandMatches(value: unknown, expected: string): boolean {
|
||||
return typeof value === "string" && (value === expected || value.startsWith(`${expected} `));
|
||||
}
|
||||
|
||||
function migrationFailedContainerName(probe: Record<string, unknown>): string | null {
|
||||
const containers = Array.isArray(probe.containers) ? probe.containers.filter(isRecord) : [];
|
||||
return stringAtNullable(containers.find((container) => finiteNumberOrNull(container.exitCode) !== null && finiteNumberOrNull(container.exitCode) !== 0), "name");
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -266,7 +285,7 @@ export function migrationJobFailureSummary(probe: Record<string, unknown>, paylo
|
||||
const failed = containers.find((item) => item.exitCode !== null && item.exitCode !== 0) ?? containers[0] ?? null;
|
||||
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: redactedMigrationJobText(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, code: stringAtNullable(nestedError, "code") ?? 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 {
|
||||
|
||||
Reference in New Issue
Block a user