From 8d091e3de89cb0ecff4e98ad5856539832858bc6 Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Mon, 13 Jul 2026 02:46:45 +0000 Subject: [PATCH] fix: parse migration job envelopes --- scripts/src/cicd-delivery-authority.test.ts | 18 +++++++ .../src/hwlab-node-web-sentinel-cicd-jobs.ts | 49 ++++++++++++++----- scripts/src/hwlab-node-web-sentinel-cicd.ts | 17 ++++++- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/scripts/src/cicd-delivery-authority.test.ts b/scripts/src/cicd-delivery-authority.test.ts index a0b2debf..82acd31a 100644 --- a/scripts/src/cicd-delivery-authority.test.ts +++ b/scripts/src/cicd-delivery-authority.test.ts @@ -24,6 +24,7 @@ import { guardedSentinelDeliveryAction, migrationJobFailed, migrationJobFailureSummary, + migrationJobPayloadFromProbe, runWebProbeSentinelCommand, sameMigrationSource, webProbeSentinelDeliveryAuthorityFromCatalog, @@ -321,6 +322,23 @@ describe("migrated CLI 执行 guard 与提示清理", () => { expect(JSON.stringify(failure.projection)).not.toMatch(/PW123|stack|runs=-|findings=-/iu); }); + test("migration Job 只从目标容器读取多行 CLI envelope,忽略合并日志尾随结果", () => { + const verifyFailure = JSON.stringify({ ok: false, data: { ok: false, command: "web-probe sentinel migration verify", 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", 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 }; + 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 业务失败、创建失败和超时均只返回安全 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 }; const createFailed = migrationJobFailed("migration", { jobName: "job" }, "create-failed", {}, { podPhase: "CreateFailed", logsTail: "DATABASE_URL=https://user:PW123@host/db" }) as { projection?: Record }; diff --git a/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts b/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts index d6280444..a19484ad 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts @@ -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 { - 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 | null { + let last: Record | 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 | 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 { diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index e4bc8a4a..519df4ca 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -248,7 +248,7 @@ function runSentinelMigrationJob(spec: HwlabRuntimeLaneSpec, options: Extract): Record { + 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 && 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 migrationFailedContainerName(probe: Record): 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, phase: string, payload: Record, probe: Record, next?: Record): RenderedCliResult { return rendered(false, command, "ok=false", { ...plan, ok: false, mutation: true, phase, failure: migrationJobFailureSummary(probe, payload), next, valuesRedacted: true }); }