diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 0f6baf49..8d8929bf 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -25,6 +25,7 @@ bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consu 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 --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 ``` 节点级只读状态必须优先用 `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。 @@ -46,6 +47,13 @@ bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id < - CI/CD source authority 只能来自 YAML 声明的 Kubernetes 托管 source authority:legacy lane 使用 k8s git-mirror snapshot,Gitea/PaC migrated lane 使用 GitHub PR merge -> GitHub webhook bridge -> Gitea controlled mirror + immutable snapshot ref -> Pipelines-as-Code。受控命令先在 k8s 内同步/创建不可变 `refs/unidesk/snapshots/.../` stage ref;build/status/publish 只消费该 snapshot,host worktree、本地 `git fetch/pull`、可变 branch ref 或 Pipeline 内直连 GitHub 都不能作为 authoritative source。 - JD01/NC01 `agentrun--v02`、`sentinel--v03`、`hwlab--v03` 的正式 CI/CD closeout 入口是 `cicd status --node ` 和 `platform-infra pipelines-as-code closeout|status|history --target --consumer `。`closeout` 是通用 consumer 引导入口,只读取/等待 PaC、Tekton、GitOps、Argo 和 runtime 对齐,不触发旧手动发布。`cicd branch-follower` 和 `cicd gitea-actions-poc` 对这些 consumer 只保留历史/迁移只读用途,不得作为当前交付判断入口。 - PaC `.tekton` 文件必须用 Repository CR 的 target/node 参数隔离 JD01/NC01,避免同一个 Gitea push 在一个 target cluster 内额外创建另一个 target 的 PipelineRun;history/detail 必须按实际 PipelineRun prefix/pipeline 归属 consumer。 +- PaC source-only closeout 必须使用目标侧结构化证据: + - `g14-ci-plan` 的 source commit、affected/build/rollout 计数必须完整; + - `collect-artifacts` 与 `gitops-promote` 必须同时明确 `no-build-no-rollout-plan`; + - 三者一致时才允许返回 `no-runtime-change` 和 `retained` provenance; + - 仍要求 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` 才可读。 - 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 new file mode 100644 index 00000000..2c75db5c --- /dev/null +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -0,0 +1,441 @@ +"use strict"; + +function record(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} + +function stringOrNull(value) { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function stringArray(value) { + return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : null; +} + +function digestFromImage(value) { + if (typeof value !== "string" || !value.includes("@")) return null; + return value.split("@").slice(1).join("@") || null; +} + +function exactSkipMarker(records, event) { + return [...records].reverse().find((item) => item.event === event + && item.status === "skipped" + && item.reason === "no-build-no-rollout-plan") || null; +} + +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 affected = stringArray(plan?.affectedServices); + const rollout = stringArray(plan?.rolloutServices); + const build = stringArray(plan?.buildServices); + const reused = stringArray(plan?.reusedServices); + const sourceCommit = stringOrNull(plan?.sourceCommitId); + const planShapeValid = affected !== null && rollout !== null && build !== null; + const sourceCommitValid = sourceCommit !== null && /^[0-9a-f]{40}$/iu.test(sourceCommit); + const noRuntimeChangePlan = planShapeValid + && affected.length === 0 + && rollout.length === 0 + && build.length === 0; + const noRuntimeChangeMarkers = collectMarker !== null && promoteMarker !== null; + const skipMarkerSeen = collectMarker !== null || promoteMarker !== null; + const skipMarkerConflict = skipMarkerSeen && !noRuntimeChangePlan; + + let mode = "inconsistent"; + let valid = false; + let reason = "structured-plan-or-terminal-marker-is-incomplete"; + if (planShapeValid && sourceCommitValid && noRuntimeChangePlan && noRuntimeChangeMarkers) { + mode = "no-runtime-change"; + valid = true; + reason = "no-build-no-rollout-plan"; + } else if (planShapeValid && sourceCommitValid && !noRuntimeChangePlan && !skipMarkerConflict) { + mode = "delivery"; + valid = true; + reason = "artifact-or-runtime-delivery-required"; + } else if (!sourceCommitValid) { + reason = "source-commit-invalid-or-missing"; + } else if (!planShapeValid) { + reason = "delivery-plan-shape-invalid-or-missing"; + } else if (skipMarkerConflict) { + reason = "no-runtime-change-marker-conflicts-with-delivery-plan"; + } + + return { + schemaVersion: "v1", + contract: "hwlab-g14-ci-plan", + mode, + valid, + reason, + sourceCommit, + plan: { + affectedServiceCount: affected?.length ?? null, + rolloutServiceCount: rollout?.length ?? null, + buildServiceCount: build?.length ?? null, + reusedServiceCount: reused?.length ?? null, + }, + terminal: { + collectArtifacts: collectMarker === null ? "missing" : "no-build-no-rollout-plan", + gitopsPromote: promoteMarker === null ? "missing" : "no-build-no-rollout-plan", + }, + valuesPrinted: false, + }; +} + +function evaluatePacStatus(inputValue) { + const input = record(inputValue); + const artifact = record(input.artifact); + const argo = record(input.argo); + const runtime = record(input.runtime); + const sourceObservation = record(artifact.sourceObservation); + const revisionRelation = record(argo.revisionRelation); + const gitopsRelation = stringOrNull(revisionRelation.relation) || "unknown"; + const sourceCommit = stringOrNull(input.sourceCommit); + const registryDigest = stringOrNull(input.registryDigest); + const artifactDigest = stringOrNull(artifact.digest); + const expectedDigest = registryDigest || artifactDigest; + const runtimeImage = stringOrNull(runtime.image); + const runtimeDigest = stringOrNull(runtime.digest) || digestFromImage(runtimeImage); + const runtimeMatches = expectedDigest === null || runtimeDigest === expectedDigest; + const runtimeReady = Number.isInteger(runtime.replicas) + && runtime.replicas > 0 + && runtime.readyReplicas === runtime.replicas; + const selectedArtifactDigests = [...new Set([ + artifactDigest, + ...(Array.isArray(artifact.digests) ? artifact.digests : []), + ].filter((value) => typeof value === "string" && value.length > 0))]; + const selectedArtifactMatchesRuntime = runtimeDigest !== null && selectedArtifactDigests.includes(runtimeDigest); + const registryMatchesArtifact = registryDigest === null || artifactDigest === null || registryDigest === artifactDigest; + const healthUrl = stringOrNull(input.healthUrl); + const healthStatus = stringOrNull(input.healthStatus); + const healthReady = healthUrl === null || healthStatus === "200"; + const gitopsReady = stringOrNull(artifact.gitopsCommit) !== null && stringOrNull(argo.revision) !== null; + const gitopsRevisionAligned = gitopsRelation === "exact" || gitopsRelation === "descendant"; + const deliveryDisabled = artifact.imageStatus === "disabled"; + const deliverySkipped = artifact.imageStatus === "skipped"; + const sourceObservationMode = stringOrNull(sourceObservation.mode); + const noRuntimeChange = sourceObservationMode === "no-runtime-change"; + const sourceObservationInconsistent = sourceObservationMode === "inconsistent" + || (sourceObservationMode !== null && sourceObservation.valid !== true); + const observationSourceCommit = stringOrNull(sourceObservation.sourceCommit); + const observationSourceMatches = sourceCommit !== null && observationSourceCommit === sourceCommit; + const retainedRevision = stringOrNull(argo.revision); + const retainedDigestReady = runtimeDigest !== null && /^sha256:[0-9a-f]{64}$/iu.test(runtimeDigest); + const retainedOutputConflict = noRuntimeChange + && (stringOrNull(artifact.gitopsCommit) !== null + || artifactDigest !== null + || selectedArtifactDigests.length > 0); + + let code = "pac-diagnostics-not-applicable"; + let phase = "not-applicable"; + let ok = true; + let hint = "consumer did not expose artifact or runtime alignment evidence"; + if (deliveryDisabled) { + code = "pac-delivery-disabled"; + phase = "disabled"; + hint = "delivery is disabled by YAML; registry, GitOps and runtime artifacts are intentionally absent"; + } else if (!sourceCommit) { + ok = false; + code = "pac-source-unknown"; + phase = "source-unknown"; + hint = "latest PaC PipelineRun did not expose a source commit"; + } else if (sourceObservationInconsistent) { + 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"; + } 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"; + } else if (noRuntimeChange && retainedOutputConflict) { + ok = false; + code = "pac-retained-output-conflict"; + phase = "source-only-output-conflict"; + hint = "a no-runtime-change PipelineRun unexpectedly reported a new artifact or GitOps output"; + } else if (noRuntimeChange && retainedRevision === null) { + ok = false; + code = "pac-retained-gitops-missing"; + phase = "retained-gitops-missing"; + hint = "the no-runtime-change observation is valid but the retained Argo GitOps revision is missing"; + } else if (noRuntimeChange && (argo.sync !== "Synced" || argo.health !== "Healthy")) { + ok = false; + code = "pac-retained-argo-not-ready"; + phase = "retained-argo-not-ready"; + hint = "the no-runtime-change observation is valid but retained Argo is not Synced/Healthy"; + } else if (noRuntimeChange && gitopsRelation !== "retained") { + ok = false; + code = "pac-retained-revision-unproven"; + phase = "retained-revision-unproven"; + hint = "the retained GitOps revision was not linked to the structured no-runtime-change observation"; + } else if (noRuntimeChange && !runtimeReady) { + ok = false; + code = "pac-retained-runtime-not-ready"; + phase = "retained-runtime-not-ready"; + hint = "the retained runtime ready replicas do not match the desired replica count"; + } else if (noRuntimeChange && !retainedDigestReady) { + ok = false; + code = "pac-retained-runtime-digest-missing"; + phase = "retained-runtime-digest-missing"; + hint = "the retained runtime is not pinned to a visible sha256 digest"; + } else if (noRuntimeChange && !healthReady) { + ok = false; + code = "pac-retained-health-not-ready"; + phase = "retained-health-not-ready"; + hint = "the retained runtime health endpoint is not ready"; + } else if (noRuntimeChange) { + 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) { + ok = false; + code = "pac-registry-missing"; + phase = "source-ready-registry-missing"; + hint = "PaC source commit is known but the configured registry tag is missing"; + } else if (!registryMatchesArtifact) { + ok = false; + code = "pac-artifact-registry-mismatch"; + phase = "artifact-registry-mismatch"; + hint = "PipelineRun artifact digest does not match the configured registry digest"; + } else if (!gitopsReady) { + ok = false; + code = "pac-gitops-missing"; + phase = "artifact-ready-gitops-missing"; + hint = "PipelineRun completed but its GitOps commit is not visible"; + } else if (argo.sync !== "Synced" || argo.health !== "Healthy") { + ok = false; + code = "pac-argo-not-ready"; + phase = "gitops-ready-argo-pending"; + hint = "GitOps exists but Argo is not Synced/Healthy"; + } else if (!gitopsRevisionAligned) { + ok = false; + 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 (gitopsRelation === "descendant" && !selectedArtifactMatchesRuntime) { + ok = false; + code = "pac-descendant-artifact-runtime-mismatch"; + phase = "argo-descendant-runtime-mismatch"; + hint = "a GitOps descendant is ready only when the selected PipelineRun artifact digest exactly matches the runtime digest"; + } else if (!runtimeMatches) { + ok = false; + code = "pac-runtime-not-aligned"; + phase = "argo-ready-runtime-mismatch"; + hint = "runtime image digest does not match the artifact digest produced by the selected PipelineRun"; + } else if (!runtimeReady) { + ok = false; + code = "pac-runtime-not-ready"; + phase = "runtime-replicas-not-ready"; + hint = "runtime ready replicas do not match the desired replica count"; + } else if (!healthReady) { + ok = false; + code = "pac-health-not-ready"; + phase = "runtime-ready-health-pending"; + hint = "runtime image is aligned but the configured health endpoint is not ready"; + } else if (artifactDigest || selectedArtifactDigests.length > 0 || stringOrNull(input.imageRepository) !== null) { + code = deliverySkipped + ? "pac-ready-runtime-unchanged" + : gitopsRelation === "descendant" + ? "pac-ready-gitops-descendant" + : "pac-ready-gitops-exact"; + phase = "ready"; + hint = deliverySkipped + ? "YAML-declared runtime inputs are unchanged; the existing image, GitOps manifest, runtime and health remain aligned" + : gitopsRelation === "descendant" + ? "Argo is on a commit-graph-proven GitOps descendant and the selected artifact, runtime, Argo and health are aligned" + : "Argo is on the exact selected GitOps commit and the artifact, runtime, Argo and health are aligned"; + } + + return { + ok, + code, + phase, + hint, + sourceCommit, + runtimeSourceCommit: stringOrNull(input.runtimeSourceCommit), + imageRepository: stringOrNull(input.imageRepository), + registryProbeBase: stringOrNull(input.registryProbeBase), + imageTag: stringOrNull(input.imageTag), + registry: { + present: input.registryPresent === true, + digest: registryDigest, + url: stringOrNull(input.registryUrl), + }, + selectedArtifactDigests, + gitops: { + branch: stringOrNull(input.gitopsBranch), + manifestPath: stringOrNull(input.gitopsManifestPath), + commit: noRuntimeChange ? retainedRevision : stringOrNull(artifact.gitopsCommit) || retainedRevision, + revisionRelation, + }, + argo: { + sync: stringOrNull(argo.sync), + health: stringOrNull(argo.health), + revision: retainedRevision, + repoURL: stringOrNull(argo.repoURL), + targetRevision: stringOrNull(argo.targetRevision), + revisionRelation, + }, + runtime: { + image: runtimeImage, + digest: runtimeDigest, + readyReplicas: runtime.readyReplicas ?? null, + replicas: runtime.replicas ?? null, + }, + health: { url: healthUrl, status: healthStatus, ready: healthReady }, + pipelineRun: stringOrNull(input.pipelineRun), + deliveryDisabled, + deliverySkipped, + deliveryMode: noRuntimeChange ? "no-runtime-change" : sourceObservationMode || "delivery", + sourceObservation: Object.keys(sourceObservation).length > 0 ? sourceObservation : null, + provenance: noRuntimeChange ? { + relation: "retained", + sourceCommit, + gitopsRevision: retainedRevision, + runtimeImage, + runtimeDigest, + newArtifact: false, + newGitopsRevision: false, + runtimeChanged: false, + valuesPrinted: false, + } : { + relation: gitopsRelation, + sourceCommit, + gitopsRevision: retainedRevision, + runtimeImage, + runtimeDigest, + valuesPrinted: false, + }, + valuesPrinted: false, + }; +} + +function fixtureInput(overrides = {}) { + const digest = `sha256:${"a".repeat(64)}`; + const revision = "b".repeat(40); + return { + sourceCommit: "c".repeat(40), + artifact: {}, + argo: { + sync: "Synced", + health: "Healthy", + revision, + revisionRelation: { relation: "retained", observedRevision: revision }, + }, + runtime: { image: `registry/service@${digest}`, digest, replicas: 1, readyReplicas: 1 }, + registryPresent: false, + healthUrl: null, + healthStatus: null, + ...overrides, + }; +} + +function noRuntimeChangeObservation(sourceCommit = "c".repeat(40)) { + return extractPacSourceObservation([ + { + event: "g14-ci-plan", + sourceCommitId: sourceCommit, + affectedServices: [], + rolloutServices: [], + buildServices: [], + reusedServices: ["service"], + }, + { event: "collect-artifacts", status: "skipped", reason: "no-build-no-rollout-plan" }, + { event: "gitops-promote", status: "skipped", reason: "no-build-no-rollout-plan" }, + ]); +} + +function deliveryObservation(sourceCommit = "c".repeat(40)) { + return extractPacSourceObservation([{ + event: "g14-ci-plan", + sourceCommitId: sourceCommit, + affectedServices: ["service"], + rolloutServices: ["service"], + buildServices: ["service"], + reusedServices: [], + }]); +} + +function runPacStatusFixtureChecks() { + const digestA = `sha256:${"a".repeat(64)}`; + const digestD = `sha256:${"d".repeat(64)}`; + const revision = "b".repeat(40); + const retained = noRuntimeChangeObservation(); + const delivery = deliveryObservation(); + const exactArgo = { sync: "Synced", health: "Healthy", revision, revisionRelation: { relation: "exact", expectedRevision: revision, observedRevision: revision } }; + const cases = [ + { + id: "retained-ready", + expectedOk: true, + expectedCode: "pac-ready-no-runtime-change", + input: fixtureInput({ artifact: { imageStatus: "retained", sourceObservation: retained } }), + }, + { + id: "retained-gitops-missing", + expectedOk: false, + expectedCode: "pac-retained-gitops-missing", + input: fixtureInput({ artifact: { imageStatus: "retained", sourceObservation: retained }, argo: { sync: "Synced", health: "Healthy", revision: null, revisionRelation: { relation: "unknown" } } }), + }, + { + 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 } }), + }, + { + 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" } } }), + }, + { + 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 } }), + }, + { + id: "incomplete-no-runtime-change-markers", + expectedOk: false, + expectedCode: "pac-source-observation-inconsistent", + input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([{ event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: [], rolloutServices: [], buildServices: [], reusedServices: [] }]) } }), + }, + { + id: "retained-source-mismatch", + expectedOk: false, + expectedCode: "pac-source-observation-mismatch", + input: fixtureInput({ sourceCommit: "e".repeat(40), artifact: { imageStatus: "retained", sourceObservation: retained } }), + }, + { + id: "delivery-one-sided-skip-conflict", + 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: ["service"], reusedServices: [] }, + { event: "gitops-promote", status: "skipped", reason: "no-build-no-rollout-plan" }, + ]) } }), + }, + ]; + const checks = cases.map((item) => { + const result = evaluatePacStatus(item.input); + return { + id: item.id, + ok: result.ok === item.expectedOk && result.code === item.expectedCode, + expectedOk: item.expectedOk, + actualOk: result.ok, + expectedCode: item.expectedCode, + actualCode: result.code, + }; + }); + return { ok: checks.every((item) => item.ok), checks, valuesPrinted: false }; +} + +module.exports = { + evaluatePacStatus, + extractPacSourceObservation, + runPacStatusFixtureChecks, +}; diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 6d3ef98c..882bb314 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -1,6 +1,11 @@ #!/bin/sh set -eu +UNIDESK_PAC_EVALUATOR_PATH=$(mktemp) +printf '%s' "$UNIDESK_PAC_EVALUATOR_B64" | base64 -d >"$UNIDESK_PAC_EVALUATOR_PATH" +export UNIDESK_PAC_EVALUATOR_PATH +trap 'rm -f "$UNIDESK_PAC_EVALUATOR_PATH"' 0 1 2 15 + json_escape() { sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' ' } @@ -339,6 +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 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')); @@ -492,7 +498,7 @@ function recursiveEnvReuse(value, state) { } function envReuseForPipelineRun(namespace, name) { - const state = { status: null, buildSkippedCount: null, serviceReusedCount: null, buildCache: null, source: 'logs' }; + const state = { status: null, buildSkippedCount: null, serviceReusedCount: null, buildCache: null, source: 'logs', sourceObservation: null }; let logs = ''; try { logs = cp.execFileSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${name}`, '--all-containers', '--tail=320'], { @@ -513,13 +519,17 @@ 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 { - recursiveEnvReuse(JSON.parse(trimmed), state); + const parsed = JSON.parse(trimmed); + records.push(parsed); + recursiveEnvReuse(parsed, state); } catch {} } + state.sourceObservation = extractPacSourceObservation(records); return state; } @@ -572,6 +582,12 @@ function rowFor(consumer, item, taskRuns) { const tasks = taskSummary(taskRuns, item.metadata.name, consumer.namespace); const labels = item.metadata?.labels || {}; const annotations = item.metadata?.annotations || {}; + const sourceCommit = extractCommit(item, params); + const evidence = envReuseForPipelineRun(consumer.namespace, item.metadata.name); + const sourceObservation = evidence.sourceObservation === null ? null : { + ...evidence.sourceObservation, + sourceMatched: sourceCommit !== null && evidence.sourceObservation.sourceCommit === sourceCommit, + }; return { id: item.metadata.name, consumer: consumer.id, @@ -590,11 +606,18 @@ function rowFor(consumer, item, taskRuns) { durationSeconds: durationSeconds(item.status?.startTime || item.metadata?.creationTimestamp, item.status?.completionTime), status: c.status || null, reason: c.reason || null, - commit: extractCommit(item, params), + commit: sourceCommit, branch: extractBranch(item, params), eventType: firstString(labels['pipelinesascode.tekton.dev/event-type'], annotations['pipelinesascode.tekton.dev/event-type']), sender: firstString(labels['pipelinesascode.tekton.dev/sender'], annotations['pipelinesascode.tekton.dev/sender']), - envReuse: envReuseForPipelineRun(consumer.namespace, item.metadata.name), + envReuse: { + status: evidence.status, + buildSkippedCount: evidence.buildSkippedCount, + serviceReusedCount: evidence.serviceReusedCount, + buildCache: evidence.buildCache, + source: evidence.source, + }, + sourceObservation, taskRuns: tasks, source: 'gitea-pac-tekton-live', valuesPrinted: false, @@ -684,6 +707,7 @@ artifact_summary() { 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' 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) { @@ -691,6 +715,7 @@ for (const line of lines) { 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 @@ -731,6 +756,24 @@ if (image) { 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) { @@ -854,17 +897,23 @@ line('expected', artifact.gitopsCommit); line('observed', argo.revision); line('repoUrl', argo.repoURL); line('targetRevision', argo.targetRevision); +line('retained', artifact.sourceObservation?.mode === 'no-runtime-change' && artifact.sourceObservation?.valid === true ? 'true' : 'false'); NODE ) expected=$(printf '%s\n' "$fields" | sed -n 's/^expected=//p' | base64 -d 2>/dev/null || true) observed=$(printf '%s\n' "$fields" | sed -n 's/^observed=//p' | base64 -d 2>/dev/null || true) repo_url=$(printf '%s\n' "$fields" | sed -n 's/^repoUrl=//p' | base64 -d 2>/dev/null || true) target_revision=$(printf '%s\n' "$fields" | sed -n 's/^targetRevision=//p' | base64 -d 2>/dev/null || true) + retained=$(printf '%s\n' "$fields" | sed -n 's/^retained=//p' | base64 -d 2>/dev/null || true) relation=unknown proof=unavailable reason=missing-revision-context fetch_ok=false - if printf '%s\n%s\n' "$expected" "$observed" | grep -Eqv '^[0-9a-fA-F]{7,64}$'; then + if [ "$retained" = true ] && printf '%s' "$observed" | grep -Eq '^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$'; then + relation=retained + proof=structured-no-runtime-change-plan + reason=source-observation-retains-current-revision + elif printf '%s\n%s\n' "$expected" "$observed" | grep -Eqv '^[0-9a-fA-F]{7,64}$'; then reason=invalid-or-missing-revision elif [ -z "$repo_url" ] \ || ! git check-ref-format "refs/heads/$target_revision" >/dev/null 2>&1; then @@ -949,8 +998,9 @@ const pipelines = parseLoose(process.argv[3], []); const artifact = parseLoose(process.argv[4], {}); const latest = Array.isArray(pipelines) ? pipelines[0] || {} : {}; const sourceCommit = latest.sourceCommit || null; -const runtimeSourceCommit = artifact.runtimeSourceCommit || sourceCommit; -const tag = runtimeSourceCommit ? String(runtimeSourceCommit).slice(0, 12) : ''; +const noRuntimeChange = artifact.sourceObservation?.mode === 'no-runtime-change' && artifact.sourceObservation?.valid === true; +const runtimeSourceCommit = artifact.runtimeSourceCommit || (noRuntimeChange ? null : sourceCommit); +const tag = !noRuntimeChange && runtimeSourceCommit ? String(runtimeSourceCommit).slice(0, 12) : ''; const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : ''; const param = (name) => params[`${prefix}${name}`] ?? params[name]; const imageRepository = typeof param('image_repository') === 'string' ? param('image_repository') : ''; @@ -1045,6 +1095,7 @@ NODE export UNIDESK_PAC_DIAG_HEALTH_STATUS="$health_status" node - "$params_file" "$pipelines_file" "$artifact_file" "$argo_file" "$runtime_file" <<'NODE' const fs = require('node:fs'); +const { evaluatePacStatus } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); function parseLoose(path, fallback) { const input = fs.readFileSync(path, 'utf8').trim(); if (!input) return fallback; @@ -1053,105 +1104,30 @@ function parseLoose(path, fallback) { } return fallback; } -const params = parseLoose(process.argv[2], {}); const pipelines = parseLoose(process.argv[3], []); const artifact = parseLoose(process.argv[4], {}); const argo = parseLoose(process.argv[5], {}); const runtime = parseLoose(process.argv[6], {}); const latest = Array.isArray(pipelines) ? pipelines[0] || {} : {}; -const registryPresent = process.env.UNIDESK_PAC_DIAG_REGISTRY_PRESENT === 'true'; -const sourceCommit = process.env.UNIDESK_PAC_DIAG_SOURCE_COMMIT || latest.sourceCommit || null; -const registryDigest = process.env.UNIDESK_PAC_DIAG_REGISTRY_DIGEST || null; -const artifactDigest = artifact.digest || null; -const expectedDigest = registryDigest || artifactDigest; -const runtimeImage = runtime.image || null; -const runtimeDigest = runtime.digest || (typeof runtimeImage === 'string' && runtimeImage.includes('@') ? runtimeImage.split('@').slice(1).join('@') : null); -const runtimeMatches = !expectedDigest || runtimeDigest === expectedDigest; -const runtimeReady = Number.isInteger(runtime.replicas) && runtime.replicas > 0 && runtime.readyReplicas === runtime.replicas; -const selectedArtifactDigests = [...new Set([artifactDigest, ...(Array.isArray(artifact.digests) ? artifact.digests : [])].filter(Boolean))]; -const selectedArtifactMatchesRuntime = Boolean(runtimeDigest && selectedArtifactDigests.includes(runtimeDigest)); -const registryMatchesArtifact = !registryDigest || !artifactDigest || registryDigest === artifactDigest; -const healthUrl = process.env.UNIDESK_PAC_DIAG_HEALTH_URL || null; -const healthStatus = process.env.UNIDESK_PAC_DIAG_HEALTH_STATUS || null; -const healthReady = !healthUrl || healthStatus === '200'; -const revisionRelation = argo.revisionRelation || { relation: 'unknown' }; -const gitopsRelation = revisionRelation.relation || 'unknown'; -const gitopsReady = Boolean(artifact.gitopsCommit && argo.revision); -const gitopsRevisionAligned = gitopsRelation === 'exact' || gitopsRelation === 'descendant'; -const deliveryDisabled = artifact.imageStatus === 'disabled'; -const deliverySkipped = artifact.imageStatus === 'skipped'; -let code = 'pac-diagnostics-not-applicable'; -let phase = 'not-applicable'; -let ok = true; -let hint = 'consumer did not expose artifact or runtime alignment evidence'; -if (deliveryDisabled) { - code = 'pac-delivery-disabled'; phase = 'disabled'; hint = 'delivery is disabled by YAML; registry, GitOps and runtime artifacts are intentionally absent'; -} else if (!sourceCommit) { - ok = false; code = 'pac-source-unknown'; phase = 'source-unknown'; hint = 'latest PaC PipelineRun did not expose a source commit'; -} else if (process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY && !registryPresent) { - ok = false; code = 'pac-registry-missing'; phase = 'source-ready-registry-missing'; hint = 'PaC source commit is known but the configured registry tag is missing'; -} else if (!registryMatchesArtifact) { - ok = false; code = 'pac-artifact-registry-mismatch'; phase = 'artifact-registry-mismatch'; hint = 'PipelineRun artifact digest does not match the configured registry digest'; -} else if (!gitopsReady) { - ok = false; code = 'pac-gitops-missing'; phase = 'artifact-ready-gitops-missing'; hint = 'PipelineRun completed but its GitOps commit is not visible'; -} else if (argo.sync !== 'Synced' || argo.health !== 'Healthy') { - ok = false; code = 'pac-argo-not-ready'; phase = 'gitops-ready-argo-pending'; hint = 'GitOps exists but Argo is not Synced/Healthy'; -} else if (!gitopsRevisionAligned) { - ok = false; 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 (gitopsRelation === 'descendant' && !selectedArtifactMatchesRuntime) { - ok = false; code = 'pac-descendant-artifact-runtime-mismatch'; phase = 'argo-descendant-runtime-mismatch'; hint = 'a GitOps descendant is ready only when the selected PipelineRun artifact digest exactly matches the runtime digest'; -} else if (!runtimeMatches) { - ok = false; code = 'pac-runtime-not-aligned'; phase = 'argo-ready-runtime-mismatch'; hint = 'runtime image digest does not match the artifact digest produced by the selected PipelineRun'; -} else if (!runtimeReady) { - ok = false; code = 'pac-runtime-not-ready'; phase = 'runtime-replicas-not-ready'; hint = 'runtime ready replicas do not match the desired replica count'; -} else if (!healthReady) { - ok = false; code = 'pac-health-not-ready'; phase = 'runtime-ready-health-pending'; hint = 'runtime image is aligned but the configured health endpoint is not ready'; -} else if (artifactDigest || selectedArtifactDigests.length > 0 || process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY) { - code = deliverySkipped ? 'pac-ready-runtime-unchanged' : gitopsRelation === 'descendant' ? 'pac-ready-gitops-descendant' : 'pac-ready-gitops-exact'; - phase = 'ready'; - hint = deliverySkipped - ? 'YAML-declared runtime inputs are unchanged; the existing image, GitOps manifest, runtime and health remain aligned' - : gitopsRelation === 'descendant' - ? 'Argo is on a commit-graph-proven GitOps descendant and the selected artifact, runtime, Argo and health are aligned' - : 'Argo is on the exact selected GitOps commit and the artifact, runtime and health are aligned'; -} -process.stdout.write(JSON.stringify({ - ok, - code, - phase, - hint, - sourceCommit, +const evaluated = evaluatePacStatus({ + sourceCommit: process.env.UNIDESK_PAC_DIAG_SOURCE_COMMIT || latest.sourceCommit || null, runtimeSourceCommit: process.env.UNIDESK_PAC_DIAG_RUNTIME_SOURCE_COMMIT || artifact.runtimeSourceCommit || null, imageRepository: process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY || null, registryProbeBase: process.env.UNIDESK_PAC_DIAG_REGISTRY_PROBE_BASE || null, imageTag: process.env.UNIDESK_PAC_DIAG_IMAGE_TAG || null, - registry: { - present: registryPresent, - digest: registryDigest, - url: process.env.UNIDESK_PAC_DIAG_REGISTRY_URL || null, - }, - selectedArtifactDigests, - gitops: { - branch: process.env.UNIDESK_PAC_DIAG_GITOPS_BRANCH || null, - manifestPath: process.env.UNIDESK_PAC_DIAG_GITOPS_MANIFEST_PATH || null, - commit: artifact.gitopsCommit || argo.revision || null, - revisionRelation, - }, - argo: { - sync: argo.sync || null, - health: argo.health || null, - revision: argo.revision || null, - repoURL: argo.repoURL || null, - targetRevision: argo.targetRevision || null, - revisionRelation, - }, - runtime: { image: runtimeImage, digest: runtimeDigest, readyReplicas: runtime.readyReplicas ?? null, replicas: runtime.replicas ?? null }, - health: { url: healthUrl, status: healthStatus, ready: healthReady }, + registryUrl: process.env.UNIDESK_PAC_DIAG_REGISTRY_URL || null, + registryPresent: process.env.UNIDESK_PAC_DIAG_REGISTRY_PRESENT === 'true', + registryDigest: process.env.UNIDESK_PAC_DIAG_REGISTRY_DIGEST || null, + gitopsBranch: process.env.UNIDESK_PAC_DIAG_GITOPS_BRANCH || null, + gitopsManifestPath: process.env.UNIDESK_PAC_DIAG_GITOPS_MANIFEST_PATH || null, + healthUrl: process.env.UNIDESK_PAC_DIAG_HEALTH_URL || null, + healthStatus: process.env.UNIDESK_PAC_DIAG_HEALTH_STATUS || null, pipelineRun: latest.name || null, - deliveryDisabled, - deliverySkipped, - valuesPrinted: false, -})); + artifact, + argo, + runtime, +}); +process.stdout.write(JSON.stringify(evaluated)); NODE rm -f "$params_file" "$pipelines_file" "$artifact_file" "$argo_file" "$runtime_file" } @@ -1229,6 +1205,15 @@ history_action() { "$consumers" "$rows" "$errors" "$hooks" } +debug_step_action() { + 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; +NODE +} + webhook_test_action() { hooks=$(hook_summary) hook_id=$(printf '%s' "$hooks" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(String(a[0]?.id||""))') @@ -1241,6 +1226,7 @@ case "$UNIDESK_PAC_ACTION" in apply) apply_action ;; status) status_action ;; history) history_action ;; + debug-step) debug_step_action ;; webhook-test) webhook_test_action ;; *) printf '{"ok":false,"error":"unsupported-action","valuesPrinted":false}\n'; exit 2 ;; esac diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index a12cc216..d247cbea 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -21,6 +21,7 @@ import { materializeYamlComposition } from "./yaml-composition"; const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml"); const configLabel = "config/platform-infra/pipelines-as-code.yaml"; const remoteScriptFile = rootPath("scripts", "src", "platform-infra-pipelines-as-code-remote.sh"); +const evaluatorFile = rootPath("scripts", "native", "cicd", "pac-status-evaluator.cjs"); const fieldManager = "unidesk-platform-infra-pipelines-as-code"; const y = createYamlFieldReader(configLabel); @@ -138,6 +139,7 @@ interface CommonOptions { consumerId: string | null; full: boolean; raw: boolean; + json: boolean; } export interface PipelinesAsCodeNodeStatusOptions { @@ -182,48 +184,54 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf if (action === "plan") { const options = parseCommonOptions(args.slice(1)); const result = plan(options); - return options.full || options.raw ? result : renderPlan(result); + return options.json || options.full || options.raw ? result : renderPlan(result); } if (action === "apply") { const options = parseApplyOptions(args.slice(1)); const result = await apply(config, options); - return options.full || options.raw ? result : renderApply(result); + return options.json || options.full || options.raw ? result : renderApply(result); } if (action === "status") { const options = parseCommonOptions(args.slice(1)); const result = await status(config, options); - return options.full || options.raw ? result : renderStatus(result); + return options.full || options.raw ? result : options.json ? compactStatusJson(result) : renderStatus(result); } if (action === "closeout") { const options = parseCloseoutOptions(args.slice(1)); const result = await closeout(config, options); - return options.full || options.raw ? result : renderCloseout(result); + return options.full || options.raw ? result : options.json ? compactCloseoutJson(result) : renderCloseout(result); } if (action === "history" || action === "runs") { const options = parseHistoryOptions(args.slice(1)); const result = await history(config, options); - return options.full || options.raw ? result : renderHistory(result); + return options.full || options.raw ? result : options.json ? compactHistoryJson(result) : renderHistory(result); + } + if (action === "debug-step") { + const options = parseCommonOptions(args.slice(1)); + const result = await debugStep(config, options); + return options.full || options.raw ? result : options.json ? result : renderDebugStep(result); } if (action === "webhook-test") { const options = parseWebhookTestOptions(args.slice(1)); const result = await webhookTest(config, options); - return options.full || options.raw ? result : renderWebhookTest(result); + return options.json || options.full || options.raw ? result : renderWebhookTest(result); } return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help() }; } function help(): Record { return { - command: "platform-infra pipelines-as-code plan|apply|status|closeout|history", + command: "platform-infra pipelines-as-code plan|apply|status|closeout|history|debug-step", configTruth: configLabel, usage: [ "bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01", "bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --dry-run", "bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --confirm", - "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--full|--raw]", + "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--json|--full|--raw]", "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]", ], 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.", @@ -469,7 +477,7 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise { const repository = resolveRepository(pac, consumer.repositoryRef); try { - const current = await status(config, { targetId: target.id, consumerId: consumer.id, full: false, raw: false }); + const current = await status(config, { targetId: target.id, consumerId: consumer.id, full: false, raw: false, json: false }); return nodeStatusRow(target.id, consumer, repository, current); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -564,7 +572,7 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis let observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics); let sourceMatched = options.sourceCommit === null || observedSourceCommit === options.sourceCommit; const ciReady = closeoutCiReady(current, options.sourceCommit); - let gitOpsMirrorFlush = await runCloseoutGitOpsMirrorFlush(config, pac, consumer, latest, ciReady, observedSourceCommit); + let gitOpsMirrorFlush = await runCloseoutGitOpsMirrorFlush(config, pac, consumer, summary, latest, ciReady, observedSourceCommit); const gitOpsMirrorFlushReady = record(gitOpsMirrorFlush).ok !== false; const runtimeStartedAt = Date.now(); if (ciReady && gitOpsMirrorFlushReady) { @@ -621,8 +629,12 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis async function history(config: UniDeskConfig, options: HistoryOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); + const targetConsumers = pac.consumers.filter((consumer) => consumer.node.toLowerCase() === target.id.toLowerCase()); + const inferredDetailConsumers = options.detailId === null + ? [] + : targetConsumers.filter((consumer) => options.detailId?.startsWith(consumer.pipelineRunPrefix)); const selectedConsumers = options.consumerId === null - ? pac.consumers + ? inferredDetailConsumers.length === 1 ? inferredDetailConsumers : targetConsumers : [resolveConsumer(pac, options.consumerId)]; const firstConsumer = selectedConsumers[0]; if (firstConsumer === undefined) throw new Error("no Pipelines-as-Code consumers are configured"); @@ -648,11 +660,41 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise< detailId: options.detailId, rows: arrayRecords(record(remote).rows), historyErrors, - remote, + controlPlane: parsed === null ? undefined : { + crdPresent: parsed.crdPresent === true, + controllerReady: parsed.controllerReady, + repositoryCondition: parsed.repositoryCondition, + repository: record(parsed.repository), + consumerRows: arrayRecords(parsed.consumerRows), + webhookCount: arrayRecords(parsed.webhooks).length, + valuesPrinted: false, + }, + remote: parsed === null || options.raw ? remote : undefined, next: nextCommands(target.id, options.consumerId ?? pac.defaults.consumerId, pac.defaults.consumerId), }; } +async function debugStep(config: UniDeskConfig, options: CommonOptions): Promise> { + const pac = readPacConfig(); + const target = resolveTarget(pac, options.targetId); + const consumer = resolveConsumer(pac, options.consumerId); + const repository = resolveRepository(pac, consumer.repositoryRef); + const result = await capture(config, target.route, ["sh"], remoteScript("debug-step", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, emptySecretMaterial(), "")); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && parsed?.ok === true, + action: "platform-infra-pipelines-as-code-debug-step", + mutation: false, + target: targetSummary(target), + consumer: consumer.id, + evaluator: "scripts/native/cicd/pac-status-evaluator.cjs", + checks: parsed === null ? [] : arrayRecords(parsed.checks), + remote: parsed === null || options.raw ? parsed ?? compactCapture(result, { full: true }) : undefined, + next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), + valuesPrinted: false, + }; +} + async function webhookTest(config: UniDeskConfig, options: WebhookTestOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); @@ -681,10 +723,11 @@ async function fetchReleaseManifest(pac: PacConfig): Promise { return text; } -function remoteScript(action: "apply" | "status" | "history" | "webhook-test", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | WebhookTestOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string { +function remoteScript(action: "apply" | "status" | "history" | "debug-step" | "webhook-test", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | WebhookTestOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string { const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`; const env: Record = { UNIDESK_PAC_ACTION: action, + UNIDESK_PAC_EVALUATOR_B64: Buffer.from(readFileSync(evaluatorFile, "utf8"), "utf8").toString("base64"), UNIDESK_PAC_FIELD_MANAGER: fieldManager, UNIDESK_PAC_TARGET_ID: target.id, UNIDESK_PAC_TARGET_NAMESPACE: consumer.namespace, @@ -791,6 +834,17 @@ function parseLinePair(path: string, usernameLine: number, passwordLine: number) return { username, password }; } +function emptySecretMaterial(): SecretMaterial { + return { + adminUsername: "", + adminPassword: "", + adminFingerprint: "", + webhookSecret: "", + webhookFingerprint: "", + webhookPath: "", + }; +} + function ensureSecrets(pac: PacConfig, createWebhookSecret: boolean, allowMissingWebhookSecret = false): SecretMaterial { const adminPath = credentialPath(pac.gitea.admin.sourceRoot, pac.gitea.admin.sourceRef); if (!existsSync(adminPath)) throw new Error(`${pac.gitea.admin.sourceRef} is missing`); @@ -858,11 +912,18 @@ function statusSummary(payload: Record): Record): Record { } function consumerCoverage(pac: PacConfig, targetId: string): Record[] { - return pac.consumers.map((consumer) => { + return pac.consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase()).map((consumer) => { const repository = resolveRepository(pac, consumer.repositoryRef); const suffix = consumerSuffix(consumer.id, pac.defaults.consumerId); return { @@ -1057,6 +1120,167 @@ function nextCommands(targetId: string, consumerId: string, defaultConsumerId: s }; } +function compactStatusJson(result: Record): Record { + const consumer = record(result.consumer); + return { + ok: result.ok === true, + action: result.action, + mutation: false, + target: result.target, + consumer: { + id: consumer.id, + node: consumer.node, + lane: consumer.lane, + namespace: consumer.namespace, + pipeline: consumer.pipeline, + argoApplication: consumer.argoApplication, + }, + summary: compactStatusSummary(record(result.summary)), + next: result.next, + valuesPrinted: false, + }; +} + +function compactStatusSummary(summary: Record): Record { + const latest = record(summary.latestPipelineRun); + const artifact = record(summary.artifact); + const argo = record(summary.argo); + const runtime = record(summary.runtime); + const diagnostics = record(summary.diagnostics); + const taskRuns = arrayRecords(summary.taskRuns); + const longest = longestTaskRun(taskRuns); + return { + ready: summary.ready === true, + crdPresent: summary.crdPresent === true, + controllerReady: summary.controllerReady, + repositoryCondition: summary.repositoryCondition, + webhookCount: summary.webhookCount, + latestPipelineRun: { + name: latest.name, + status: latest.status, + reason: latest.reason, + durationSeconds: latest.durationSeconds, + sourceCommit: latest.sourceCommit, + }, + taskRuns: { + count: taskRuns.length, + longest: longest === null ? null : { + name: longest.name, + status: longest.status, + reason: longest.reason, + durationSeconds: longest.durationSeconds, + }, + }, + pipelineRunGate: summary.pipelineRunGate, + sourceObservation: summary.sourceObservation, + artifact: { + imageStatus: artifact.imageStatus, + envReuse: artifact.envReuse, + envIdentity: artifact.envIdentity, + digest: artifact.digest, + gitopsCommit: artifact.gitopsCommit, + provenanceRelation: artifact.provenanceRelation, + }, + argo: { + sync: argo.sync, + health: argo.health, + revision: argo.revision, + repoURL: argo.repoURL, + targetRevision: argo.targetRevision, + revisionRelation: argo.revisionRelation, + }, + runtime: { + deployment: runtime.deployment, + readyReplicas: runtime.readyReplicas, + replicas: runtime.replicas, + image: runtime.image, + digest: runtime.digest, + }, + diagnostics: { + ok: diagnostics.ok !== false, + code: diagnostics.code, + phase: diagnostics.phase, + hint: diagnostics.hint, + deliveryMode: diagnostics.deliveryMode, + registry: diagnostics.registry, + }, + provenance: summary.provenance, + valuesPrinted: false, + }; +} + +function compactCloseoutJson(result: Record): Record { + const consumer = record(result.consumer); + const flush = record(result.gitOpsMirrorFlush); + return { + ok: result.ok === true, + action: result.action, + mutation: result.mutation === true, + target: result.target, + consumer: { id: consumer.id, node: consumer.node, lane: consumer.lane }, + sourceCommit: result.sourceCommit, + observedSourceCommit: result.observedSourceCommit, + sourceMatched: result.sourceMatched === true, + ready: result.ready === true, + wait: result.wait, + summary: compactStatusSummary(record(result.summary)), + gitOpsMirrorFlush: { + ok: flush.ok !== false, + enabled: flush.enabled, + required: flush.required, + applicable: flush.applicable, + mode: flush.mode, + executed: flush.executed === true, + reason: flush.reason, + }, + blocker: result.blocker, + next: result.next, + valuesPrinted: false, + }; +} + +function compactHistoryJson(result: Record): Record { + return { + ok: result.ok === true, + action: result.action, + mutation: false, + target: result.target, + config: result.config, + consumers: result.consumers, + detailId: result.detailId, + rows: arrayRecords(result.rows).map((row) => { + const taskRuns = record(row.taskRuns); + return { + id: row.id ?? row.pipelineRun, + consumer: row.consumer, + repo: row.repo, + pipeline: row.pipeline, + triggeredAt: row.triggeredAt, + startTime: row.startTime, + completionTime: row.completionTime, + displayTime: row.displayTime, + displayTimeZone: row.displayTimeZone, + durationSeconds: row.durationSeconds, + status: row.status, + reason: row.reason, + commit: row.commit, + branch: row.branch, + envReuse: row.envReuse, + sourceObservation: row.sourceObservation, + taskRuns: { + count: taskRuns.count, + longest: taskRuns.longest, + failed: taskRuns.failed, + logsCommand: taskRuns.logsCommand, + }, + }; + }), + historyErrors: result.historyErrors, + next: result.next, + valuesPrinted: false, + }; +} + function renderPlan(result: Record): RenderedCliResult { const target = record(result.target); const config = record(result.config); @@ -1116,6 +1340,8 @@ function renderStatus(result: Record): RenderedCliResult { const artifact = record(summary.artifact); const argo = record(summary.argo); const diagnostics = record(summary.diagnostics); + const sourceObservation = record(summary.sourceObservation); + const deliveryPlan = record(sourceObservation.plan); const repository = record(summary.repository); const webhooks = arrayRecords(summary.webhooks); const lines = [ @@ -1134,6 +1360,18 @@ function renderStatus(result: Record): RenderedCliResult { "LATEST PIPELINERUN", ...table(["NAME", "STATUS", "REASON", "DURATION_S", "SOURCE"], [[stringValue(latest.name), stringValue(latest.status), stringValue(latest.reason), stringValue(latest.durationSeconds), short(stringValue(latest.sourceCommit))]]), "", + "DELIVERY OBSERVATION", + ...table(["MODE", "VALID", "SOURCE_MATCHED", "AFFECTED", "ROLLOUT", "BUILD", "REUSED", "REASON"], [[ + stringValue(sourceObservation.mode), + boolText(sourceObservation.valid), + boolText(sourceObservation.sourceMatched ?? (stringValue(sourceObservation.sourceCommit) === stringValue(latest.sourceCommit))), + stringValue(deliveryPlan.affectedServiceCount), + stringValue(deliveryPlan.rolloutServiceCount), + stringValue(deliveryPlan.buildServiceCount), + stringValue(deliveryPlan.reusedServiceCount), + stringValue(sourceObservation.reason), + ]]), + "", "TASKRUN DURATIONS", ...(taskRuns.length === 0 ? ["-"] : table(["TASKRUN", "STATUS", "REASON", "FAILED_STEP", "EXIT", "DURATION_S"], taskRuns.map((item) => { const failedStep = record(item.failedStep); @@ -1184,6 +1422,7 @@ function renderCloseout(result: Record): RenderedCliResult { const argo = record(summary.argo); const runtime = record(summary.runtime); const diagnostics = record(summary.diagnostics); + const sourceObservation = record(summary.sourceObservation); const blocker = record(result.blocker); const gitOpsMirrorFlush = record(result.gitOpsMirrorFlush); const lines = [ @@ -1203,6 +1442,7 @@ function renderCloseout(result: Record): RenderedCliResult { "", "SOURCE / PIPELINERUN", ...table(["EXPECTED", "OBSERVED", "PIPELINERUN", "STATUS", "DURATION_S"], [[short(stringValue(result.sourceCommit), 16), short(stringValue(result.observedSourceCommit), 16), stringValue(latest.name), stringValue(latest.reason ?? latest.status), stringValue(latest.durationSeconds)]]), + ` delivery: ${stringValue(sourceObservation.mode)} (${stringValue(sourceObservation.reason)})`, "", "RUNTIME", ...table(["IMAGE_STATUS", "ENV_REUSE", "DIGEST", "GITOPS", "ARGO", "RELATION", "RUNTIME"], [[ @@ -1289,6 +1529,32 @@ function renderWebhookTest(result: Record): RenderedCliResult { return rendered(result, "platform-infra pipelines-as-code webhook-test", lines); } +function renderDebugStep(result: Record): RenderedCliResult { + const checks = arrayRecords(result.checks); + const lines = [ + "PLATFORM-INFRA PIPELINES-AS-CODE DEBUG STEP", + ...table(["TARGET", "CONSUMER", "OK", "CHECKS", "MUTATION"], [[ + stringValue(record(result.target).id), + stringValue(result.consumer), + boolText(result.ok), + stringValue(checks.length), + boolText(result.mutation), + ]]), + "", + "STATUS EVALUATOR FIXTURES", + ...(checks.length === 0 ? ["-"] : table(["CASE", "OK", "EXPECTED", "ACTUAL"], checks.map((item) => [ + stringValue(item.id), + boolText(item.ok), + `${stringValue(item.expectedOk)}/${stringValue(item.expectedCode)}`, + `${stringValue(item.actualOk)}/${stringValue(item.actualCode)}`, + ]))), + "", + "NEXT", + ` status: ${stringValue(record(result.next).status)}`, + ]; + return rendered(result, "platform-infra pipelines-as-code debug-step", lines); +} + function parseApplyOptions(args: string[]): ApplyOptions { const commonArgs: string[] = []; let confirm = false; @@ -1388,6 +1654,7 @@ function parseCommonOptions(args: string[]): CommonOptions { let consumerId: string | null = null; let full = false; let raw = false; + let json = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--target" || arg === "--node" || arg === "--consumer") { @@ -1402,11 +1669,13 @@ function parseCommonOptions(args: string[]): CommonOptions { } else if (arg === "--raw") { raw = true; full = true; + } else if (arg === "--json") { + json = true; } else { throw new Error(`unsupported pipelines-as-code option: ${arg}`); } } - return { targetId, consumerId, full, raw }; + return { targetId, consumerId, full, raw, json }; } function closeoutReady(value: Record, sourceCommit: string | null): boolean { @@ -1427,6 +1696,7 @@ function closeoutCiReady(value: Record, sourceCommit: string | if (pipelineGate.ok !== true) return false; const observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics); if (sourceCommit !== null && observedSourceCommit !== sourceCommit) 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; return stringValue(artifact.gitopsCommit) !== "-"; @@ -1479,10 +1749,23 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function runCloseoutGitOpsMirrorFlush(config: UniDeskConfig, pac: PacConfig, consumer: PacConsumer, latest: Record, baseReady: boolean, observedSourceCommit: string): Promise> { +async function runCloseoutGitOpsMirrorFlush(config: UniDeskConfig, pac: PacConfig, consumer: PacConsumer, summary: Record, latest: Record, baseReady: boolean, observedSourceCommit: string): Promise> { const cfg = pac.closeout.gitOpsMirrorFlush; const configSource = `${configLabel}.closeout.gitOpsMirrorFlush`; const required = cfg.enabled && consumer.closeoutGitOpsMirrorFlush; + if (baseReady && stringValue(record(summary.diagnostics).code) === "pac-ready-no-runtime-change") { + return { + ok: true, + enabled: cfg.enabled, + required, + applicable: false, + mode: "retained-no-runtime-change", + executed: false, + reason: "the successful source observation produced no GitOps change, so there is no new revision to flush", + configSource, + valuesPrinted: false, + }; + } if (!required) { return { ok: true, @@ -1766,6 +2049,8 @@ function runtimeText(runtime: Record): string { function renderHistoryDetail(row: Record): string[] { const envReuse = record(row.envReuse); + const sourceObservation = record(row.sourceObservation); + const deliveryPlan = record(sourceObservation.plan); const taskRuns = record(row.taskRuns); const longest = record(taskRuns.longest); const failed = record(taskRuns.failed); @@ -1789,6 +2074,14 @@ function renderHistoryDetail(row: Record): string[] { ["failedStepTermination", stringValue(failedStep.terminationReason ?? failedStep.reason)], ["failure", compactLine(stringValue(failed.message))], ["envReuseSource", stringValue(envReuse.source)], + ["deliveryMode", stringValue(sourceObservation.mode)], + ["deliveryValid", stringValue(sourceObservation.valid)], + ["deliverySourceMatched", stringValue(sourceObservation.sourceMatched)], + ["deliveryReason", stringValue(sourceObservation.reason)], + ["affectedServices", stringValue(deliveryPlan.affectedServiceCount)], + ["rolloutServices", stringValue(deliveryPlan.rolloutServiceCount)], + ["buildServices", stringValue(deliveryPlan.buildServiceCount)], + ["reusedServices", stringValue(deliveryPlan.reusedServiceCount)], ]), "", "LOGS", diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index 9e2771b2..abd2680b 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -468,8 +468,10 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra gitea mirror sync --target JD01 --confirm", "bun scripts/cli.ts platform-infra gitea mirror status --target JD01", "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 status --target JD01 --consumer ", - "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer --limit 10", + "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]", ], 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,