diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 10e62b95..dcc8d58b 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -113,7 +113,11 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - 禁止在入口内部调用完整 `status`、同步读取 TaskRun/log、执行 Git fetch 或拼接第二个 history 状态源; - 任一批量读取失败时必须保留 typed `readErrors` 和最后成功阶段,不得提高 YAML timeout 或把读取失败投影为对象缺失; - 固定 `remoteCaptureCount=1`、`mutation=false`,不得再手工串联 `status`、`history`、`delivery-timing` 或裸 Kubernetes 对象查询; - - `complete` 表示 PipelineRun、exact-source runtime 与 Argo 全部就绪;`partial` 保留已成立的 exact-source 证据并明确 Argo 或 runtime 首断点;`blocked` 表示目标读取失败、source 不匹配、PipelineRun 未成功或当前 runtime 未观察到该 source; + - runtime 证据是否适用只读 owning YAML 的 `consumers[].deliveryObservation.runtimeEvidence`: + - `required` 要求 exact-source workload 存在且 ready; + - `not-applicable` 用于只有配置/控制面交付、没有独立 workload 的 consumer,缺少 workload 不得阻塞; + - 字段缺失时按非核心配置回退到 `required`,并输出配置路径、回退值和 `blocking=false` warning; + - `complete` 表示 PipelineRun、适用的 runtime 证据与 Argo 全部就绪;`partial` 保留已成立的证据并明确 Argo 或适用 runtime 首断点;`blocked` 表示目标读取失败、source 不匹配、PipelineRun 未成功或必需 runtime 未观察到该 source; - TaskRun artifact 日志默认延后时只披露 `artifactEvidence.deferred=true`,不放宽既有 status evaluator,也不伪造 provenance。 - 新增 PaC consumer 的首次引导: diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 5a499d56..5181b8d9 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -14,6 +14,7 @@ metadata: - 1769 - 1802 - 1811 + - 2165 - 2274 - 2628 defaults: @@ -441,6 +442,8 @@ consumers: pipelineRunPrefix: unidesk-host- argoNamespace: argocd argoApplication: unidesk-host + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: true closeoutGitOpsMirrorLane: v03 argoBootstrap: @@ -459,6 +462,8 @@ consumers: pipelineRunPrefix: platform-infra-gitea-nc01- argoNamespace: argocd argoApplication: platform-infra-gitea-nc01 + deliveryObservation: + runtimeEvidence: not-applicable closeoutGitOpsMirrorFlush: false - id: platform-infra-sub2rank-nc01 repositoryRef: sub2rank-nc01 @@ -469,6 +474,8 @@ consumers: pipelineRunPrefix: sub2rank-nc01 argoNamespace: argocd argoApplication: sub2rank-nc01 + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: true closeoutGitOpsMirrorLane: v03 argoBootstrap: @@ -508,6 +515,8 @@ consumers: pipelineRunPrefix: sub2rank-development-nc01 argoNamespace: argocd argoApplication: sub2rank-development-nc01 + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: false argoBootstrap: project: default @@ -546,6 +555,8 @@ consumers: pipelineRunPrefix: hwpod-home-nc01 argoNamespace: argocd argoApplication: hwpod-home-nc01 + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: true closeoutGitOpsMirrorLane: v03 argoBootstrap: @@ -634,6 +645,8 @@ consumers: pipelineRunPrefix: selfmedia-nc01 argoNamespace: argocd argoApplication: selfmedia-nc01 + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: false argoBootstrap: project: default @@ -675,6 +688,8 @@ consumers: pipelineRunPrefix: selfmedia-production-nc01 argoNamespace: argocd argoApplication: selfmedia-production-nc01 + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: false params: source_branch: release @@ -729,6 +744,8 @@ consumers: pipelineRunPrefix: pikaoa-dev-nc01 argoNamespace: argocd argoApplication: pikaoa-dev-nc01 + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: false argoBootstrap: project: default @@ -770,6 +787,8 @@ consumers: pipelineRunPrefix: pikaoa-nc01 argoNamespace: argocd argoApplication: pikaoa-nc01 + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: false params: source_branch: release @@ -911,6 +930,8 @@ templates: pipelineRunPrefix: "agentrun-${nodeLower}-v02-ci" argoNamespace: argocd argoApplication: "agentrun-${nodeLower}-v02" + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: false agentrunRelease: id: "agentrun-${nodeLower}-release" @@ -922,6 +943,8 @@ templates: pipelineRunPrefix: "agentrun-${nodeLower}-release-ci" argoNamespace: argocd argoApplication: "agentrun-${nodeLower}-release" + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: false params: git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git @@ -944,6 +967,8 @@ templates: pipelineRunPrefix: "hwlab-web-probe-sentinel-${nodeLower}" argoNamespace: argocd argoApplication: "hwlab-web-probe-sentinel-${nodeLower}" + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: true closeoutGitOpsMirrorLane: v03 hwlabV03: @@ -956,4 +981,6 @@ templates: pipelineRunPrefix: "hwlab-${nodeLower}-v03-ci-poll" argoNamespace: argocd argoApplication: hwlab-node-v03 + deliveryObservation: + runtimeEvidence: required closeoutGitOpsMirrorFlush: true diff --git a/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md b/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md index 552a204b..afb145fb 100644 --- a/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md +++ b/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md @@ -275,6 +275,21 @@ preflight、CI gate 或人工确认门禁。 - 禁止在 PipelineRun、Pod 或 Job 启动后临时安装 `kubectl`; - 该默认能力只属于 CI tools image,不得隐式扩大为产品业务镜像或长期 controller 的运行时依赖。 +### 6.10 OPS-TARGET-REQ-010 发布观测证据适用性 + +| 编号 | 短名 | 主责模块 | 关联模块 | +| --- | --- | --- | --- | +| OPS-TARGET-REQ-010 | 观测证据适用性 | PJ2026-01060308 YAML目标治理 | [发布流水](PJ2026-010601-controlled-release.md)、[运维监控](PJ2026-010605-observability-monitoring.md) | + +PaC consumer 必须通过 owning YAML 的 +`deliveryObservation.runtimeEvidence` 声明发布观测是否需要 workload 证据。 + +- `required` consumer 必须观察到与 source commit 精确匹配且 ready 的 workload; +- `not-applicable` 只适用于配置或控制面交付,没有独立业务 workload 的 consumer; +- `not-applicable` consumer 仍必须满足 source commit 匹配、PipelineRun 成功和 Argo `Synced/Healthy`; +- 非核心字段缺失时可以回退到 `required`,但必须输出配置路径、回退值、原因和 `blocking=false` warning; +- 观测模式不得改变 PipelineRun、GitOps、Argo 或 runtime authority,也不得新增第二状态源。 + ## 7. 过程控制 本规格的执行 issue 为 [#911](https://github.com/pikasTech/unidesk/issues/911)。源码文件头部应标注 `SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets`;自动生成文件、纯配置、锁文件和无法承载注释头的二进制产物可例外。 diff --git a/scripts/src/platform-infra-pac-delivery-observe.test.ts b/scripts/src/platform-infra-pac-delivery-observe.test.ts index 5f3cebf4..caa4c95c 100644 --- a/scripts/src/platform-infra-pac-delivery-observe.test.ts +++ b/scripts/src/platform-infra-pac-delivery-observe.test.ts @@ -12,7 +12,15 @@ const commit = "b".repeat(40); function fixture(overrides: Record = {}): Record { return { target: { id: "NC01" }, - consumer: { id: "fixture" }, + consumer: { + id: "fixture", + deliveryObservation: { + runtimeEvidence: "required", + configPath: "config/platform-infra/pipelines-as-code.yaml#consumers.fixture.deliveryObservation.runtimeEvidence", + defaulted: false, + fallbackReason: null, + }, + }, observation: { ok: true }, summary: { latestPipelineRun: { name: "fixture-run", status: "True", reason: "Succeeded", sourceCommit: commit }, @@ -140,3 +148,38 @@ test("delivery observe preserves a typed snapshot read failure", () => { }); assert.equal((result.readErrors as unknown[]).length, 1); }); + +test("delivery observe completes a configuration-only consumer without runtime workloads", () => { + const status = fixture({ runtime: { workloads: [] } }); + status.consumer = { + id: "fixture", + deliveryObservation: { + runtimeEvidence: "not-applicable", + configPath: "config/platform-infra/pipelines-as-code.yaml#consumers.fixture.deliveryObservation.runtimeEvidence", + defaulted: false, + fallbackReason: null, + }, + }; + const result = projectPacDeliveryObserve({ status, sourceCommit: commit }); + assert.equal(result.status, "complete"); + assert.equal((result.runtime as Record).applicable, false); + assert.equal((result.runtime as Record).ready, null); + assert.deepEqual(result.firstBreak, null); + assert.deepEqual((result.warnings as Record[])[0], { + code: "runtime-evidence-not-applicable", + phase: "runtime", + blocking: false, + mode: "not-applicable", + configPath: "config/platform-infra/pipelines-as-code.yaml#consumers.fixture.deliveryObservation.runtimeEvidence", + }); +}); + +test("delivery observe defaults missing non-core runtime evidence mode without blocking", () => { + const status = fixture(); + status.consumer = { id: "fixture" }; + const result = projectPacDeliveryObserve({ status, sourceCommit: commit }); + assert.equal(result.status, "complete"); + assert.equal((result.runtime as Record).evidenceMode, "required"); + assert.equal((result.warnings as Record[])[0]?.code, "runtime-evidence-mode-defaulted"); + assert.equal((result.warnings as Record[])[0]?.blocking, false); +}); diff --git a/scripts/src/platform-infra-pac-delivery-observe.ts b/scripts/src/platform-infra-pac-delivery-observe.ts index f2bc2ab0..e45d4d1d 100644 --- a/scripts/src/platform-infra-pac-delivery-observe.ts +++ b/scripts/src/platform-infra-pac-delivery-observe.ts @@ -48,6 +48,12 @@ export function projectPacDeliveryObserve(input: PacDeliveryObserveInput): Recor const runtime = record(summary.runtime); const workloads = arrayRecords(runtime.workloads); const matchingWorkloads = workloads.filter((item) => item.sourceCommit === input.sourceCommit); + const deliveryObservation = record(record(input.status.consumer).deliveryObservation); + const configuredRuntimeEvidence = stringOrNull(deliveryObservation.runtimeEvidence); + const runtimeEvidence = configuredRuntimeEvidence === "not-applicable" ? "not-applicable" : "required"; + const runtimeEvidenceRequired = runtimeEvidence === "required"; + const observationConfigPath = stringOrNull(deliveryObservation.configPath); + const observationModeDefaulted = deliveryObservation.defaulted === true || configuredRuntimeEvidence === null; const pipelineSourceCommit = stringOrNull(pipeline.sourceCommit); const sourceMatched = pipelineSourceCommit === input.sourceCommit; const pipelineSucceeded = pipeline.status === "True"; @@ -55,15 +61,16 @@ export function projectPacDeliveryObserve(input: PacDeliveryObserveInput): Recor const argoReady = argo.sync === "Synced" && argo.health === "Healthy"; const observationReady = record(input.status.observation).ok === true; const readErrors = arrayRecords(input.status.readErrors); - const state = !observationReady || !sourceMatched || !pipelineSucceeded || matchingWorkloads.length === 0 + const state = !observationReady || !sourceMatched || !pipelineSucceeded || (runtimeEvidenceRequired && matchingWorkloads.length === 0) ? "blocked" - : runtimeReady && argoReady + : (!runtimeEvidenceRequired || runtimeReady) && argoReady ? "complete" : "partial"; const firstBreak = firstBreakFor({ observationReady, sourceMatched, pipelineSucceeded, + runtimeEvidenceRequired, matchingWorkloads, runtimeReady, argoReady, @@ -72,6 +79,24 @@ export function projectPacDeliveryObserve(input: PacDeliveryObserveInput): Recor }); const artifactMode = record(record(summary.artifact).collector).mode ?? null; + const warnings = runtimeEvidence === "not-applicable" + ? [{ + code: "runtime-evidence-not-applicable", + phase: "runtime", + blocking: false, + mode: runtimeEvidence, + configPath: observationConfigPath, + }] + : !observationModeDefaulted + ? [] + : [{ + code: "runtime-evidence-mode-defaulted", + phase: "runtime", + blocking: false, + mode: runtimeEvidence, + configPath: observationConfigPath, + reason: stringOrNull(deliveryObservation.fallbackReason), + }]; return { ok: state !== "blocked", status: state, @@ -101,9 +126,11 @@ export function projectPacDeliveryObserve(input: PacDeliveryObserveInput): Recor firstCondition: argo.firstCondition ?? null, }, runtime: { + evidenceMode: runtimeEvidence, + applicable: runtimeEvidenceRequired, workloadCount: workloads.length, matchingWorkloadCount: matchingWorkloads.length, - ready: runtimeReady, + ready: runtimeEvidenceRequired ? runtimeReady : null, workloads: matchingWorkloads.map((item) => ({ deployment: item.deployment ?? null, namespace: item.namespace ?? null, @@ -118,6 +145,7 @@ export function projectPacDeliveryObserve(input: PacDeliveryObserveInput): Recor deferred: artifactMode === "taskrun-status-only" || artifactMode === "deferred-single-snapshot", }, firstBreak, + warnings, valuesPrinted: false, }; } @@ -136,7 +164,7 @@ export function renderPacDeliveryObserve(result: Record): strin `SOURCE expected=${short(text(result.sourceCommit))} observed=${short(text(pipeline.sourceCommit))} matched=${String(pipeline.sourceMatched === true)}`, `PIPELINE id=${text(pipeline.id)} status=${text(pipeline.status)} reason=${text(pipeline.reason)}`, `ARGO sync=${text(argo.sync)} health=${text(argo.health)} revision=${short(text(argo.revision))}`, - `RUNTIME matching=${text(runtime.matchingWorkloadCount)}/${text(runtime.workloadCount)} ready=${String(runtime.ready === true)}`, + `RUNTIME mode=${text(runtime.evidenceMode)} matching=${text(runtime.matchingWorkloadCount)}/${text(runtime.workloadCount)} ready=${runtime.applicable === true ? String(runtime.ready === true) : "n/a"}`, ...workloads.map((item) => ` ${text(item.namespace)}/${text(item.deployment)} ${text(item.readyReplicas)}/${text(item.replicas)} digest=${short(text(item.digest), 24)}`), `FIRST_BREAK ${Object.keys(firstBreak).length === 0 ? "-" : `${text(firstBreak.code)} phase=${text(firstBreak.phase)}`}`, "REMOTE_CAPTURE_COUNT 1", @@ -147,6 +175,7 @@ function firstBreakFor(input: { observationReady: boolean; sourceMatched: boolean; pipelineSucceeded: boolean; + runtimeEvidenceRequired: boolean; matchingWorkloads: readonly Record[]; runtimeReady: boolean; argoReady: boolean; @@ -165,8 +194,8 @@ function firstBreakFor(input: { } if (!input.sourceMatched) return { code: "pipeline-source-commit-mismatch", phase: "pipeline" }; if (!input.pipelineSucceeded) return { code: "pipeline-not-succeeded", phase: "pipeline" }; - if (input.matchingWorkloads.length === 0) return { code: "runtime-source-commit-not-observed", phase: "runtime" }; - if (!input.runtimeReady) return { code: "runtime-workload-not-ready", phase: "runtime" }; + if (input.runtimeEvidenceRequired && input.matchingWorkloads.length === 0) return { code: "runtime-source-commit-not-observed", phase: "runtime" }; + if (input.runtimeEvidenceRequired && !input.runtimeReady) return { code: "runtime-workload-not-ready", phase: "runtime" }; if (!input.argoReady) { return { code: "argo-not-ready", diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index b25aa5a3..c58365f7 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -186,12 +186,14 @@ export interface PacConfigValidationWarning { | "pac-unselected-consumer-parse-invalid" | "pac-unselected-consumer-invalid" | "pac-unselected-repository-parse-invalid" - | "pac-unselected-repository-invalid"; + | "pac-unselected-repository-invalid" + | "pac-consumer-delivery-observation-defaulted"; readonly object: { readonly kind: "consumer" | "repository"; readonly id: string }; readonly consumer: string | null; readonly configPath: string; readonly blocking: false; readonly evidence: string; + readonly fallback?: { readonly value: string; readonly reason: string }; } interface PacRepository { @@ -219,6 +221,12 @@ interface PacConsumer { pipelineRunPrefix: string; argoNamespace: string; argoApplication: string; + deliveryObservation: { + runtimeEvidence: "required" | "not-applicable"; + configPath: string; + defaulted: boolean; + fallbackReason: string | null; + }; argoBootstrap: { project: string; repoUrl: string; @@ -819,7 +827,22 @@ function parsePacConsumers( const id = rawPacObjectId(entry.value, entry.path, "consumer"); try { if (!isRecord(entry.value)) throw new Error(`${configLabel}.${entry.path} must be an object`); - values.push(parseConsumer(entry.value, entry.path, defaultRepositoryRef)); + const consumer = parseConsumer(entry.value, entry.path, defaultRepositoryRef); + values.push(consumer); + if (consumer.deliveryObservation.defaulted) { + warnings.push({ + code: "pac-consumer-delivery-observation-defaulted", + object: { kind: "consumer", id: consumer.id }, + consumer: consumer.id, + configPath: consumer.deliveryObservation.configPath, + blocking: false, + evidence: pacValueEvidence(consumer.deliveryObservation.fallbackReason ?? "deliveryObservation.runtimeEvidence defaulted"), + fallback: { + value: consumer.deliveryObservation.runtimeEvidence, + reason: consumer.deliveryObservation.fallbackReason ?? "non-core delivery observation mode is not configured", + }, + }); + } } catch (error) { if (selectedConsumerIds.has(id.toLowerCase())) throw error; warnings.push(pacConfigWarning("pac-unselected-consumer-parse-invalid", "consumer", id, entry.path, error)); @@ -869,6 +892,7 @@ function parseConsumer(consumer: Record, path: string, defaultR const argoBootstrap = consumer.argoBootstrap === undefined ? null : y.objectField(consumer, "argoBootstrap", path); const deliveryProvenance = consumer.deliveryProvenance === undefined ? null : parseConsumerDeliveryProvenance(y.objectField(consumer, "deliveryProvenance", path), `${path}.deliveryProvenance`); const runnerServiceAccount = consumer.runnerServiceAccount === undefined ? null : y.objectField(consumer, "runnerServiceAccount", path); + const deliveryObservation = parseConsumerDeliveryObservation(consumer.deliveryObservation, path); return { id, node: y.stringField(consumer, "node", path), @@ -878,6 +902,7 @@ function parseConsumer(consumer: Record, path: string, defaultR pipelineRunPrefix: y.stringField(consumer, "pipelineRunPrefix", path), argoNamespace: y.kubernetesNameField(consumer, "argoNamespace", path), argoApplication: y.kubernetesNameField(consumer, "argoApplication", path), + deliveryObservation, argoBootstrap: argoBootstrap === null ? null : { project: y.kubernetesNameField(argoBootstrap, "project", `${path}.argoBootstrap`), repoUrl: urlField(argoBootstrap, "repoUrl", `${path}.argoBootstrap`), @@ -907,6 +932,34 @@ function parseConsumer(consumer: Record, path: string, defaultR }; } +function parseConsumerDeliveryObservation(value: unknown, path: string): PacConsumer["deliveryObservation"] { + const configPath = `${configLabel}#${path}.deliveryObservation.runtimeEvidence`; + if (!isRecord(value)) { + return { + runtimeEvidence: "required", + configPath, + defaulted: true, + fallbackReason: value === undefined + ? "deliveryObservation.runtimeEvidence is missing" + : "deliveryObservation must be an object", + }; + } + if (value.runtimeEvidence !== "required" && value.runtimeEvidence !== "not-applicable") { + return { + runtimeEvidence: "required", + configPath, + defaulted: true, + fallbackReason: "deliveryObservation.runtimeEvidence must be required or not-applicable", + }; + } + return { + runtimeEvidence: value.runtimeEvidence, + configPath, + defaulted: false, + fallbackReason: null, + }; +} + function parseFalse(record: Record, key: string, path: string): false { if (y.booleanField(record, key, path) !== false) throw new Error(`${path}.${key} must be false`); return false; @@ -2594,6 +2647,7 @@ function consumerObservationSummary(consumer: PacConsumer): Record): Record): Record): Record { const consumer = record(result.consumer); + const deliveryObservation = record(consumer.deliveryObservation); return { ok: result.ok === true, action: result.action, @@ -2679,6 +2735,7 @@ function compactStatusJson(result: Record): Record): RenderedCliResult { const config = record(result.config); const repository = record(result.repository); const consumer = record(result.consumer); + const deliveryObservation = record(consumer.deliveryObservation); const internalPublish = record(record(config.capabilities).sentinelInternalPublish); const secrets = arrayRecords(result.secrets); const policy = arrayRecords(result.policy); @@ -2933,6 +2991,7 @@ function renderPlan(result: Record): RenderedCliResult { ["trigger", "Pipelines-as-Code", "Gitea push webhook"], ["ci", "Tekton", stringValue(consumer.pipeline)], ["cd", "Argo", stringValue(consumer.argoApplication)], + ["runtime evidence", "owning YAML", `${stringValue(deliveryObservation.runtimeEvidence)} (${stringValue(deliveryObservation.configPath)})`], ]), "", "SECRETS",