diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 8d8929bf..54cec7ba 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -26,6 +26,7 @@ bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --con bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10 bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer +bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer --id ``` 节点级只读状态必须优先用 `cicd status --node `。它从 `config/platform-infra/pipelines-as-code.yaml` 找到该 node 的所有当前 PaC consumer,一次性汇总 PipelineRun、Argo/GitOps、runtime readiness 和诊断;不要再靠阅读源码或手动拼三条 consumer 命令来回答 “NC01 的 CI/CD 流水线情况”。`platform-infra pipelines-as-code status --target --consumer ` 只作为单 consumer drill-down。 @@ -54,6 +55,11 @@ bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --c - 仍要求 PipelineRun 成功、原 Argo `Synced/Healthy`、runtime ready、sha256 digest pin 与 health ready; - 证据缺失、冲突或 source commit 不匹配必须 fail-closed,不得等价为 delivery disabled,也不得伪造新 artifact、GitOps revision 或 runtime source commit。 - PaC `status|closeout|history` 的 `--json` 是有界 machine contract;`--full` 展开单条详情,`--raw` 才展开目标侧原始 payload。`history --id --full` 不得重复嵌套同一 rows/consumer payload或依赖 `/tmp/unidesk-cli-output` 才可读。 +- PaC `debug-step --id ` 必须读取目标侧真实 PipelineRun、TaskRun condition/result 与合同 Task 日志: + - 默认输出每个 terminal role 的 presence、status、source 与首个断点; + - 同时披露 artifact catalog、digest、GitOps commit 和 collector 读错; + - 该入口只读且 `mutation=false`,不能创建 PipelineRun 或写 GitOps/runtime; + - 未带 `--id` 时才只运行 evaluator fixture。 - GitHub/Git 相关 egress 必须走 YAML-first host proxy/sourceRef:branch-follower controller 读 `config/cicd-branch-followers.yaml#controller.source.githubSsh`,runtime legacy git-mirror 读 owning lane/control-plane YAML 的 host proxy 和 `githubTransport`,Gitea/PaC 迁移 lane 读 `config/platform-infra/gitea.yaml` 与 `config/platform-infra/pipelines-as-code.yaml`;禁止依赖未声明 host env、trans proxy、裸直连 GitHub 或 CLI 输出解析。 - Gitea/PaC lane 的 GitHub -> Gitea 自动同步必须用 `platform-infra gitea mirror webhook apply|status|test`。该路径是单向同步,只更新 Gitea branch 和 immutable snapshot ref;不得引入 Gitea -> GitHub 回写、轮询 fallback、Gitea Actions/act_runner fallback 或第二套状态存储。 - Gitea webhook/FRPC/Caddy 变更的 closeout 必须看受控 CLI 的 rollout/status/test 证据;Secret 或 ConfigMap apply 成功不等于 connector Pod 已加载新 proxy/path。 diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index 2c75db5c..430e35d1 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -23,12 +23,85 @@ function exactSkipMarker(records, event) { && item.reason === "no-build-no-rollout-plan") || null; } +const pacContractTasks = new Set(["plan-artifacts", "collect-artifacts", "gitops-promote"]); + +function taskTerminalRecord(value) { + const taskRun = record(value); + const metadata = record(taskRun.metadata); + const labels = record(metadata.labels); + const pipelineTask = stringOrNull(labels["tekton.dev/pipelineTask"]); + if (pipelineTask === null || !pacContractTasks.has(pipelineTask)) return null; + const status = record(taskRun.status); + const condition = Array.isArray(status.conditions) + ? status.conditions.map(record).find((item) => item.type === "Succeeded") || {} + : {}; + const conditionStatus = stringOrNull(condition.status); + const normalizedStatus = conditionStatus === "True" + ? "succeeded" + : conditionStatus === "False" + ? "failed" + : "running"; + return { + event: "pac-task-terminal", + pipelineTask, + taskRun: stringOrNull(metadata.name), + status: normalizedStatus, + reason: stringOrNull(condition.reason), + results: Array.isArray(status.results) + ? status.results.map(record).map((item) => stringOrNull(item.name)).filter(Boolean) + : [], + }; +} + +function parsePacLogRecords(value) { + const records = []; + const lines = String(value || "").split(/\r?\n/u); + let pending = ""; + for (const line of lines) { + const trimmed = line.trim(); + if (pending.length === 0) { + if (!trimmed.startsWith("{")) continue; + pending = trimmed; + } else { + pending += `\n${line}`; + } + try { + records.push(JSON.parse(pending)); + pending = ""; + } catch { + if (pending.length > 2 * 1024 * 1024) pending = ""; + } + } + return records; +} + +function exactTaskTerminal(records, pipelineTask) { + return [...records].reverse().find((item) => item.event === "pac-task-terminal" + && item.pipelineTask === pipelineTask) || null; +} + +function terminalSource(task, marker, markerStatus = "skipped", markerSource = "structured-log-marker") { + return { + present: task !== null || marker !== null, + status: task?.status || (marker === null ? "missing" : markerStatus), + source: task !== null ? "taskrun-condition" : marker === null ? null : markerSource, + taskRun: task?.taskRun || null, + reason: task?.reason || marker?.reason || null, + results: Array.isArray(task?.results) ? task.results : [], + markerPresent: marker !== null, + markerSource: marker === null ? null : markerSource, + }; +} + function extractPacSourceObservation(recordsInput) { const records = Array.isArray(recordsInput) ? recordsInput.map(record) : []; const plan = [...records].reverse().find((item) => item.event === "g14-ci-plan") || null; const collectMarker = exactSkipMarker(records, "collect-artifacts"); const promoteMarker = exactSkipMarker(records, "gitops-promote"); - if (plan === null && collectMarker === null && promoteMarker === null) return null; + const planTask = exactTaskTerminal(records, "plan-artifacts"); + const collectTask = exactTaskTerminal(records, "collect-artifacts"); + const promoteTask = exactTaskTerminal(records, "gitops-promote"); + if (plan === null && collectMarker === null && promoteMarker === null && planTask === null && collectTask === null && promoteTask === null) return null; const affected = stringArray(plan?.affectedServices); const rollout = stringArray(plan?.rolloutServices); @@ -44,6 +117,12 @@ function extractPacSourceObservation(recordsInput) { const noRuntimeChangeMarkers = collectMarker !== null && promoteMarker !== null; const skipMarkerSeen = collectMarker !== null || promoteMarker !== null; const skipMarkerConflict = skipMarkerSeen && !noRuntimeChangePlan; + const deliveryTerminalReady = planTask?.status === "succeeded" + && collectTask?.status === "succeeded" + && promoteTask?.status === "succeeded"; + const deliveryTerminalFailed = planTask?.status === "failed" + || collectTask?.status === "failed" + || promoteTask?.status === "failed"; let mode = "inconsistent"; let valid = false; @@ -52,7 +131,7 @@ function extractPacSourceObservation(recordsInput) { mode = "no-runtime-change"; valid = true; reason = "no-build-no-rollout-plan"; - } else if (planShapeValid && sourceCommitValid && !noRuntimeChangePlan && !skipMarkerConflict) { + } else if (planShapeValid && sourceCommitValid && !noRuntimeChangePlan && !skipMarkerConflict && deliveryTerminalReady) { mode = "delivery"; valid = true; reason = "artifact-or-runtime-delivery-required"; @@ -62,6 +141,10 @@ function extractPacSourceObservation(recordsInput) { reason = "delivery-plan-shape-invalid-or-missing"; } else if (skipMarkerConflict) { reason = "no-runtime-change-marker-conflicts-with-delivery-plan"; + } else if (deliveryTerminalFailed) { + reason = "delivery-terminal-failed"; + } else if (!noRuntimeChangePlan) { + reason = "delivery-terminal-evidence-incomplete"; } return { @@ -78,9 +161,134 @@ function extractPacSourceObservation(recordsInput) { reusedServiceCount: reused?.length ?? null, }, terminal: { - collectArtifacts: collectMarker === null ? "missing" : "no-build-no-rollout-plan", - gitopsPromote: promoteMarker === null ? "missing" : "no-build-no-rollout-plan", + planArtifacts: planTask?.status || (plan === null ? "missing" : "observed"), + collectArtifacts: collectMarker !== null ? "no-build-no-rollout-plan" : collectTask?.status || "missing", + gitopsPromote: promoteMarker !== null ? "no-build-no-rollout-plan" : promoteTask?.status || "missing", }, + terminalSources: { + planArtifacts: terminalSource(planTask, plan, "observed", "g14-ci-plan-structured-log"), + collectArtifacts: terminalSource(collectTask, collectMarker), + gitopsPromote: terminalSource(promoteTask, promoteMarker), + }, + valuesPrinted: false, + }; +} + +function digestOf(item) { + const digest = stringOrNull(item.digest); + if (digest !== null) return digest; + const digestRef = stringOrNull(item.digestRef); + return digestRef !== null && digestRef.includes("@") ? digestRef.split("@").slice(1).join("@") : null; +} + +function extractPacArtifactEvidence(recordsInput, logText) { + const records = Array.isArray(recordsInput) ? recordsInput.map(record) : []; + const sourceObservation = extractPacSourceObservation(records); + const catalog = [...records].reverse().find((item) => item.status === "published" + && Array.isArray(item.services) + && typeof item.registryVerified === "boolean") || null; + const publish = [...records].reverse().find((item) => item.phase === "gitops-publish" || stringOrNull(item.gitopsCommit) !== null) || null; + const image = publish || [...records].reverse().find((item) => stringOrNull(item.imageStatus) !== null + || item.status === "reused" + || item.status === "built") || null; + const logLines = String(logText || "").split(/\r?\n/u); + const gitopsCommit = stringOrNull(image?.gitopsCommit) + || [...logLines].reverse().map((line) => line.match(/^\[[^\]]+\s+([0-9a-f]{7,64})\]/u)?.[1] || null).find(Boolean) + || null; + const catalogDigests = catalog === null + ? [] + : [...new Set(catalog.services.map(record).map(digestOf).filter((value) => /^sha256:[0-9a-f]{64}$/iu.test(value || "")))]; + const loggedDigests = [...new Set(logLines.flatMap((line) => [...line.matchAll(/"(?:digest|environmentDigest)"\s*:\s*"(sha256:[0-9a-f]{64})"/gu)].map((match) => match[1])))]; + const digests = catalogDigests.length > 0 ? catalogDigests : loggedDigests; + const envHeaderIndex = logLines.findIndex((line) => /^ENV_REUSE\s+NODE_DEPS\s+/u.test(line.trim())); + let humanEnv = null; + if (envHeaderIndex >= 0) { + const row = (logLines.slice(envHeaderIndex + 1).find((line) => line.trim()) || "").trim().split(/\s+/u); + if (row.length >= 5) humanEnv = { envReuse: row[0], nodeDeps: row[1], buildPackage: row[2], buildNetwork: row[3], cache: row[4] }; + } + if (catalog !== null) { + const reusedCount = Number.isInteger(catalog.reusedCount) ? catalog.reusedCount : null; + const publishedCount = Number.isInteger(catalog.publishedCount) ? catalog.publishedCount : null; + return { + imageStatus: reusedCount !== null && reusedCount > 0 && publishedCount === 0 ? "reused" : "published", + envIdentity: null, + envReuse: reusedCount !== null && reusedCount > 0 ? "hit" : null, + nodeDepsReuse: null, + buildCache: null, + digest: digests.length === 1 ? digests[0] : null, + digests, + gitopsCommit, + sourceCommit: stringOrNull(catalog.sourceCommitId) || sourceObservation?.sourceCommit || null, + runtimeSourceCommit: null, + action: "artifact-catalog-published", + reason: reusedCount !== null && reusedCount > 0 ? "reused-env-catalog" : "artifact-catalog", + catalog: { + present: true, + status: stringOrNull(catalog.status), + sourceCommit: stringOrNull(catalog.sourceCommitId), + registryVerified: catalog.registryVerified === true, + publishedCount, + reusedCount, + requiredServiceCount: Number.isInteger(catalog.requiredServiceCount) ? catalog.requiredServiceCount : null, + digestCount: digests.length, + source: "collect-artifacts-structured-log", + valuesPrinted: false, + }, + sourceObservation, + valuesPrinted: false, + }; + } + if (image !== null) { + const env = record(image.envReuse); + return { + imageStatus: stringOrNull(image.imageStatus) || stringOrNull(image.status) || (stringOrNull(image.digestRef) !== null ? "built" : null), + envIdentity: stringOrNull(image.envIdentity), + envReuse: stringOrNull(env.dependencyReuse) || stringOrNull(env.mode) || stringOrNull(image.envReuseStatus), + nodeDepsReuse: null, + buildCache: null, + digest: digestOf(image) || (digests.length === 1 ? digests[0] : null), + digests, + gitopsCommit, + sourceCommit: stringOrNull(image.sourceCommit) || sourceObservation?.sourceCommit || null, + runtimeSourceCommit: stringOrNull(image.runtimeSourceCommit), + baselineSourceCommit: stringOrNull(image.baselineSourceCommit), + action: stringOrNull(image.action), + reason: stringOrNull(image.reason), + sourceObservation, + valuesPrinted: false, + }; + } + if (sourceObservation !== null) { + return { + imageStatus: sourceObservation.mode === "no-runtime-change" && sourceObservation.valid === true ? "retained" : null, + envIdentity: null, + envReuse: null, + nodeDepsReuse: null, + buildCache: null, + digest: null, + digests: [], + gitopsCommit, + sourceCommit: sourceObservation.sourceCommit || null, + runtimeSourceCommit: null, + action: sourceObservation.mode, + reason: sourceObservation.reason, + sourceObservation, + valuesPrinted: false, + }; + } + return { + imageStatus: humanEnv !== null ? "published" : null, + envIdentity: null, + envReuse: humanEnv?.envReuse || null, + nodeDepsReuse: humanEnv?.nodeDeps || null, + buildCache: humanEnv?.cache || null, + buildPackage: humanEnv?.buildPackage || null, + buildNetwork: humanEnv?.buildNetwork || null, + digest: digests.length === 1 ? digests[0] : null, + digests, + gitopsCommit, + sourceCommit: null, + sourceObservation: null, valuesPrinted: false, }; } @@ -117,7 +325,20 @@ function evaluatePacStatus(inputValue) { const deliveryDisabled = artifact.imageStatus === "disabled"; const deliverySkipped = artifact.imageStatus === "skipped"; const sourceObservationMode = stringOrNull(sourceObservation.mode); + const hwlabCatalogDelivery = sourceObservationMode === "delivery" + && sourceObservation.contract === "hwlab-g14-ci-plan"; const noRuntimeChange = sourceObservationMode === "no-runtime-change"; + const catalog = record(artifact.catalog); + const catalogPresent = catalog.present === true; + const catalogSourceCommit = stringOrNull(catalog.sourceCommit); + const catalogSourceMatches = catalogSourceCommit !== null && catalogSourceCommit === sourceCommit; + const catalogDeliveryEvidence = sourceObservationMode === "delivery" + && sourceObservation.valid === true + && catalogPresent + && catalog.status === "published" + && catalog.registryVerified === true + && catalogSourceMatches; + const registryEvidenceReady = input.registryPresent === true || catalogDeliveryEvidence; const sourceObservationInconsistent = sourceObservationMode === "inconsistent" || (sourceObservationMode !== null && sourceObservation.valid !== true); const observationSourceCommit = stringOrNull(sourceObservation.sourceCommit); @@ -146,12 +367,12 @@ function evaluatePacStatus(inputValue) { ok = false; code = "pac-source-observation-inconsistent"; phase = "source-observation-inconsistent"; - hint = "structured delivery plan and no-runtime-change terminal markers are incomplete or contradictory"; + hint = "structured delivery plan or terminal evidence is incomplete or contradictory"; } else if (sourceObservationMode !== null && !observationSourceMatches) { ok = false; code = "pac-source-observation-mismatch"; phase = "source-observation-mismatch"; - hint = "the structured no-runtime-change observation does not belong to the selected PipelineRun source commit"; + hint = "the structured source observation does not belong to the selected PipelineRun source commit"; } else if (noRuntimeChange && retainedOutputConflict) { ok = false; code = "pac-retained-output-conflict"; @@ -191,11 +412,31 @@ function evaluatePacStatus(inputValue) { code = "pac-ready-no-runtime-change"; phase = "ready-no-runtime-change"; hint = "the successful source observation requires no artifact, GitOps or runtime change; retained Argo and runtime provenance remain healthy"; - } else if (stringOrNull(input.imageRepository) !== null && input.registryPresent !== true) { + } else if (catalogPresent && !catalogSourceMatches) { + ok = false; + code = "pac-artifact-catalog-source-mismatch"; + phase = "artifact-catalog-source-mismatch"; + hint = "the artifact catalog source commit does not match the selected PipelineRun"; + } else if (hwlabCatalogDelivery && (!catalogPresent || catalog.status !== "published")) { + ok = false; + code = "pac-artifact-catalog-missing"; + phase = "artifact-catalog-missing"; + hint = "the HWLAB delivery plan completed but a published artifact catalog is not visible"; + } else if (hwlabCatalogDelivery && catalog.registryVerified !== true) { + ok = false; + code = "pac-artifact-catalog-unverified"; + phase = "artifact-catalog-unverified"; + hint = "the HWLAB artifact catalog did not prove registry verification"; + } else if (hwlabCatalogDelivery && selectedArtifactDigests.length === 0) { + ok = false; + code = "pac-artifact-catalog-digest-missing"; + phase = "artifact-catalog-digest-missing"; + hint = "the HWLAB artifact catalog did not expose any sha256 digest"; + } else if (stringOrNull(input.imageRepository) !== null && !registryEvidenceReady) { ok = false; code = "pac-registry-missing"; phase = "source-ready-registry-missing"; - hint = "PaC source commit is known but the configured registry tag is missing"; + hint = "PaC source commit is known but neither the configured registry tag nor a verified artifact catalog is visible"; } else if (!registryMatchesArtifact) { ok = false; code = "pac-artifact-registry-mismatch"; @@ -216,6 +457,11 @@ function evaluatePacStatus(inputValue) { code = `pac-argo-revision-${gitopsRelation}`; phase = `gitops-ready-argo-revision-${gitopsRelation}`; hint = `Argo revision relation is ${gitopsRelation}; only exact or commit-graph-proven descendant is deployable`; + } else if (catalogDeliveryEvidence && !selectedArtifactMatchesRuntime) { + ok = false; + code = "pac-catalog-artifact-runtime-mismatch"; + phase = "artifact-catalog-runtime-mismatch"; + hint = "the runtime digest is not present in the verified artifact catalog for this PipelineRun"; } else if (gitopsRelation === "descendant" && !selectedArtifactMatchesRuntime) { ok = false; code = "pac-descendant-artifact-runtime-mismatch"; @@ -261,7 +507,9 @@ function evaluatePacStatus(inputValue) { registryProbeBase: stringOrNull(input.registryProbeBase), imageTag: stringOrNull(input.imageTag), registry: { - present: input.registryPresent === true, + present: registryEvidenceReady, + probePresent: input.registryPresent === true, + catalogVerified: catalogDeliveryEvidence, digest: registryDigest, url: stringOrNull(input.registryUrl), }, @@ -350,14 +598,23 @@ function noRuntimeChangeObservation(sourceCommit = "c".repeat(40)) { } function deliveryObservation(sourceCommit = "c".repeat(40)) { - return extractPacSourceObservation([{ - event: "g14-ci-plan", - sourceCommitId: sourceCommit, - affectedServices: ["service"], - rolloutServices: ["service"], - buildServices: ["service"], - reusedServices: [], - }]); + return extractPacSourceObservation([ + { + event: "g14-ci-plan", + sourceCommitId: sourceCommit, + affectedServices: ["service"], + rolloutServices: ["service"], + buildServices: ["service"], + reusedServices: [], + }, + { event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "gitops-promote", taskRun: "promote", status: "succeeded", reason: "Succeeded", results: [] }, + ]); +} + +function publishedCatalog(sourceCommit = "c".repeat(40)) { + return { present: true, status: "published", sourceCommit, registryVerified: true }; } function runPacStatusFixtureChecks() { @@ -384,19 +641,55 @@ function runPacStatusFixtureChecks() { id: "delivery-ready-exact", expectedOk: true, expectedCode: "pac-ready-gitops-exact", - input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery }, argo: exactArgo, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }), + input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery, catalog: publishedCatalog() }, argo: exactArgo, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }), }, { id: "delivery-gitops-missing", expectedOk: false, expectedCode: "pac-gitops-missing", - input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, sourceObservation: delivery }, argo: { sync: "Synced", health: "Healthy", revision: null, revisionRelation: { relation: "unknown" } } }), + input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, digests: [digestA], sourceObservation: delivery, catalog: publishedCatalog() }, argo: { sync: "Synced", health: "Healthy", revision: null, revisionRelation: { relation: "unknown" } } }), }, { id: "delivery-runtime-mismatch", expectedOk: false, - expectedCode: "pac-runtime-not-aligned", - input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery }, argo: exactArgo, runtime: { image: `registry/service@${digestD}`, digest: digestD, replicas: 1, readyReplicas: 1 } }), + expectedCode: "pac-catalog-artifact-runtime-mismatch", + input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery, catalog: publishedCatalog() }, argo: exactArgo, runtime: { image: `registry/service@${digestD}`, digest: digestD, replicas: 1, readyReplicas: 1 } }), + }, + { + id: "catalog-delivery-ready", + expectedOk: true, + expectedCode: "pac-ready-gitops-exact", + input: fixtureInput({ imageRepository: "registry/service", artifact: { imageStatus: "reused", digests: [digestA], gitopsCommit: revision, sourceObservation: delivery, catalog: { present: true, status: "published", sourceCommit: "c".repeat(40), registryVerified: true } }, argo: exactArgo, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }), + }, + { + id: "catalog-delivery-missing", + expectedOk: false, + expectedCode: "pac-artifact-catalog-missing", + input: fixtureInput({ imageRepository: "registry/service", artifact: { imageStatus: "reused", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery }, registryPresent: true, argo: exactArgo, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }), + }, + { + id: "catalog-delivery-unverified", + expectedOk: false, + expectedCode: "pac-artifact-catalog-unverified", + input: fixtureInput({ imageRepository: "registry/service", artifact: { imageStatus: "reused", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery, catalog: { present: true, status: "published", sourceCommit: "c".repeat(40), registryVerified: false } }, registryPresent: true, argo: exactArgo, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }), + }, + { + id: "catalog-delivery-digest-missing", + expectedOk: false, + expectedCode: "pac-artifact-catalog-digest-missing", + input: fixtureInput({ imageRepository: "registry/service", artifact: { imageStatus: "reused", gitopsCommit: revision, sourceObservation: delivery, catalog: { present: true, status: "published", sourceCommit: "c".repeat(40), registryVerified: true } }, registryPresent: true, argo: exactArgo, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }), + }, + { + id: "catalog-delivery-runtime-mismatch", + expectedOk: false, + expectedCode: "pac-catalog-artifact-runtime-mismatch", + input: fixtureInput({ imageRepository: "registry/service", artifact: { imageStatus: "reused", digests: [digestA], gitopsCommit: revision, sourceObservation: delivery, catalog: { present: true, status: "published", sourceCommit: "c".repeat(40), registryVerified: true } }, argo: exactArgo, runtime: { image: `registry/service@${digestD}`, digest: digestD, replicas: 1, readyReplicas: 1 } }), + }, + { + id: "delivery-terminal-evidence-missing", + expectedOk: false, + expectedCode: "pac-source-observation-inconsistent", + input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([{ event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: ["service"], rolloutServices: ["service"], buildServices: [], reusedServices: ["service"] }]) } }), }, { id: "incomplete-no-runtime-change-markers", @@ -436,6 +729,9 @@ function runPacStatusFixtureChecks() { module.exports = { evaluatePacStatus, + extractPacArtifactEvidence, extractPacSourceObservation, + parsePacLogRecords, runPacStatusFixtureChecks, + taskTerminalRecord, }; diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 882bb314..adfa1527 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -344,7 +344,7 @@ history_rows() { node <<'NODE' const fs = require('node:fs'); const cp = require('node:child_process'); -const { extractPacSourceObservation } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); +const { extractPacArtifactEvidence, parsePacLogRecords, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); const limit = Math.max(1, Math.min(50, Number.parseInt(process.env.UNIDESK_PAC_HISTORY_LIMIT || '5', 10) || 5)); const detailId = process.env.UNIDESK_PAC_HISTORY_ID || ''; const consumers = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_HISTORY_CONSUMERS_B64 || 'W10=', 'base64').toString('utf8')); @@ -497,18 +497,41 @@ function recursiveEnvReuse(value, state) { for (const item of Object.values(value)) recursiveEnvReuse(item, state); } -function envReuseForPipelineRun(namespace, name) { - const state = { status: null, buildSkippedCount: null, serviceReusedCount: null, buildCache: null, source: 'logs', sourceObservation: null }; +function envReuseForPipelineRun(namespace, name, taskRuns) { + const state = { status: null, buildSkippedCount: null, serviceReusedCount: null, buildCache: null, source: 'pipeline-task-logs', sourceObservation: null, artifact: null, collector: null }; + const matching = (taskRuns.items || []).filter((item) => item.metadata?.labels?.['tekton.dev/pipelineRun'] === name); + const contractTaskRuns = matching.filter((item) => taskTerminalRecord(item) !== null); + const terminalRecords = contractTaskRuns.map(taskTerminalRecord).filter(Boolean); + const logErrors = []; let logs = ''; - try { - logs = cp.execFileSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${name}`, '--all-containers', '--tail=320'], { + for (const item of contractTaskRuns) { + const podName = item.status?.podName; + if (!podName) { + logErrors.push({ taskRun: item.metadata?.name || null, reason: 'pod-name-missing' }); + continue; + } + const result = cp.spawnSync('kubectl', ['-n', namespace, 'logs', podName, '--all-containers=true'], { encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], timeout: 12000, - maxBuffer: 1024 * 1024, + maxBuffer: 4 * 1024 * 1024, }); - } catch { - return { ...state, source: 'logs-unavailable' }; + if (result.error || result.status !== 0) { + logErrors.push({ taskRun: item.metadata?.name || null, reason: result.error ? String(result.error.message || result.error) : String(result.stderr || '').slice(0, 240) }); + continue; + } + logs += `${result.stdout || ''}\n`; + } + if (contractTaskRuns.length === 0) { + state.source = 'pipeline-run-log-fallback'; + const result = cp.spawnSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${name}`, '--all-containers=true', '--tail=320'], { + encoding: 'utf8', + timeout: 12000, + maxBuffer: 2 * 1024 * 1024, + }); + if (result.error || result.status !== 0) { + return { ...state, source: 'logs-unavailable', collector: { mode: state.source, contractTaskCount: 0, terminalRecordCount: 0, logErrorCount: 1, valuesPrinted: false } }; + } + logs = result.stdout || ''; } const lines = logs.split(/\r?\n/u); const headerIndex = lines.findIndex((line) => /^ENV_REUSE\s+NODE_DEPS\s+/u.test(line.trim())); @@ -519,17 +542,18 @@ function envReuseForPipelineRun(namespace, name) { state.buildCache = row[4]; } } - const records = []; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue; - try { - const parsed = JSON.parse(trimmed); - records.push(parsed); - recursiveEnvReuse(parsed, state); - } catch {} - } - state.sourceObservation = extractPacSourceObservation(records); + const records = [...parsePacLogRecords(logs), ...terminalRecords]; + for (const parsed of records) recursiveEnvReuse(parsed, state); + state.artifact = extractPacArtifactEvidence(records, logs); + state.sourceObservation = state.artifact.sourceObservation; + state.collector = { + mode: state.source, + contractTaskCount: contractTaskRuns.length, + terminalRecordCount: terminalRecords.length, + logErrorCount: logErrors.length, + logErrors, + valuesPrinted: false, + }; return state; } @@ -583,7 +607,7 @@ function rowFor(consumer, item, taskRuns) { const labels = item.metadata?.labels || {}; const annotations = item.metadata?.annotations || {}; const sourceCommit = extractCommit(item, params); - const evidence = envReuseForPipelineRun(consumer.namespace, item.metadata.name); + const evidence = envReuseForPipelineRun(consumer.namespace, item.metadata.name, taskRuns); const sourceObservation = evidence.sourceObservation === null ? null : { ...evidence.sourceObservation, sourceMatched: sourceCommit !== null && evidence.sourceObservation.sourceCommit === sourceCommit, @@ -618,6 +642,8 @@ function rowFor(consumer, item, taskRuns) { source: evidence.source, }, sourceObservation, + artifact: evidence.artifact, + collector: evidence.collector, taskRuns: tasks, source: 'gitea-pac-tekton-live', valuesPrinted: false, @@ -703,98 +729,61 @@ artifact_summary() { printf '{}' return fi - log_file=$(mktemp) - kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" logs -l "tekton.dev/pipelineRun=$UNIDESK_PAC_TARGET_PIPELINERUN" --all-containers --tail=240 >"$log_file" 2>/dev/null || true - node - "$log_file" <<'NODE' + task_file=$(mktemp) + kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get taskrun -l "tekton.dev/pipelineRun=$UNIDESK_PAC_TARGET_PIPELINERUN" -o json >"$task_file" 2>/dev/null || printf '{"items":[]}' >"$task_file" + node - "$task_file" <<'NODE' const fs = require('node:fs'); -const { extractPacSourceObservation } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); -const lines = fs.readFileSync(process.argv[2], 'utf8').split(/\r?\n/); -const records = []; -for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue; - try { records.push(JSON.parse(trimmed)); } catch {} -} -const sourceObservation = extractPacSourceObservation(records); -const publish = [...records].reverse().find((item) => item.phase === 'gitops-publish' || item.gitopsCommit); -const image = publish || [...records].reverse().find((item) => item.imageStatus || item.status === 'reused' || item.status === 'built'); -const gitopsCommit = image?.gitopsCommit - || [...lines].reverse().map((line) => line.match(/^\[[^\]]+\s+([0-9a-f]{7,64})\]/u)?.[1] || null).find(Boolean) - || null; -const loggedDigests = [...new Set(lines.flatMap((line) => [...line.matchAll(/"(?:digest|environmentDigest)"\s*:\s*"(sha256:[0-9a-f]{64})"/gu)].map((match) => match[1])))]; -const envHeaderIndex = lines.findIndex((line) => /^ENV_REUSE\s+NODE_DEPS\s+/u.test(line.trim())); -let humanEnv = null; -if (envHeaderIndex >= 0) { - const row = (lines.slice(envHeaderIndex + 1).find((line) => line.trim()) || '').trim().split(/\s+/u); - if (row.length >= 5) { - humanEnv = { envReuse: row[0], nodeDeps: row[1], buildPackage: row[2], buildNetwork: row[3], cache: row[4] }; +const cp = require('node:child_process'); +const { extractPacArtifactEvidence, parsePacLogRecords, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); +const input = JSON.parse(fs.readFileSync(process.argv[2], 'utf8') || '{"items":[]}'); +const namespace = process.env.UNIDESK_PAC_TARGET_NAMESPACE; +const pipelineRun = process.env.UNIDESK_PAC_TARGET_PIPELINERUN; +const taskRuns = (input.items || []).filter((item) => item.metadata?.labels?.['tekton.dev/pipelineRun'] === pipelineRun); +const contractTaskRuns = taskRuns.filter((item) => taskTerminalRecord(item) !== null); +const terminalRecords = contractTaskRuns.map(taskTerminalRecord).filter(Boolean); +const logErrors = []; +let logText = ''; +for (const item of contractTaskRuns) { + const podName = item.status?.podName; + if (!podName) { + logErrors.push({ taskRun: item.metadata?.name || null, reason: 'pod-name-missing' }); + continue; } + const result = cp.spawnSync('kubectl', ['-n', namespace, 'logs', podName, '--all-containers=true'], { + encoding: 'utf8', + timeout: 12000, + maxBuffer: 4 * 1024 * 1024, + }); + if (result.error || result.status !== 0) { + logErrors.push({ taskRun: item.metadata?.name || null, reason: result.error ? String(result.error.message || result.error) : String(result.stderr || '').slice(0, 240) }); + continue; + } + logText += `${result.stdout || ''}\n`; } -function digestOf(item) { - if (item.digest) return item.digest; - const ref = item.digestRef || ''; - const index = String(ref).indexOf('@'); - return index >= 0 ? String(ref).slice(index + 1) : null; -} -function envReuseOf(item) { - const env = item.envReuse || {}; - return env.dependencyReuse || env.mode || item.envReuseStatus || null; -} -let out = { valuesPrinted: false }; -if (image) { - out = { - imageStatus: image.imageStatus || image.status || (image.digestRef ? 'built' : null), - envIdentity: image.envIdentity || null, - envReuse: envReuseOf(image), - nodeDepsReuse: null, - buildCache: null, - digest: digestOf(image) || (loggedDigests.length === 1 ? loggedDigests[0] : null), - digests: loggedDigests, - gitopsCommit, - sourceCommit: image.sourceCommit || null, - runtimeSourceCommit: image.runtimeSourceCommit || null, - baselineSourceCommit: image.baselineSourceCommit || null, - action: image.action || null, - reason: image.reason || null, - sourceObservation, - valuesPrinted: false, - }; -} else if (sourceObservation) { - out = { - imageStatus: sourceObservation.mode === 'no-runtime-change' && sourceObservation.valid === true ? 'retained' : null, - envIdentity: null, - envReuse: null, - nodeDepsReuse: null, - buildCache: null, - digest: null, - digests: [], - gitopsCommit: null, - sourceCommit: sourceObservation.sourceCommit || null, - runtimeSourceCommit: null, - action: sourceObservation.mode, - reason: sourceObservation.reason, - sourceObservation, - valuesPrinted: false, - }; -} else if (humanEnv || gitopsCommit || loggedDigests.length > 0) { - out = { - imageStatus: humanEnv ? 'published' : null, - envIdentity: null, - envReuse: humanEnv?.envReuse || null, - nodeDepsReuse: humanEnv?.nodeDeps || null, - buildCache: humanEnv?.cache || null, - buildPackage: humanEnv?.buildPackage || null, - buildNetwork: humanEnv?.buildNetwork || null, - digest: loggedDigests.length === 1 ? loggedDigests[0] : null, - digests: loggedDigests, - gitopsCommit, - sourceCommit: null, - valuesPrinted: false, - }; +let mode = 'pipeline-task-logs'; +if (contractTaskRuns.length === 0) { + mode = 'pipeline-run-log-fallback'; + const fallback = cp.spawnSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${pipelineRun}`, '--all-containers=true', '--tail=320'], { + encoding: 'utf8', + timeout: 12000, + maxBuffer: 2 * 1024 * 1024, + }); + if (!fallback.error && fallback.status === 0) logText = fallback.stdout || ''; + else logErrors.push({ taskRun: null, reason: fallback.error ? String(fallback.error.message || fallback.error) : String(fallback.stderr || '').slice(0, 240) }); } +const records = [...parsePacLogRecords(logText), ...terminalRecords]; +const out = extractPacArtifactEvidence(records, logText); +out.collector = { + mode, + contractTaskCount: contractTaskRuns.length, + terminalRecordCount: terminalRecords.length, + logErrorCount: logErrors.length, + logErrors, + valuesPrinted: false, +}; process.stdout.write(JSON.stringify(out)); NODE - rm -f "$log_file" + rm -f "$task_file" } runtime_summary() { @@ -1206,11 +1195,124 @@ history_action() { } debug_step_action() { - node <<'NODE' + fixtures=$(node <<'NODE' const { runPacStatusFixtureChecks } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); const result = runPacStatusFixtureChecks(); -process.stdout.write(`${JSON.stringify(result)}\n`); -if (!result.ok) process.exitCode = 1; +process.stdout.write(JSON.stringify(result)); +NODE +) + if [ -z "$UNIDESK_PAC_HISTORY_ID" ]; then + printf '%s\n' "$fixtures" + FIXTURES_JSON="$fixtures" node -e 'const result=JSON.parse(process.env.FIXTURES_JSON||"{}"); if(!result.ok) process.exitCode=1' + return + fi + history=$(history_rows) + UNIDESK_PAC_DEBUG_FIXTURES_JSON="$fixtures" UNIDESK_PAC_DEBUG_HISTORY_JSON="$history" node <<'NODE' +function record(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} +function terminalRow(sourceObservation, key, step) { + const source = record(record(sourceObservation.terminalSources)[key]); + return { + step, + present: source.present === true, + status: source.status || 'missing', + source: source.source || null, + taskRun: source.taskRun || null, + reason: source.reason || null, + results: Array.isArray(source.results) ? source.results : [], + markerPresent: source.markerPresent === true, + markerSource: source.markerSource || null, + valuesPrinted: false, + }; +} +const fixtures = JSON.parse(process.env.UNIDESK_PAC_DEBUG_FIXTURES_JSON || '{}'); +const history = JSON.parse(process.env.UNIDESK_PAC_DEBUG_HISTORY_JSON || '{}'); +const rows = Array.isArray(history.rows) ? history.rows : []; +const historyErrors = Array.isArray(history.errors) ? history.errors : []; +const row = record(rows[0]); +const sourceObservation = record(row.sourceObservation); +const artifact = record(row.artifact); +const catalog = record(artifact.catalog); +const collector = record(row.collector); +const terminal = [ + terminalRow(sourceObservation, 'planArtifacts', 'plan-artifacts'), + terminalRow(sourceObservation, 'collectArtifacts', 'collect-artifacts'), + terminalRow(sourceObservation, 'gitopsPromote', 'gitops-promote'), +]; +let firstBreak = null; +function breakAt(code, stage, reason) { + if (firstBreak === null) firstBreak = { code, stage, reason, valuesPrinted: false }; +} +if (historyErrors.length > 0) breakAt('target-read-failed', 'target-read', historyErrors[0]?.error || historyErrors[0]?.stderr || 'target-side history read failed'); +if (Object.keys(row).length === 0) breakAt('pipeline-run-not-found', 'pipeline-run', `PipelineRun ${process.env.UNIDESK_PAC_HISTORY_ID || '-'} was not found for the selected consumer`); +if (Object.keys(row).length > 0 && row.status !== 'True') breakAt('pipeline-run-not-succeeded', 'pipeline-run', `PipelineRun terminal status is ${row.status || row.reason || 'unknown'}`); +if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'g14-ci-plan structured evidence is missing'); +if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'g14-ci-plan source commit does not match the selected PipelineRun'); +if (sourceObservation.valid !== true) breakAt('source-observation-invalid', 'plan-artifacts', sourceObservation.reason || 'delivery plan is incomplete or contradictory'); +for (const item of terminal) { + if (item.present !== true || item.status !== 'succeeded') breakAt('terminal-evidence-incomplete', item.step, `${item.step} TaskRun terminal status is ${item.status}`); +} +if (Number(collector.logErrorCount || 0) > 0) breakAt('contract-log-read-failed', 'collector', 'one or more contract TaskRun logs could not be read'); +if (sourceObservation.mode === 'delivery') { + if (catalog.present !== true || catalog.status !== 'published') breakAt('artifact-catalog-missing', 'collect-artifacts', 'published artifact catalog evidence is missing'); + if (catalog.registryVerified !== true) breakAt('artifact-catalog-unverified', 'collect-artifacts', 'artifact catalog registry verification is not true'); + if (!Array.isArray(artifact.digests) || artifact.digests.length === 0) breakAt('artifact-digest-missing', 'collect-artifacts', 'artifact catalog did not expose any sha256 digest'); + if (typeof artifact.gitopsCommit !== 'string' || artifact.gitopsCommit.length === 0) breakAt('gitops-commit-missing', 'gitops-promote', 'GitOps commit is not visible in the successful promotion TaskRun'); +} +const realRun = { + ok: firstBreak === null, + found: Object.keys(row).length > 0, + pipelineRun: Object.keys(row).length === 0 ? null : { + id: row.id || row.pipelineRun || null, + status: row.status || null, + reason: row.reason || null, + sourceCommit: row.commit || null, + sourceMatched: sourceObservation.sourceMatched === true, + valuesPrinted: false, + }, + mode: sourceObservation.mode || null, + valid: sourceObservation.valid === true, + terminal, + artifact: { + imageStatus: artifact.imageStatus || null, + envReuse: artifact.envReuse || null, + digests: Array.isArray(artifact.digests) ? artifact.digests : [], + gitopsCommit: artifact.gitopsCommit || null, + sourceCommit: artifact.sourceCommit || null, + catalog: { + present: catalog.present === true, + status: catalog.status || null, + sourceCommit: catalog.sourceCommit || null, + registryVerified: catalog.registryVerified === true, + publishedCount: catalog.publishedCount ?? null, + reusedCount: catalog.reusedCount ?? null, + requiredServiceCount: catalog.requiredServiceCount ?? null, + digestCount: catalog.digestCount ?? null, + source: catalog.source || null, + valuesPrinted: false, + }, + valuesPrinted: false, + }, + collector: { + mode: collector.mode || null, + contractTaskCount: collector.contractTaskCount ?? null, + terminalRecordCount: collector.terminalRecordCount ?? null, + logErrorCount: collector.logErrorCount ?? null, + logErrors: Array.isArray(collector.logErrors) ? collector.logErrors : [], + valuesPrinted: false, + }, + firstBreak, + valuesPrinted: false, +}; +const out = { + ok: fixtures.ok === true && realRun.ok, + checks: Array.isArray(fixtures.checks) ? fixtures.checks : [], + realRun, + valuesPrinted: false, +}; +process.stdout.write(`${JSON.stringify(out)}\n`); +if (!out.ok) process.exitCode = 1; NODE } diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index e80193c2..841363c1 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -204,10 +204,10 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf if (action === "history" || action === "runs") { const options = parseHistoryOptions(args.slice(1)); const result = await history(config, options); - return options.full || options.raw ? result : options.json ? compactHistoryJson(result) : renderHistory(result); + return options.raw ? result : options.full ? compactHistoryJson(result, true) : options.json ? compactHistoryJson(result) : renderHistory(result); } if (action === "debug-step") { - const options = parseCommonOptions(args.slice(1)); + const options = parseHistoryOptions(args.slice(1)); const result = await debugStep(config, options); return options.full || options.raw ? result : options.json ? result : renderDebugStep(result); } @@ -231,7 +231,7 @@ function help(): Record { "bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer --source-commit --wait", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id ", - "bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 [--json]", + "bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 [--consumer ] [--id ] [--json]", ], diagnostics: "webhook-test exists only for bounded connectivity diagnosis and must not be used as delivery evidence.", boundary: "Sole CI trigger path for GH-1552/GH-1607: GitHub PR merge -> GitHub webhook bridge -> Gitea mirror/snapshot -> Pipelines-as-Code -> Tekton -> GitOps/Argo/k8s runtime.", @@ -722,7 +722,7 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise< }; } -async function debugStep(config: UniDeskConfig, options: CommonOptions): Promise> { +async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); const consumer = resolveConsumer(pac, options.consumerId); @@ -735,8 +735,10 @@ async function debugStep(config: UniDeskConfig, options: CommonOptions): Promise mutation: false, target: targetSummary(target), consumer: consumer.id, + detailId: options.detailId, evaluator: "scripts/native/cicd/pac-status-evaluator.cjs", checks: parsed === null ? [] : arrayRecords(parsed.checks), + realRun: parsed === null ? null : parsed.realRun ?? null, remote: parsed === null || options.raw ? parsed ?? compactCapture(result, { full: true }) : undefined, next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), valuesPrinted: false, @@ -1340,7 +1342,8 @@ function compactCloseoutJson(result: Record): Record): Record { +function compactHistoryJson(result: Record, includeTaskDetails = false): Record { + const detailRequested = stringValue(result.detailId) !== "-"; return { ok: result.ok === true, action: result.action, @@ -1351,6 +1354,9 @@ function compactHistoryJson(result: Record): Record { const taskRuns = record(row.taskRuns); + const artifact = record(row.artifact); + const catalog = record(artifact.catalog); + const collector = record(row.collector); return { id: row.id ?? row.pipelineRun, consumer: row.consumer, @@ -1368,10 +1374,42 @@ function compactHistoryJson(result: Record): Record): RenderedCliResult { function renderDebugStep(result: Record): RenderedCliResult { const checks = arrayRecords(result.checks); + const realRun = record(result.realRun); + const pipelineRun = record(realRun.pipelineRun); + const artifact = record(realRun.artifact); + const catalog = record(artifact.catalog); + const terminal = arrayRecords(realRun.terminal); + const firstBreak = record(realRun.firstBreak); + const fixturePassed = checks.filter((item) => item.ok === true).length; const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE DEBUG STEP", - ...table(["TARGET", "CONSUMER", "OK", "CHECKS", "MUTATION"], [[ + ...table(["TARGET", "CONSUMER", "PIPELINERUN", "OK", "FIXTURES", "MUTATION"], [[ stringValue(record(result.target).id), stringValue(result.consumer), + stringValue(result.detailId), boolText(result.ok), - stringValue(checks.length), + `${fixturePassed}/${checks.length}`, boolText(result.mutation), ]]), + ...(Object.keys(realRun).length === 0 ? [] : [ + "", + "REAL PIPELINERUN EVIDENCE", + ...table(["FOUND", "STATUS", "SOURCE", "MODE", "VALID", "FIRST_BREAK"], [[ + boolText(realRun.found), + stringValue(pipelineRun.status ?? pipelineRun.reason), + short(stringValue(pipelineRun.sourceCommit), 16), + stringValue(realRun.mode), + boolText(realRun.valid), + stringValue(firstBreak.code), + ]]), + "", + "TERMINAL EVIDENCE", + ...(terminal.length === 0 ? ["-"] : table(["STEP", "PRESENT", "STATUS", "SOURCE", "MARKER", "TASKRUN", "RESULTS"], terminal.map((item) => [ + stringValue(item.step), + boolText(item.present), + stringValue(item.status), + stringValue(item.source), + item.markerPresent === true ? stringValue(item.markerSource) : "-", + short(stringValue(item.taskRun), 52), + compactResultNames(item.results), + ]))), + "", + "ARTIFACT / GITOPS", + ...table(["CATALOG", "VERIFIED", "REUSED", "DIGESTS", "GITOPS"], [[ + stringValue(catalog.status), + boolText(catalog.registryVerified), + stringValue(catalog.reusedCount), + stringValue(catalog.digestCount), + short(stringValue(artifact.gitopsCommit), 16), + ]]), + ]), "", "STATUS EVALUATOR FIXTURES", ...(checks.length === 0 ? ["-"] : table(["CASE", "OK", "EXPECTED", "ACTUAL"], checks.map((item) => [ @@ -1819,6 +1897,20 @@ function closeoutCiReady(value: Record, sourceCommit: string | if (pipelineGate.ok !== true) return false; const observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics); if (sourceCommit !== null && observedSourceCommit !== sourceCommit) return false; + const sourceObservation = record(summary.sourceObservation); + if (Object.keys(sourceObservation).length > 0) { + if (sourceObservation.valid !== true || stringValue(sourceObservation.sourceCommit) !== observedSourceCommit) return false; + if (stringValue(sourceObservation.contract) === "hwlab-g14-ci-plan" && stringValue(sourceObservation.mode) === "delivery") { + const deliveryArtifact = record(summary.artifact); + const catalog = record(deliveryArtifact.catalog); + const digests = Array.isArray(deliveryArtifact.digests) ? deliveryArtifact.digests : []; + if (catalog.present !== true + || stringValue(catalog.status) !== "published" + || catalog.registryVerified !== true + || stringValue(catalog.sourceCommit) !== observedSourceCommit + || digests.length === 0) return false; + } + } if (stringValue(diagnostics.code) === "pac-ready-no-runtime-change") return true; const artifact = record(summary.artifact); if (stringValue(artifact.imageStatus) === "disabled") return true; @@ -2176,6 +2268,10 @@ function renderHistoryDetail(row: Record): string[] { const envReuse = record(row.envReuse); const sourceObservation = record(row.sourceObservation); const deliveryPlan = record(sourceObservation.plan); + const terminalSources = record(sourceObservation.terminalSources); + const artifact = record(row.artifact); + const catalog = record(artifact.catalog); + const collector = record(row.collector); const taskRuns = record(row.taskRuns); const longest = record(taskRuns.longest); const failed = record(taskRuns.failed); @@ -2209,6 +2305,25 @@ function renderHistoryDetail(row: Record): string[] { ["reusedServices", stringValue(deliveryPlan.reusedServiceCount)], ]), "", + "TERMINAL EVIDENCE", + ...table(["STEP", "PRESENT", "STATUS", "SOURCE", "MARKER", "TASKRUN"], [ + ["plan-artifacts", boolText(record(terminalSources.planArtifacts).present), stringValue(record(terminalSources.planArtifacts).status), stringValue(record(terminalSources.planArtifacts).source), record(terminalSources.planArtifacts).markerPresent === true ? stringValue(record(terminalSources.planArtifacts).markerSource) : "-", short(stringValue(record(terminalSources.planArtifacts).taskRun), 52)], + ["collect-artifacts", boolText(record(terminalSources.collectArtifacts).present), stringValue(record(terminalSources.collectArtifacts).status), stringValue(record(terminalSources.collectArtifacts).source), record(terminalSources.collectArtifacts).markerPresent === true ? stringValue(record(terminalSources.collectArtifacts).markerSource) : "-", short(stringValue(record(terminalSources.collectArtifacts).taskRun), 52)], + ["gitops-promote", boolText(record(terminalSources.gitopsPromote).present), stringValue(record(terminalSources.gitopsPromote).status), stringValue(record(terminalSources.gitopsPromote).source), record(terminalSources.gitopsPromote).markerPresent === true ? stringValue(record(terminalSources.gitopsPromote).markerSource) : "-", short(stringValue(record(terminalSources.gitopsPromote).taskRun), 52)], + ]), + "", + "ARTIFACT / COLLECTOR", + ...table(["CATALOG", "VERIFIED", "REUSED", "DIGESTS", "GITOPS", "TASKS", "TERMINALS", "LOG_ERRORS"], [[ + stringValue(catalog.status), + boolText(catalog.registryVerified), + stringValue(catalog.reusedCount), + stringValue(catalog.digestCount), + short(stringValue(artifact.gitopsCommit), 16), + stringValue(collector.contractTaskCount), + stringValue(collector.terminalRecordCount), + stringValue(collector.logErrorCount), + ]]), + "", "LOGS", ` ${stringValue(taskRuns.logsCommand)}`, ]; @@ -2219,6 +2334,13 @@ function compactLine(value: string): string { return trimmed.length > 0 ? trimmed.slice(0, 120) : "-"; } +function compactResultNames(value: unknown): string { + if (!Array.isArray(value) || value.length === 0) return "-"; + const names = value.filter((item): item is string => typeof item === "string"); + if (names.length <= 2) return names.join(","); + return `${names.length}:${names.slice(0, 2).join(",")},...`; +} + function table(headers: string[], rows: string[][]): string[] { const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length))); const format = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index])).join(" "); diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index abd2680b..e8a2151a 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -471,7 +471,7 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer [--json|--full|--raw]", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer --limit 10 [--json]", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id [--json|--full]", - "bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer [--json]", + "bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer [--id ] [--json]", ], description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the target-scoped Kafka event bus, internal Gitea source authority, and Gitea + Pipelines-as-Code closeout for JD01 migrated consumers. Public services use PK01 Caddy/FRP or YAML-declared PK01 Caddy upstreams rather than Kubernetes Ingress, NodePort, or LoadBalancer.", target,