diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 0d22ba22..a38bd660 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -40,7 +40,10 @@ bun scripts/cli.ts agentrun control-plane legacy-cicd --help bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help ``` -节点级只读状态必须优先用 `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。 +- 节点级只读状态: + - 优先用 `cicd status --node `; + - 单 consumer 再用 `platform-infra pipelines-as-code status --target --consumer ` 下钻; + - 状态输出与 registry applicability 的长期判定见 [references/gitea-pac.md](references/gitea-pac.md)。 - 新增 PaC consumer 的首次引导: - 先执行一次 `pipelines-as-code bootstrap --dry-run`; diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index 5c9488f8..1339e4a6 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -78,6 +78,15 @@ bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target -- - 只有归属或 TaskRun 断点不清楚时再用 `history --id` 或 `debug-step --id`; - 默认不使用 `--full` 或 `--raw`,避免把大对象带回本机; - consumer 声明 `sourceArtifact` 且需要精确提交验收时,最后执行一次 `source-artifact verify-runtime`。 +- node 状态摘要必须保持可读且可下钻: + - text 输出在零 consumer、ready、warning 与失败时都返回非空 typed 摘要; + - 摘要只给出与当前状态匹配的精确下钻命令; + - 不要求操作者阅读源码或手工拼接多个 consumer 命令。 +- registry applicability 只由 owning YAML 决定: + - consumer 对应 pipeline 声明 image repository 时,registry 状态适用; + - 适用的 registry 缺失是 blocker; + - GitOps-only consumer 未声明 image repository 时,registry 显示 `N/A`; + - 不得因 registry 不适用而把 GitOps-only consumer 判为失败。 ## PaC 源码侧制品同步 diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index f14ffb46..f60a62c3 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -567,6 +567,11 @@ function extractPacArtifactEvidence(recordsInput, logText) { function evaluatePacStatus(inputValue) { const input = record(inputValue); + const registryApplicability = input.registryApplicability === "not-configured" + ? "not-configured" + : input.registryApplicability === "configured" || stringOrNull(input.imageRepository) !== null + ? "configured" + : "not-configured"; const artifact = record(input.artifact); const argo = record(input.argo); const runtime = record(input.runtime); @@ -704,7 +709,7 @@ function evaluatePacStatus(inputValue) { 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) { + } else if (registryApplicability === "configured" && stringOrNull(input.imageRepository) !== null && !registryEvidenceReady) { ok = false; code = "pac-registry-missing"; phase = "source-ready-registry-missing"; @@ -779,7 +784,9 @@ function evaluatePacStatus(inputValue) { registryProbeBase: stringOrNull(input.registryProbeBase), imageTag: stringOrNull(input.imageTag), registry: { - present: registryEvidenceReady, + applicability: registryApplicability, + status: registryApplicability === "configured" ? (registryEvidenceReady ? "present" : "missing") : "not-configured", + present: registryApplicability === "configured" ? registryEvidenceReady : null, probePresent: input.registryPresent === true, catalogVerified: catalogDeliveryEvidence, digest: registryDigest, @@ -915,6 +922,18 @@ function runPacStatusFixtureChecks() { expectedCode: "pac-ready-gitops-exact", 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: "gitops-only-registry-not-configured", + expectedOk: true, + expectedCode: "pac-ready-gitops-exact", + input: fixtureInput({ registryApplicability: "not-configured", imageRepository: "registry/shared-repository-image", artifact: { gitopsCommit: revision }, argo: exactArgo }), + }, + { + id: "image-registry-missing-fails-closed", + expectedOk: false, + expectedCode: "pac-registry-missing", + input: fixtureInput({ registryApplicability: "configured", imageRepository: "registry/service", artifact: { imageStatus: "built", digest: digestA, gitopsCommit: revision }, argo: exactArgo }), + }, { id: "delivery-ready-descendant", expectedOk: true, diff --git a/scripts/src/cicd-node-status.test.ts b/scripts/src/cicd-node-status.test.ts new file mode 100644 index 00000000..eafa1ff7 --- /dev/null +++ b/scripts/src/cicd-node-status.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test"; + +import { renderNodeStatus } from "./cicd-node-status"; + +const next = { + status: "bun scripts/cli.ts cicd status --node NC01", + history: "bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01", + fixAutomaticDelivery: { reference: ".agents/skills/unidesk-cicd/SKILL.md" }, +}; + +function result(status: "ready" | "warning" | "blocked", consumers: Array>): Record { + return { + ok: status === "ready", + node: "NC01", + target: { id: "NC01" }, + summary: { + ready: status === "ready", + status, + code: status === "ready" ? "cicd-node-ready" : status === "warning" ? "cicd-node-no-consumers" : "cicd-node-blocked", + total: consumers.length, + readyCount: consumers.filter((consumer) => consumer.ready === true).length, + blockedCount: consumers.filter((consumer) => consumer.ready !== true).length, + elapsedMs: 12, + }, + consumers, + next, + }; +} + +const readyConsumer = { + consumer: "platform-infra-gitea-nc01", + ready: true, + pipelineStatus: "Succeeded", + durationSeconds: 9, + sourceCommit: "a".repeat(40), + gitopsCommit: "b".repeat(40), + argo: { sync: "Synced", health: "Healthy", revisionRelation: { relation: "exact" } }, + runtime: { readyReplicas: 1, replicas: 1, digest: `sha256:${"c".repeat(64)}` }, + reason: "ready", + latestPipelineRun: "platform-infra-gitea-nc01-fixture", + digest: `sha256:${"c".repeat(64)}`, + imageStatus: "-", + diagnostics: { hint: "GitOps-only consumer is ready", registry: { applicability: "not-configured", status: "not-configured", present: null } }, +}; + +test("node status renderer is non-empty for zero consumers, ready, warning, and blocked projections", () => { + const fixtures = [ + result("warning", []), + result("ready", [readyConsumer]), + result("warning", [{ ...readyConsumer, ready: false, reason: "diagnostics-warning" }]), + result("blocked", [{ ...readyConsumer, ready: false, reason: "pac-registry-missing" }]), + ]; + for (const fixture of fixtures) { + const rendered = renderNodeStatus(fixture, true).renderedText; + expect(rendered.trim().length).toBeGreaterThan(0); + expect(rendered).toContain("CI/CD NODE STATUS"); + expect(rendered).toContain("NEXT"); + } +}); + +test("node full renderer shows registry N/A without hiding digest evidence", () => { + const rendered = renderNodeStatus(result("ready", [readyConsumer]), true).renderedText; + expect(rendered).toContain("N/A"); + expect(rendered).toContain("sha256:"); + expect(rendered).toContain("platform-infra-gitea-nc01-fixture"); +}); diff --git a/scripts/src/cicd-node-status.ts b/scripts/src/cicd-node-status.ts index fa25ac1e..a5534791 100644 --- a/scripts/src/cicd-node-status.ts +++ b/scripts/src/cicd-node-status.ts @@ -76,7 +76,7 @@ function parseNodeStatusOptions(args: string[]): NodeStatusOptions { return { nodeId, targetId, full, raw, output }; } -function renderNodeStatus(result: Record, full: boolean): RenderedCliResult { +export function renderNodeStatus(result: Record, full: boolean): RenderedCliResult { const target = record(result.target); const summary = record(result.summary); const consumers = arrayRecords(result.consumers); @@ -97,13 +97,16 @@ function renderNodeStatus(result: Record, full: boolean): Rende stringValue(row.latestPipelineRun), short(stringValue(row.digest), 18), stringValue(row.imageStatus), + registryText(record(record(row.diagnostics).registry)), compactLine(stringValue(record(row.diagnostics).hint)), ]); const lines = [ "CI/CD NODE STATUS", - ...table(["NODE", "TARGET", "READY", "READY_COUNT", "BLOCKED", "ELAPSED_MS"], [[ + ...table(["NODE", "TARGET", "STATUS", "CODE", "READY", "READY_COUNT", "BLOCKED", "ELAPSED_MS"], [[ stringValue(result.node), stringValue(target.id), + stringValue(summary.status), + stringValue(summary.code), boolText(summary.ready), `${stringValue(summary.readyCount)}/${stringValue(summary.total)}`, stringValue(summary.blockedCount), @@ -115,7 +118,7 @@ function renderNodeStatus(result: Record, full: boolean): Rende "", ...(full ? [ "DETAIL", - ...(detailRows.length === 0 ? ["-"] : table(["CONSUMER", "PIPELINERUN", "DIGEST", "IMAGE_STATUS", "HINT"], detailRows)), + ...(detailRows.length === 0 ? ["-"] : table(["CONSUMER", "PIPELINERUN", "DIGEST", "IMAGE_STATUS", "REGISTRY", "HINT"], detailRows)), "", ] : []), "NEXT", @@ -126,6 +129,11 @@ function renderNodeStatus(result: Record, full: boolean): Rende return { ok: result.ok !== false, command: "cicd status", renderedText: lines.join("\n"), contentType: "text/plain" }; } +function registryText(registry: Record): string { + const status = stringValue(registry.status); + return status === "not-configured" ? "N/A" : status; +} + function isHelpToken(value: string | undefined): boolean { return value === "help" || value === "--help" || value === "-h"; } diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index ab7c46c1..2f79b8b7 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -347,7 +347,7 @@ test("AgentRun rendered delivery plan and real TaskRun terminals close the statu test("provenance fixtures fail closed for pre-bootstrap, forged marker, wrong SA, and policy mismatch", () => { const result = evaluator.runPacStatusFixtureChecks(); expect(result.ok).toBe(true); - for (const id of ["admission-provenance-verified", "pre-bootstrap-run-fails-closed", "forged-name-candidate-fails-closed", "forged-label-candidate-fails-closed", "forged-pipeline-ref-candidate-fails-closed", "wrong-service-account-fails-closed", "policy-identity-mismatch-fails-closed"]) { + for (const id of ["gitops-only-registry-not-configured", "image-registry-missing-fails-closed", "admission-provenance-verified", "pre-bootstrap-run-fails-closed", "forged-name-candidate-fails-closed", "forged-label-candidate-fails-closed", "forged-pipeline-ref-candidate-fails-closed", "wrong-service-account-fails-closed", "policy-identity-mismatch-fails-closed"]) { expect(result.checks.find((item) => item.id === id)?.ok).toBe(true); } }); diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 6c82d3af..758a192f 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -1242,8 +1242,9 @@ const runtimeSourceCommit = artifact.runtimeSourceCommit || (noRuntimeChange ? n 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') : ''; -const probeBase = typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : ''; +const registryConfigured = process.env.UNIDESK_PAC_REGISTRY_APPLICABILITY === 'configured'; +const imageRepository = registryConfigured && typeof param('image_repository') === 'string' ? param('image_repository') : ''; +const probeBase = registryConfigured && typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : ''; let registryUrl = ''; if (imageRepository && tag) { const firstSlash = imageRepository.indexOf('/'); @@ -1352,6 +1353,7 @@ 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, + registryApplicability: process.env.UNIDESK_PAC_REGISTRY_APPLICABILITY || 'not-configured', registryProbeBase: process.env.UNIDESK_PAC_DIAG_REGISTRY_PROBE_BASE || null, imageTag: process.env.UNIDESK_PAC_DIAG_IMAGE_TAG || null, registryUrl: process.env.UNIDESK_PAC_DIAG_REGISTRY_URL || null, diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index be5e7a9d..2ab47c3a 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -1015,9 +1015,6 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC const target = resolveTarget(pac, options.targetId ?? options.nodeId); const nodeId = options.nodeId ?? target.id; const consumers = pac.consumers.filter((consumer) => consumer.node.toLowerCase() === nodeId.toLowerCase()); - if (consumers.length === 0) { - throw new Error(`no Pipelines-as-Code consumers are configured for node ${nodeId}; known nodes: ${Array.from(new Set(pac.consumers.map((item) => item.node))).sort().join(", ")}`); - } const startedAt = Date.now(); const statuses = await Promise.all(consumers.map(async (consumer) => { const repository = resolveRepository(pac, consumer.repositoryRef); @@ -1047,8 +1044,9 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC } })); const ready = statuses.every((row) => row.ready === true); + const configured = statuses.length > 0; return { - ok: ready, + ok: configured && ready, action: "cicd-node-status", mutation: false, node: nodeId, @@ -1061,7 +1059,10 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC }, consumers: statuses, summary: { - ready, + ready: configured && ready, + status: configured ? (ready ? "ready" : "blocked") : "warning", + code: configured ? (ready ? "cicd-node-ready" : "cicd-node-blocked") : "cicd-node-no-consumers", + hint: configured ? null : `no Pipelines-as-Code consumers are configured for node ${nodeId}`, total: statuses.length, readyCount: statuses.filter((row) => row.ready === true).length, blockedCount: statuses.filter((row) => row.ready !== true).length, @@ -1362,6 +1363,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_WEBHOOK_SECRET_KEY: repository.webhookSecretKey, UNIDESK_PAC_CONCURRENCY_LIMIT: String(repository.concurrencyLimit), UNIDESK_PAC_PARAMS_JSON: JSON.stringify(repository.params), + UNIDESK_PAC_REGISTRY_APPLICABILITY: registryApplicability(consumer, repository), UNIDESK_PAC_PIPELINE_NAME: consumer.pipeline, UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix, UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"), @@ -1395,6 +1397,14 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`; } +function registryApplicability(consumer: PacConsumer, repository: PacRepository): "configured" | "not-configured" { + const prefix = consumer.id === "unidesk-host" ? "unidesk_host_" : ""; + const imageRepository = repository.params[`${prefix}image_repository`] ?? repository.params.image_repository; + if (typeof imageRepository !== "string" || imageRepository.length === 0) return "not-configured"; + if (prefix.length > 0) return "configured"; + return repository.params.pipeline_name === consumer.pipeline ? "configured" : "not-configured"; +} + function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record { const repository = resolveRepository(pac, consumer.repositoryRef); const admissionIdentity = pacAdmissionDesiredIdentity(consumer.node); @@ -1680,6 +1690,7 @@ function nodeStatusRow(targetId: string, consumer: PacConsumer, repository: PacR code: stringValue(diagnostics.code), phase: stringValue(diagnostics.phase), hint: compactLine(stringValue(diagnostics.hint)), + registry: record(diagnostics.registry), }, reason: ready ? "ready" : nodeStatusReason({ ciReady, argoReady, runtimeReady, diagnosticsReady, pipelineStatus: statusText(latest), diagnostics }), statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumer.id}`, @@ -2779,6 +2790,7 @@ function envReuseText(envReuse: Record): string { function registryText(registry: Record): string { if (Object.keys(registry).length === 0) return "-"; + if (registry.status === "not-configured" || registry.applicability === "not-configured") return "N/A"; const present = registry.present === true ? "present" : "missing"; const digest = short(stringValue(registry.digest), 18); return digest === "-" ? present : `${present}:${digest}`;