From 1f3283ffac2632fba14581bccd95333d463a1f8a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:40:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(cicd):=20=E5=9B=9E=E9=80=80=20PaC=20admissi?= =?UTF-8?q?on=20=E4=B8=9A=E5=8A=A1=E9=98=BB=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/pipelines-as-code.yaml | 5 +- .../R4.1.1_Task_Report.md | 81 +++++++++++++++++++ scripts/native/cicd/pac-status-evaluator.cjs | 30 ++++--- .../src/platform-infra-pac-provenance.test.ts | 37 +++++---- scripts/src/platform-infra-pac-provenance.ts | 12 +-- ...-infra-pipelines-as-code-bootstrap.test.ts | 29 +++++++ ...tform-infra-pipelines-as-code-bootstrap.ts | 15 +++- ...platform-infra-pipelines-as-code-remote.sh | 49 +++++------ 8 files changed, 193 insertions(+), 65 deletions(-) create mode 100644 docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 8436c436..f323004b 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -65,9 +65,10 @@ deliveryProvenance: admission: policyName: unidesk-pac-delivery-provenance-v2 bindingName: unidesk-pac-delivery-provenance-v2 - failurePolicy: Fail + failurePolicy: Ignore validationActions: - - Deny + - Warn + - Audit capabilities: sentinelInternalPublish: enabled: false diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md new file mode 100644 index 00000000..fcc3135b --- /dev/null +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R4.1.1_Task_Report.md @@ -0,0 +1,81 @@ +# R4.1.1 任务报告 + +## 状态 + +- MDTODO:`R4.1.1` +- GitHub issue:`pikasTech/unidesk#2039` +- 状态:`in_progress` +- 原因:源码修复与定向验证已完成,尚未合并 PR,也未执行合并后的 NC01 原入口运行面验收。 + +## 问题事实 + +- PR #1808 引入同名 `ValidatingAdmissionPolicy` 与 binding: + - 使用 `failurePolicy: Fail`; + - 使用 `validationActions: [Deny]`; + - 阻断不满足 provenance CEL 的 PipelineRun 创建或更新。 +- PR #1812 继续收紧 watcher 的 queue transition: + - 假定同一次 UPDATE 完成 `spec.status: PipelineRunPending -> absent`; + - 同时完成 `state: queued -> started`。 +- NC01 官方 `pipelines-as-code-watcher` 的真实更新不满足该原子假定: + - live `unidesk-pac-delivery-provenance-v2` 拒绝 watcher; + - HWLAB `df925768` 与 AgentRun 自动队列停在 Pending。 + +## 本次语义回退 + +- owning YAML 将同名 v2 policy 改为 Kubernetes 原生 warning-only: + - `failurePolicy: Ignore`; + - `validationActions: [Warn, Audit]`。 +- 保留现有观察项的计算和披露: + - CEL、marker、creator 与 ServiceAccount; + - queue transition、spec/config SHA、版本与 resource epoch; + - 默认 RBAC。 +- 上述观察项不再作为以下入口的阻断条件: + - 业务 admission 与 bootstrap; + - status、history 与 debug; + - 外层 PaC delivery eligibility。 +- admission 与 consumer bootstrap 漂移统一输出结构化字段: + - `blocking: false`; + - `warning: true|false`; + - `warnings` / `reasons`。 +- PaC bootstrap 的 `pac-admission` 阶段在 live identity 或 generation 尚未精确收敛时显示 `state: warning`,整体结果仍可成功。 +- 外层 GitHub/Gitea PaC push event 继续作为: + - 唯一流水线触发; + - 唯一 delivery authority。 +- admission exact identity 仅决定 `admissionProvenanceVerified` 与 warning,不再让已观察到的 outer event 失去 eligibility。 + +## 明确保留 + +- PR #1808 的 source artifact、三阶段 terminal、GitOps/status/history/debug 可观测性与无关后续改动。 +- YAML-owned policy/binding 名称 `unidesk-pac-delivery-provenance-v2`,以便受控 bootstrap 通过 server-side apply 原位收敛现有 live v2。 +- provenance、creator、marker、ServiceAccount、spec/config SHA、版本、epoch、RBAC 与 drift 的只读计算和结构化披露。 +- GitHub PR merge 继续作为唯一正式交付触发。 +- 未新增以下入口或机制: + - PipelineRun、mirror、trigger、sync、flush、Argo sync 或 runtime patch; + - 数据库迁移、合同、锁、租约、第二 authority 或 fallback。 + +## 定向验证 + +- `sh -n scripts/src/platform-infra-pipelines-as-code-remote.sh` +- `bun test scripts/src/platform-infra-pac-provenance.test.ts scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts` + - 结果:`23 pass, 0 fail, 234 expect()`。 +- `node` 调用 `runPacStatusFixtureChecks()`: + - 结果:`ok=true, checks=34`。 +- `bun scripts/cli.ts platform-infra pipelines-as-code plan --target NC01` + - 结果:YAML source-of-truth、single-path、Gitea source 与 runtime-zero-docker 策略均为 `true`。 +- `git diff --check` + - 结果:通过。 + +## 合并后受控收敛 + +- 主代理在 PR 合并后先执行只读计划: + - `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target NC01 --consumer agentrun-nc01-v02 --dry-run` +- 确认计划只体现 owning YAML 的同名 v2 warning-only 收敛后,再执行: + - `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target NC01 --consumer agentrun-nc01-v02 --confirm` +- 不需要 prune:policy 与 binding 名称保持不变,bootstrap 使用 server-side apply 原位把 live `Fail/Deny` 更新为 `Ignore` 与 `Warn/Audit`。 +- bootstrap 不是流水线补跑入口;禁止随后人工执行 PipelineRun、mirror、trigger、sync、flush、Argo sync 或 runtime patch。 +- 运行面验收由主代理观察既有 merge 事件自动恢复,并通过原 `status`、`history` 与受影响 HWLAB/AgentRun 原入口完成。 + +## 当前阻塞 + +- 无源码或定向验证阻塞。 +- 运行面验收仍等待 PR 合并及主代理执行受控 bootstrap,因此任务保持 `in_progress`。 diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index 030760dd..8ff82b34 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -222,9 +222,9 @@ function evaluatePacAdmissionState(inputValue) { if (annotations["unidesk.ai/pac-controller-identity"] !== expected.controllerIdentity) reasons.push(`${kind}-controller-identity-mismatch`); if (annotations["unidesk.ai/effective-config-sha256"] !== expected.configSha256) reasons.push(`${kind}-config-sha256-mismatch`); } - if (policySpec.failurePolicy !== "Fail") reasons.push("policy-failure-policy-not-fail"); + if (policySpec.failurePolicy !== "Ignore") reasons.push("policy-failure-policy-not-ignore"); if (bindingSpec.policyName !== expected.policyName) reasons.push("binding-policy-name-mismatch"); - if (!Array.isArray(bindingSpec.validationActions) || bindingSpec.validationActions.length !== 1 || bindingSpec.validationActions[0] !== "Deny") reasons.push("binding-validation-actions-not-deny"); + if (!Array.isArray(bindingSpec.validationActions) || stableCanonicalJson(bindingSpec.validationActions) !== stableCanonicalJson(["Warn", "Audit"])) reasons.push("binding-validation-actions-not-warning-only"); if (!Number.isInteger(policyMetadata.generation) || !Number.isInteger(policyStatus.observedGeneration) || policyStatus.observedGeneration < policyMetadata.generation) reasons.push("policy-generation-not-observed"); if (warnings.length > 0) reasons.push("policy-expression-warning"); if (roleMetadata.name !== "unidesk-pac-consumer-runner") reasons.push("default-role-missing"); @@ -258,6 +258,9 @@ function evaluatePacAdmissionState(inputValue) { configured: true, required: true, ready: reasons.length === 0, + blocking: false, + warning: reasons.length > 0, + warnings: reasons, source: "live-admissionregistration-v1", policyName: stringOrNull(policyMetadata.name), bindingName: stringOrNull(bindingMetadata.name), @@ -357,13 +360,18 @@ function classifyPacPipelineRun(consumerInput, itemInput) { deliveryClass, reason, candidate, - deliveryAuthorityEligible: outer && (!provenanceRequired || admissionProvenanceVerified), + deliveryAuthorityEligible: outer, deliveryOwner: outer ? admissionProvenanceVerified ? "pac-controller-admission" : "pac-controller-observed" : null, executionOwner: inner ? "tekton-child-unattached" : null, parentPipelineRun: null, parentRelation: inner ? "unproven" : null, metadataObservationOnly: outer && !admissionProvenanceVerified, admissionProvenanceVerified, + admissionWarning: outer && !admissionProvenanceVerified ? { + code: "pac-admission-provenance-unverified", + blocking: false, + reasons: Array.isArray(admissionState.reasons) ? admissionState.reasons : [], + } : null, admissionPolicyReady: admissionState.ready === true, admissionPolicyName: stringOrNull(admissionState.policyName), admissionBindingName: stringOrNull(admissionState.bindingName), @@ -1285,19 +1293,19 @@ function runPacStatusFixtureChecks() { }; const classificationCases = [ { id: "admission-provenance-verified", expected: true, item: verifiedRun, state: admissionState }, - { id: "pre-bootstrap-run-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, creationTimestamp: "2025-12-31T23:59:59Z" } }, state: admissionState }, - { id: "forged-name-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState }, - { id: "forged-label-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", labels: { ...verifiedRun.metadata.labels, "tekton.dev/pipeline": consumer.pipeline }, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState }, - { id: "forged-pipeline-ref-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } }, spec: { ...verifiedRun.spec, pipelineRef: { name: consumer.pipeline } } }, state: admissionState }, - { id: "wrong-service-account-fails-closed", expected: false, item: { ...verifiedRun, spec: { taskRunTemplate: { serviceAccountName: "default" } } }, state: admissionState }, - { id: "policy-identity-mismatch-fails-closed", expected: false, item: verifiedRun, state: { ...admissionState, configSha256: `sha256:${"2".repeat(64)}` } }, + { id: "pre-bootstrap-run-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, creationTimestamp: "2025-12-31T23:59:59Z" } }, state: admissionState }, + { id: "marker-mismatch-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState }, + { id: "candidate-label-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", labels: { ...verifiedRun.metadata.labels, "tekton.dev/pipeline": consumer.pipeline }, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState }, + { id: "candidate-pipeline-ref-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } }, spec: { ...verifiedRun.spec, pipelineRef: { name: consumer.pipeline } } }, state: admissionState }, + { id: "service-account-mismatch-warning-only", expected: false, item: { ...verifiedRun, spec: { taskRunTemplate: { serviceAccountName: "default" } } }, state: admissionState }, + { id: "policy-identity-mismatch-warning-only", expected: false, item: verifiedRun, state: { ...admissionState, configSha256: `sha256:${"2".repeat(64)}` } }, ]; for (const item of classificationCases) { const result = classifyPacPipelineRun({ ...consumer, admissionState: item.state }, item.item); checks.push({ id: item.id, - ok: result.admissionProvenanceVerified === item.expected && result.deliveryAuthorityEligible === item.expected, - expectedOk: item.expected, + ok: result.admissionProvenanceVerified === item.expected && result.deliveryAuthorityEligible === true, + expectedOk: true, actualOk: result.deliveryAuthorityEligible, expectedCode: item.expected ? "admission-owned-pac-push-provenance-verified" : "pac-push-metadata-observed-but-admission-provenance-unverified", actualCode: result.reason, diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index 3199cec4..ffd5f403 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -38,6 +38,11 @@ test("admission contract rejects malformed required declarations instead of sile const duplicatedRequiredConsumers = duplicatedServiceAccount.consumers.filter((item: any) => item.deliveryProvenance !== undefined); duplicatedRequiredConsumers[1].deliveryProvenance.executionServiceAccountName = duplicatedRequiredConsumers[0].deliveryProvenance.executionServiceAccountName; expect(() => parsePacAdmissionContract(duplicatedServiceAccount)).toThrow("executionServiceAccountName must be unique per target"); + + const denyingAdmission = structuredClone(source); + denyingAdmission.deliveryProvenance.admission.failurePolicy = "Fail"; + denyingAdmission.deliveryProvenance.admission.validationActions = ["Deny"]; + expect(() => parsePacAdmissionContract(denyingAdmission)).toThrow("validationActions must be exactly [Warn, Audit]"); }); test("admission queue transition contract rejects absent sources, unknown Tekton states, and duplicate transitions", () => { @@ -55,7 +60,7 @@ test("admission queue transition contract rejects absent sources, unknown Tekton expect(() => parsePacAdmissionContract(duplicate)).toThrow("deliveryProvenance.queueTransition.allowed must be unique"); }); -test("versioned admission desired state protects controller proof, candidate identity, params, and execution SA", () => { +test("versioned admission desired state reports controller proof and candidate drift without denying delivery", () => { const contract = readPacAdmissionContract(); const owningYaml = Bun.YAML.parse(readFileSync(resolve(root, "config/platform-infra/pipelines-as-code.yaml"), "utf8")) as any; expect(owningYaml.deliveryProvenance.terminalRoles).toBeUndefined(); @@ -69,15 +74,15 @@ test("versioned admission desired state protects controller proof, candidate ide toState: "started", }]); expect(contract.child.enabled).toBe(false); - expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01", "selfmedia-nc01"]); + expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01", "selfmedia-nc01", "selfmedia-production-nc01", "pikaoa-test-nc01"]); const items = documents(renderPacAdmissionDesiredFragment("NC01")); expect(items.map((item) => item.kind)).toEqual(["ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]); const policy = items[0]; const expressions = policy.spec.validations.map((item: any) => item.expression).join("\n"); const messages = policy.spec.validations.map((item: any) => item.message).join("\n"); - expect(policy.spec.failurePolicy).toBe("Fail"); + expect(policy.spec.failurePolicy).toBe("Ignore"); expect(policy.spec.matchConstraints.matchPolicy).toBe("Equivalent"); - expect(items[1].spec.validationActions).toEqual(["Deny"]); + expect(items[1].spec.validationActions).toEqual(["Warn", "Audit"]); expect(policy.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("0"); expect(items[1].metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("1"); expect(expressions).toContain("request.userInfo.username"); @@ -134,8 +139,8 @@ test("versioned admission desired state protects controller proof, candidate ide test("required consumer default RBAC has one automatic desired owner per namespace and no Tekton mutation verbs", () => { const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); - expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding"]); - expect(rbac.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci"]); + expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding"]); + expect(rbac.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci", "pikaoa-ci"]); for (const role of rbac.filter((item) => item.kind === "Role")) { expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1"); expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256); @@ -150,6 +155,8 @@ test("required consumer default RBAC has one automatic desired owner per namespa "RoleBinding", "Role", "RoleBinding", + "Role", + "RoleBinding", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", @@ -158,7 +165,7 @@ test("required consumer default RBAC has one automatic desired owner per namespa ]); }); -test("live admission evaluator requires exact desired hashes, observed generation, default-SA loss, and a new resource epoch", () => { +test("live admission evaluator reports exact desired drift as non-blocking warnings", () => { const contract = readPacAdmissionContract(); const admission = documents(renderPacAdmissionDesiredFragment("NC01")); const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); @@ -174,7 +181,7 @@ test("live admission evaluator requires exact desired hashes, observed generatio rbacConfigSha256: rbac[0].metadata.annotations["unidesk.ai/effective-config-sha256"], }; const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, expected }; - expect(evaluator.evaluatePacAdmissionState(input)).toMatchObject({ ready: true, activeAfter: "2026-01-01T00:00:01Z", defaultCanMutate: false }); + expect(evaluator.evaluatePacAdmissionState(input)).toMatchObject({ ready: true, blocking: false, warning: false, activeAfter: "2026-01-01T00:00:01Z", defaultCanMutate: false }); expect(evaluator.evaluatePacAdmissionState({ ...input, policy: { @@ -189,7 +196,7 @@ test("live admission evaluator requires exact desired hashes, observed generatio }, }, }, - })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["policy-expression-warning"]) }); + })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["policy-expression-warning"]) }); const apiDefaultedPolicy = { ...policy, spec: { @@ -206,10 +213,10 @@ test("live admission evaluator requires exact desired hashes, observed generatio ready: true, liveSpecSha256: expected.configSha256, }); - expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: true })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["default-serviceaccount-can-mutate-tekton-runs"]) }); - expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: null })).toMatchObject({ ready: false, defaultCanMutate: null, reasons: expect.arrayContaining(["default-serviceaccount-mutation-probe-unavailable"]) }); - expect(evaluator.evaluatePacAdmissionState({ ...input, role: { ...rbac[0], rules: [rbac[0].rules[0]] } })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["default-role-rules-mismatch"]) }); - expect(evaluator.evaluatePacAdmissionState({ ...input, expected: { ...expected, configSha256: `sha256:${"0".repeat(64)}` } })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["policy-config-sha256-mismatch"]) }); + expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: true })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["default-serviceaccount-can-mutate-tekton-runs"]) }); + expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: null })).toMatchObject({ ready: false, blocking: false, warning: true, defaultCanMutate: null, reasons: expect.arrayContaining(["default-serviceaccount-mutation-probe-unavailable"]) }); + expect(evaluator.evaluatePacAdmissionState({ ...input, role: { ...rbac[0], rules: [rbac[0].rules[0]] } })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["default-role-rules-mismatch"]) }); + expect(evaluator.evaluatePacAdmissionState({ ...input, expected: { ...expected, configSha256: `sha256:${"0".repeat(64)}` } })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["policy-config-sha256-mismatch"]) }); const mutatedPolicy = { ...policy, spec: { @@ -345,10 +352,10 @@ test("AgentRun rendered delivery plan and real TaskRun terminals close the statu })).toMatchObject({ ok: true, code: "pac-ready-gitops-exact" }); }); -test("provenance fixtures fail closed for pre-bootstrap, forged marker, wrong SA, and policy mismatch", () => { +test("provenance fixtures keep outer PaC delivery eligible while reporting admission warnings", () => { const result = evaluator.runPacStatusFixtureChecks(); expect(result.ok).toBe(true); - for (const id of ["gitops-only-registry-not-configured", "image-registry-missing-fails-closed", "equal-revisions-ignore-owning-git-fetch-failure", "different-revisions-fetch-failure-remains-unknown", "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", "equal-revisions-ignore-owning-git-fetch-failure", "different-revisions-fetch-failure-remains-unknown", "admission-provenance-verified", "pre-bootstrap-run-warning-only", "marker-mismatch-warning-only", "candidate-label-warning-only", "candidate-pipeline-ref-warning-only", "service-account-mismatch-warning-only", "policy-identity-mismatch-warning-only"]) { expect(result.checks.find((item) => item.id === id)?.ok).toBe(true); } }); diff --git a/scripts/src/platform-infra-pac-provenance.ts b/scripts/src/platform-infra-pac-provenance.ts index c95f517d..139fe2d8 100644 --- a/scripts/src/platform-infra-pac-provenance.ts +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -33,8 +33,8 @@ export interface PacAdmissionContract { readonly allowedQueueTransitions: readonly PacAdmissionQueueTransitionContract[]; readonly policyName: string; readonly bindingName: string; - readonly failurePolicy: "Fail"; - readonly validationActions: readonly ["Deny"]; + readonly failurePolicy: "Ignore"; + readonly validationActions: readonly ["Warn", "Audit"]; readonly child: { readonly enabled: false; readonly parentNameAnnotation: string; @@ -83,7 +83,9 @@ export function parsePacAdmissionContract(source: Record): PacA const transitionKeys = new Set(allowedQueueTransitions.map((transition) => [transition.fromSpecStatus, transition.toSpecStatus, transition.fromState, transition.toState].join("\u0000"))); if (transitionKeys.size !== allowedQueueTransitions.length) throw new Error("deliveryProvenance.queueTransition.allowed must be unique"); const validationActions = stringArray(admission.validationActions, "deliveryProvenance.admission.validationActions"); - if (validationActions.length !== 1 || validationActions[0] !== "Deny") throw new Error("deliveryProvenance.admission.validationActions must be exactly [Deny]"); + if (validationActions.length !== 2 || validationActions[0] !== "Warn" || validationActions[1] !== "Audit") { + throw new Error("deliveryProvenance.admission.validationActions must be exactly [Warn, Audit]"); + } const consumers = recordArray(root.consumers, "consumers").flatMap((consumer, index) => { if (consumer.deliveryProvenance === undefined) return []; const value = record(consumer.deliveryProvenance, `consumers[${index}].deliveryProvenance`); @@ -127,8 +129,8 @@ export function parsePacAdmissionContract(source: Record): PacA allowedQueueTransitions, policyName, bindingName, - failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Fail"), - validationActions: ["Deny"], + failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Ignore"), + validationActions: ["Warn", "Audit"], child: { enabled: literal(child.enabled, "deliveryProvenance.child.enabled", false), parentNameAnnotation: required(child.parentNameAnnotation, "deliveryProvenance.child.parentNameAnnotation"), diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index bc0a0982..e2f89a7a 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -210,6 +210,35 @@ test("PaC bootstrap projects typed stages and compact JSON", () => { expect(rendered).toContain("confirm:"); }); +test("PaC bootstrap keeps admission drift as a non-blocking warning", () => { + const result = { + ok: true, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: true, + mode: "confirmed", + target: { id: "NC01" }, + consumer: { id: "widgets", node: "NC01", lane: "v01" }, + repository: { id: "widgets", namespace: "ci" }, + mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name }, + githubPreflight: { ok: true, mutation: false, repositories: [{ key: mirror.key, code: "github-upstream-available", ok: true }] }, + giteaBootstrap: { ok: true, mutation: false, mode: "skipped", blockers: [] }, + pipelinesAsCodeApply: { + ok: true, + mutation: true, + mode: "confirmed", + remote: { + ok: true, + admission: { applied: true, ready: false }, + admissionProvenance: { policyName: "unidesk-pac-delivery-provenance-v2", reasons: ["policy-generation-not-observed"] }, + }, + }, + }; + const projection = projectPacBootstrapResult(result); + expect(projection.ok).toBe(true); + expect((projection.stages as Record[]).find((item) => item.id === "pac-admission")).toMatchObject({ ok: true, state: "warning", blocking: false }); + expect(projection.warnings).toEqual([expect.objectContaining({ code: "pac-admission-provenance-not-ready", blocking: false })]); +}); + test("PaC bootstrap returns fix-yaml next for zero YAML matches", () => { const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: no exact match")); expect(projectPacBootstrapResult(result)).toBe(result); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts index 3a8e2b84..62add1aa 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -70,6 +70,7 @@ export function projectPacBootstrapResult(result: Record): Reco const apply = record(result.pipelinesAsCodeApply); const remote = record(apply.remote); const admission = record(remote.admission); + const admissionProvenance = record(remote.admissionProvenance); const blockers = bootstrapBlockers(githubPreflight, gitea, apply, remote); const dryRun = result.mode === "dry-run"; const repositoryMissing = remote.error === "gitea-repository-missing"; @@ -78,6 +79,16 @@ export function projectPacBootstrapResult(result: Record): Reco const giteaSkipped = gitea.mode === "skipped"; const applySkipped = apply.mode === "skipped"; const applyReady = apply.ok === true || (dryRun && repositoryMissing); + const warnings = [ + ...records(result.warnings), + ...(applySkipped || dryRun || repositoryMissing || admission.ready === true ? [] : [{ + object: { kind: "ValidatingAdmissionPolicy", id: stringValue(admissionProvenance.policyName, "PaC admission") }, + blocking: false, + code: "pac-admission-provenance-not-ready", + configPath: "config/platform-infra/pipelines-as-code.yaml#deliveryProvenance.admission", + reasons: Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons : [], + }]), + ]; const next = bootstrapNext(result, blockers); return { ok: blockers.length === 0 && giteaReady && applyReady, @@ -102,12 +113,12 @@ export function projectPacBootstrapResult(result: Record): Reco stages: [ { id: "gitea-init", ok: giteaSkipped ? null : giteaReady, state: giteaSkipped ? "skipped" : giteaReady ? (dryRun ? "planned" : "ready") : "failed", mutation: gitea.mutation === true }, { id: "pac-controller", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, - { id: "pac-admission", ok: applySkipped ? null : dryRun ? true : admission.ready === true, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : dryRun ? "planned" : admission.ready === true ? "ready" : "failed", mutation: admission.applied === true }, + { id: "pac-admission", ok: applySkipped ? null : true, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : dryRun ? "planned" : admission.ready === true ? "ready" : "warning", blocking: false, mutation: admission.applied === true }, { id: "tekton-consumer", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, { id: "gitops-argo", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, ], repository: { id: repository.id, namespace: repository.namespace }, - warnings: result.warnings, + warnings, blockers, next, valuesPrinted: false, diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index e268cdcc..13a6a668 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -363,15 +363,13 @@ apply_action() { hook_id=$(ensure_webhook) ready=false if wait_ready; then ready=true; fi + prepare_admission_provenance_state admission_ready=true if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" = "1" ]; then - admission_ready=false - if wait_admission_ready; then admission_ready=true; fi - else - prepare_admission_provenance_state + admission_ready=$(printf '%s' "$UNIDESK_PAC_ADMISSION_STATE_JSON" | node -e 'let input="";process.stdin.on("data",chunk=>input+=chunk);process.stdin.on("end",()=>process.stdout.write(JSON.parse(input).ready===true?"true":"false"));') fi ok=false - if [ "$ready" = true ] && [ "$admission_ready" = true ]; then ok=true; fi + if [ "$ready" = true ]; then ok=true; fi printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"admission":{"applied":%s,"ready":%s},"admissionProvenance":%s,"argoBootstrap":%s,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ok" "$ready" "$admission_applied" "$admission_ready" "$UNIDESK_PAC_ADMISSION_STATE_JSON" "$argo_bootstrap" "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" "$(json_string "$UNIDESK_PAC_GITEA_REPO")" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")" } @@ -436,19 +434,6 @@ prepare_admission_provenance_state() { export UNIDESK_PAC_ADMISSION_STATE_JSON } -wait_admission_ready() { - timeout="${UNIDESK_PAC_WAIT_TIMEOUT_SECONDS:-55}" - end=$(( $(now_epoch) + timeout )) - while :; do - prepare_admission_provenance_state - if printf '%s' "$UNIDESK_PAC_ADMISSION_STATE_JSON" | node -e 'let input="";process.stdin.on("data",chunk=>input+=chunk);process.stdin.on("end",()=>process.exit(JSON.parse(input).ready===true?0:1));'; then - return 0 - fi - if [ "$(now_epoch)" -ge "$end" ]; then return 1; fi - sleep 2 - done -} - pipeline_rows() { payload_file=$(mktemp) kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file" @@ -624,7 +609,7 @@ function defaultCanMutate(namespace) { function admissionStateForConsumer(consumer) { const expected = consumer.deliveryProvenance; - if (!expected || expected.required !== true) return { configured: false, required: false, ready: null, reasons: ['consumer-admission-provenance-not-required'], valuesPrinted: false }; + if (!expected || expected.required !== true) return { configured: false, required: false, ready: null, blocking: false, warning: false, warnings: [], reasons: ['consumer-admission-provenance-not-required'], valuesPrinted: false }; admissionPolicy ||= kubectlJson(['get', 'validatingadmissionpolicy', expected.policyName, '-o', 'json'], {}, 'admission-policy'); admissionBinding ||= kubectlJson(['get', 'validatingadmissionpolicybinding', expected.bindingName, '-o', 'json'], {}, 'admission-binding'); if (!admissionRbac.has(consumer.namespace)) { @@ -1561,6 +1546,9 @@ if (!passwordPresent) reasons.push('argocd-repository-secret-password-missing'); process.stdout.write(JSON.stringify({ configured: runnerConfigured || argoConfigured, ready: reasons.length === 0, + blocking: false, + warning: reasons.length > 0, + warnings: reasons, runner: { configured: runnerConfigured, serviceAccount: runnerName || null, @@ -1597,7 +1585,6 @@ status_action() { repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME") prepare_admission_provenance_state consumer_bootstrap=$(consumer_bootstrap_state) - bootstrap_ready=$(UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE||"{}"); process.stdout.write(s.configured===true&&s.ready!==true?"false":"true")') pipelines=$(pipeline_rows) latest=$(printf '%s' "$pipelines" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(a[0]?.name||"")') export UNIDESK_PAC_TARGET_PIPELINERUN="$latest" @@ -1621,7 +1608,6 @@ NODE ) runtime=$(json_normalize "$(runtime_summary)") diagnostics=$(cicd_diagnostics "$pipelines" "$artifact" "$argo" "$runtime") - admission_ready=$(UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_STATE||"{}"); process.stdout.write(s.ready===true?"true":"false")') diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node <<'NODE' const diagnostics = JSON.parse(process.env.UNIDESK_PAC_DIAGNOSTICS || '{}'); const admissionProvenance = JSON.parse(process.env.UNIDESK_PAC_STATE || '{}'); @@ -1629,20 +1615,23 @@ const consumerBootstrap = JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE || if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionProvenance.ready !== true) { process.stdout.write(JSON.stringify({ ...diagnostics, - ok: false, - code: 'pac-admission-provenance-not-ready', - phase: 'admission-provenance-not-ready', - hint: 'required PaC admission policy, binding, desired RBAC, or live default-SA mutation gate is not ready', + warnings: [...(Array.isArray(diagnostics.warnings) ? diagnostics.warnings : []), { + code: 'pac-admission-provenance-not-ready', + blocking: false, + hint: 'PaC admission policy, binding, desired RBAC, or live default-SA observation is drifted; delivery continues', + reasons: Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons : [], + }], admissionProvenance, valuesPrinted: false, })); } else if (consumerBootstrap.configured === true && consumerBootstrap.ready !== true) { process.stdout.write(JSON.stringify({ ...diagnostics, - ok: false, - code: 'pac-consumer-bootstrap-not-ready', - phase: 'consumer-bootstrap-not-ready', - hint: 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted', + warnings: [...(Array.isArray(diagnostics.warnings) ? diagnostics.warnings : []), { + code: 'pac-consumer-bootstrap-not-ready', + blocking: false, + hint: 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted; status remains observable', + }], admissionProvenance, consumerBootstrap, valuesPrinted: false, @@ -1653,7 +1642,7 @@ if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionPro NODE ) printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"consumerBootstrap":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ - "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && [ "$bootstrap_ready" = "true" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \ + "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \ "$( [ -n "$crd" ] && echo true || echo false )" \ "$(json_string "$controller_ready")" \ "$UNIDESK_PAC_ADMISSION_STATE_JSON" \