Merge pull request #2699 from pikasTech/fix/pac-delivery-observation-mode
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

修复 PaC 配置型 consumer 的发布观测误阻塞
This commit is contained in:
Lyon
2026-07-21 01:50:42 +08:00
committed by GitHub
6 changed files with 187 additions and 10 deletions
+5 -1
View File
@@ -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 的首次引导:
@@ -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
@@ -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`;自动生成文件、纯配置、锁文件和无法承载注释头的二进制产物可例外。
@@ -12,7 +12,15 @@ const commit = "b".repeat(40);
function fixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
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<string, unknown>).applicable, false);
assert.equal((result.runtime as Record<string, unknown>).ready, null);
assert.deepEqual(result.firstBreak, null);
assert.deepEqual((result.warnings as Record<string, unknown>[])[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<string, unknown>).evidenceMode, "required");
assert.equal((result.warnings as Record<string, unknown>[])[0]?.code, "runtime-evidence-mode-defaulted");
assert.equal((result.warnings as Record<string, unknown>[])[0]?.blocking, false);
});
@@ -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<string, unknown>): 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<string, unknown>[];
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",
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unkno
pipelineRunPrefix: consumer.pipelineRunPrefix,
argoNamespace: consumer.argoNamespace,
argoApplication: consumer.argoApplication,
deliveryObservation: consumer.deliveryObservation,
repositoryRef: consumer.repositoryRef,
params: Object.keys(consumer.params).sort(),
runnerServiceAccount: consumer.runnerServiceAccount,
@@ -2652,6 +2706,7 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
pipelineRunPrefix: consumer.pipelineRunPrefix,
argoNamespace: consumer.argoNamespace,
argoApplication: consumer.argoApplication,
deliveryObservation: consumer.deliveryObservation,
repositoryRef: consumer.repositoryRef,
params: consumer.params,
runnerServiceAccount: consumer.runnerServiceAccount,
@@ -2667,6 +2722,7 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
function compactStatusJson(result: Record<string, unknown>): Record<string, unknown> {
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<string, unknown>): Record<string, unkn
namespace: consumer.namespace,
pipeline: consumer.pipeline,
argoApplication: consumer.argoApplication,
deliveryObservation,
},
deliveryAuthority: result.deliveryAuthority,
warnings: result.warnings,
@@ -2919,6 +2976,7 @@ function renderPlan(result: Record<string, unknown>): 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<string, unknown>): 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",