From ff5b2d173210b96d29e07edf7009526c954a0cc1 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 00:50:40 +0200 Subject: [PATCH] =?UTF-8?q?fix(cicd):=20=E7=B2=BE=E7=A1=AE=E5=85=81?= =?UTF-8?q?=E8=AE=B8=20PaC=20watcher=20=E5=90=AF=E5=8A=A8=E9=98=9F?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/pipelines-as-code.yaml | 19 ++++- .../src/platform-infra-pac-provenance.test.ts | 53 ++++++++++-- scripts/src/platform-infra-pac-provenance.ts | 81 ++++++++++++++++++- 3 files changed, 142 insertions(+), 11 deletions(-) diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 35272334..4b53e015 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -13,6 +13,7 @@ metadata: - 1760 - 1769 - 1802 + - 1811 defaults: targetId: NC01 consumerId: hwlab-nc01-v03 @@ -26,7 +27,7 @@ release: controllerServicePort: 8080 waitTimeoutSeconds: 55 deliveryProvenance: - version: admission-pac-v1 + version: admission-pac-v2 markerAnnotation: unidesk.ai/pac-admission-provenance child: enabled: false @@ -35,9 +36,19 @@ deliveryProvenance: controller: namespace: pipelines-as-code serviceAccountName: pipelines-as-code-controller + queueTransition: + controller: + namespace: pipelines-as-code + serviceAccountName: pipelines-as-code-watcher + stateLabel: pipelinesascode.tekton.dev/state + allowed: + - fromSpecStatus: PipelineRunPending + toSpecStatus: absent + fromState: queued + toState: started admission: - policyName: unidesk-pac-delivery-provenance-v1 - bindingName: unidesk-pac-delivery-provenance-v1 + policyName: unidesk-pac-delivery-provenance-v2 + bindingName: unidesk-pac-delivery-provenance-v2 failurePolicy: Fail validationActions: - Deny @@ -130,7 +141,7 @@ consumers: LANE: v02 deliveryProvenance: required: true - markerValue: admission-pac-v1:agentrun-nc01-v02 + markerValue: admission-pac-v2:agentrun-nc01-v02 executionServiceAccountName: agentrun-nc01-v02-tekton-runner gitOps: repoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index 11c558be..381623b2 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -30,12 +30,34 @@ test("admission contract rejects malformed required declarations instead of sile expect(() => parsePacAdmissionContract(source)).toThrow("deliveryProvenance.required must be boolean true or false"); }); +test("admission queue transition contract rejects absent sources, unknown Tekton states, and duplicate transitions", () => { + const source = Bun.YAML.parse(readFileSync(resolve(root, "config/platform-infra/pipelines-as-code.yaml"), "utf8")) as any; + const absentSource = structuredClone(source); + absentSource.deliveryProvenance.queueTransition.allowed[0].fromSpecStatus = "absent"; + expect(() => parsePacAdmissionContract(absentSource)).toThrow("fromSpecStatus must be a known Tekton v1 PipelineRun spec status"); + + const unknownTarget = structuredClone(source); + unknownTarget.deliveryProvenance.queueTransition.allowed[0].toSpecStatus = "UnknownStatus"; + expect(() => parsePacAdmissionContract(unknownTarget)).toThrow("toSpecStatus must be a known Tekton v1 PipelineRun spec status or absent"); + + const duplicate = structuredClone(source); + duplicate.deliveryProvenance.queueTransition.allowed.push(structuredClone(duplicate.deliveryProvenance.queueTransition.allowed[0])); + expect(() => parsePacAdmissionContract(duplicate)).toThrow("deliveryProvenance.queueTransition.allowed must be unique"); +}); + test("versioned admission desired state protects controller proof, candidate identity, params, and execution SA", () => { 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(); - expect(contract.policyName).toEndWith("-v1"); - expect(contract.bindingName).toEndWith("-v1"); + expect(contract.policyName).toEndWith("-v2"); + expect(contract.bindingName).toEndWith("-v2"); + expect(contract.queueControllerUsername).toBe("system:serviceaccount:pipelines-as-code:pipelines-as-code-watcher"); + expect(contract.allowedQueueTransitions).toEqual([{ + fromSpecStatus: "PipelineRunPending", + toSpecStatus: "absent", + fromState: "queued", + toState: "started", + }]); expect(contract.child.enabled).toBe(false); expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02"]); const items = documents(renderPacAdmissionDesiredFragment("NC01")); @@ -52,7 +74,7 @@ test("versioned admission desired state protects controller proof, candidate ide expect(expressions).toContain("spec.pipelineRef.name"); expect(expressions).toContain("spec.params == oldObject.spec.params"); expect(expressions).toContain("object.spec.pipelineRef == oldObject.spec.pipelineRef"); - expect(expressions).toContain("object.spec == oldObject.spec"); + expect(expressions).not.toContain("object.spec == oldObject.spec"); expect(expressions).toContain("taskRunTemplate.serviceAccountName == oldObject.spec.taskRunTemplate.serviceAccountName"); expect(expressions).toContain("object.metadata.name.startsWith"); expect(expressions).toContain('object.metadata.labels["tekton.dev/pipeline"]'); @@ -61,12 +83,33 @@ test("versioned admission desired state protects controller proof, candidate ide expect(expressions).toContain(contract.child.parentUidAnnotation); expect(expressions).toContain("oldObject"); expect(JSON.stringify(items)).not.toContain('"kind":"ServiceAccount"'); - const specGuard = policy.spec.validations.find((item: any) => item.message.includes("PipelineRun spec is immutable")); + const specGuard = policy.spec.validations.find((item: any) => item.message.includes("PipelineRun spec fields are immutable")); + const queueGuard = policy.spec.validations.find((item: any) => item.message.includes("YAML-declared queue transition")); const oldRun = { spec: { pipelineSpec: { tasks: [{ name: "plan-artifacts" }] }, workspaces: [], timeouts: { pipeline: "1h" } } }; const mutatedRun = structuredClone(oldRun); mutatedRun.spec.pipelineSpec.tasks[0].name = "forged-task"; - expect(specGuard.expression).toMatch(/object\.spec == oldObject\.spec/u); + for (const field of ["managedBy", "params", "pipelineRef", "pipelineSpec", "taskRunSpecs", "taskRunTemplate", "timeouts", "workspaces"]) { + expect(specGuard.expression).toContain(`has(object.spec.${field})`); + expect(specGuard.expression).toContain(`object.spec.${field} == oldObject.spec.${field}`); + } + expect(specGuard.expression).not.toContain("pipelinesascode.tekton.dev/state"); expect(mutatedRun.spec).not.toEqual(oldRun.spec); + expect(queueGuard.expression).toContain('request.userInfo.username == "system:serviceaccount:pipelines-as-code:pipelines-as-code-watcher"'); + expect(queueGuard.expression).toContain('oldObject.spec.status == "PipelineRunPending"'); + expect(queueGuard.expression).toContain("!has(object.spec.status)"); + expect(queueGuard.expression).toContain('oldObject.metadata.labels["pipelinesascode.tekton.dev/state"] == "queued"'); + expect(queueGuard.expression).toContain('object.metadata.labels["pipelinesascode.tekton.dev/state"] == "started"'); + expect(queueGuard.expression).toContain('oldObject.metadata.annotations["unidesk.ai/pac-admission-provenance"] == "admission-pac-v2:agentrun-nc01-v02"'); + expect(queueGuard.expression).toContain('object.metadata.annotations["unidesk.ai/pac-admission-provenance"] == "admission-pac-v2:agentrun-nc01-v02"'); + expect(queueGuard.expression).toContain("oldObject.metadata.name.startsWith"); + expect(queueGuard.expression).toContain("object.metadata.name.startsWith"); + expect(queueGuard.expression).toContain("oldObject.spec.taskRunTemplate.serviceAccountName"); + expect(queueGuard.expression).toContain("object.spec.taskRunTemplate.serviceAccountName"); + expect(queueGuard.expression.match(/admission-pac-v2:agentrun-nc01-v02/gu)).toHaveLength(2); + expect(queueGuard.expression.match(/pipelines-as-code-watcher/gu)).toHaveLength(1); + expect(queueGuard.expression.match(/agentrun-nc01-v02-tekton-runner/gu)).toHaveLength(2); + expect(queueGuard.expression).not.toContain("admission-pac-v1:"); + expect(queueGuard.expression).not.toContain('== "Cancelled"'); }); test("required consumer default RBAC has one automatic desired owner and no Tekton mutation verbs", () => { diff --git a/scripts/src/platform-infra-pac-provenance.ts b/scripts/src/platform-infra-pac-provenance.ts index f7df6f5d..94983b12 100644 --- a/scripts/src/platform-infra-pac-provenance.ts +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -17,10 +17,20 @@ export interface PacAdmissionConsumerContract { readonly gitOps: { readonly repoUrl: string; readonly targetRevision: string }; } +export interface PacAdmissionQueueTransitionContract { + readonly fromSpecStatus: string; + readonly toSpecStatus: string; + readonly fromState: string; + readonly toState: string; +} + export interface PacAdmissionContract { readonly version: string; readonly markerAnnotation: string; readonly controllerUsername: string; + readonly queueControllerUsername: string; + readonly queueStateLabel: string; + readonly allowedQueueTransitions: readonly PacAdmissionQueueTransitionContract[]; readonly policyName: string; readonly bindingName: string; readonly failurePolicy: "Fail"; @@ -51,9 +61,27 @@ export function parsePacAdmissionContract(source: Record): PacA const provenance = record(root.deliveryProvenance, `${configPath}#deliveryProvenance`); const child = record(provenance.child, `${configPath}#deliveryProvenance.child`); const controller = record(provenance.controller, `${configPath}#deliveryProvenance.controller`); + const queueTransition = record(provenance.queueTransition, `${configPath}#deliveryProvenance.queueTransition`); + const queueController = record(queueTransition.controller, `${configPath}#deliveryProvenance.queueTransition.controller`); const admission = record(provenance.admission, `${configPath}#deliveryProvenance.admission`); const controllerNamespace = required(controller.namespace, "deliveryProvenance.controller.namespace"); const controllerServiceAccount = required(controller.serviceAccountName, "deliveryProvenance.controller.serviceAccountName"); + const queueControllerNamespace = required(queueController.namespace, "deliveryProvenance.queueTransition.controller.namespace"); + const queueControllerServiceAccount = required(queueController.serviceAccountName, "deliveryProvenance.queueTransition.controller.serviceAccountName"); + const queueStateLabel = required(queueTransition.stateLabel, "deliveryProvenance.queueTransition.stateLabel"); + const allowedQueueTransitions = recordArray(queueTransition.allowed, "deliveryProvenance.queueTransition.allowed").map((transition, index) => { + const fromSpecStatus = pipelineRunSpecStatus(transition.fromSpecStatus, `deliveryProvenance.queueTransition.allowed[${index}].fromSpecStatus`, false); + const toSpecStatus = pipelineRunSpecStatus(transition.toSpecStatus, `deliveryProvenance.queueTransition.allowed[${index}].toSpecStatus`, true); + return { + fromSpecStatus, + toSpecStatus, + fromState: required(transition.fromState, `deliveryProvenance.queueTransition.allowed[${index}].fromState`), + toState: required(transition.toState, `deliveryProvenance.queueTransition.allowed[${index}].toState`), + }; + }); + if (allowedQueueTransitions.length === 0) throw new Error("deliveryProvenance.queueTransition.allowed must not be empty"); + 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]"); const consumers = recordArray(root.consumers, "consumers").flatMap((consumer, index) => { @@ -89,6 +117,9 @@ export function parsePacAdmissionContract(source: Record): PacA version, markerAnnotation: required(provenance.markerAnnotation, "deliveryProvenance.markerAnnotation"), controllerUsername: `system:serviceaccount:${controllerNamespace}:${controllerServiceAccount}`, + queueControllerUsername: `system:serviceaccount:${queueControllerNamespace}:${queueControllerServiceAccount}`, + queueStateLabel, + allowedQueueTransitions, policyName, bindingName, failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Fail"), @@ -114,6 +145,8 @@ export function renderPacAdmissionDesiredFragment(targetId: string): string { const markerPresent = `has(object.metadata.annotations) && ${cel(annotation)} in object.metadata.annotations`; const oldMarkerPresent = `has(oldObject.metadata.annotations) && ${cel(annotation)} in oldObject.metadata.annotations`; const protectedUpdate = [markerPresent, oldMarkerPresent, ...consumers.flatMap((consumer) => [candidateExpression(consumer, "object"), candidateExpression(consumer, "oldObject")])].map((item) => `(${item})`).join(" || "); + const allowedQueueStatusTransition = queueTransitionExpression(contract, consumers, annotation); + const immutableSpecFields = ["managedBy", "params", "pipelineRef", "pipelineSpec", "taskRunSpecs", "taskRunTemplate", "timeouts", "workspaces"] as const; const immutableProofKeys = [ ["labels", "app.kubernetes.io/managed-by"], ["labels", "pipelinesascode.tekton.dev/event-type"], @@ -171,8 +204,13 @@ export function renderPacAdmissionDesiredFragment(targetId: string): string { reason: "Invalid", }, { - expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || object.spec == oldObject.spec`, - message: "admission-proven PaC PipelineRun spec is immutable, including embedded pipelineSpec, workspaces, and timeouts", + expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${immutableSpecFields.map(specFieldEqual).join(" && ")})`, + message: "admission-proven PaC PipelineRun spec fields are immutable, including embedded pipelineSpec, workspaces, and timeouts", + reason: "Invalid", + }, + { + expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${specFieldEqual("status")} || (${allowedQueueStatusTransition}))`, + message: "PaC PipelineRun spec.status may only follow an authenticated YAML-declared queue transition", reason: "Invalid", }, ...consumers.flatMap((consumer) => [{ @@ -280,6 +318,38 @@ function pipelineRefEqual(): string { return `((${current}) == (${previous}) && (!(${current}) || object.spec.pipelineRef == oldObject.spec.pipelineRef))`; } +function specFieldEqual(field: string): string { + const current = `has(object.spec.${field})`; + const previous = `has(oldObject.spec.${field})`; + return `((${current}) == (${previous}) && (!(${current}) || object.spec.${field} == oldObject.spec.${field}))`; +} + +function specStatusState(root: "object" | "oldObject", state: string): string { + if (state === "absent") return `!has(${root}.spec.status)`; + return `has(${root}.spec.status) && ${root}.spec.status == ${cel(state)}`; +} + +function metadataEntryState(root: "object" | "oldObject", map: "annotations" | "labels", key: string, value: string): string { + return `has(${root}.metadata.${map}) && ${cel(key)} in ${root}.metadata.${map} && ${root}.metadata.${map}[${cel(key)}] == ${cel(value)}`; +} + +function exactConsumerAdmissionProof(consumer: PacAdmissionConsumerContract, annotation: string): string { + const marker = (root: "object" | "oldObject") => metadataEntryState(root, "annotations", annotation, consumer.markerValue); + return `(${marker("oldObject")} && ${marker("object")} && ${candidateExpression(consumer, "oldObject")} && ${candidateExpression(consumer, "object")} && ${usesServiceAccount(consumer, "oldObject")} && ${usesServiceAccount(consumer, "object")})`; +} + +function queueTransitionExpression(contract: PacAdmissionContract, consumers: readonly PacAdmissionConsumerContract[], annotation: string): string { + const exactConsumerProof = consumers.map((consumer) => exactConsumerAdmissionProof(consumer, annotation)).join(" || "); + const transitions = contract.allowedQueueTransitions.map((transition) => [ + specStatusState("oldObject", transition.fromSpecStatus), + specStatusState("object", transition.toSpecStatus), + metadataEntryState("oldObject", "labels", contract.queueStateLabel, transition.fromState), + metadataEntryState("object", "labels", contract.queueStateLabel, transition.toState), + `(${exactConsumerProof})`, + ].join(" && ")); + return `request.userInfo.username == ${cel(contract.queueControllerUsername)} && (${transitions.map((transition) => `(${transition})`).join(" || ")})`; +} + function cel(value: string): string { return JSON.stringify(value); } @@ -299,6 +369,13 @@ function required(value: unknown, path: string): string { return value; } +function pipelineRunSpecStatus(value: unknown, path: string, allowAbsent: boolean): string { + const status = required(value, path); + const known = ["Cancelled", "CancelledRunFinally", "StoppedRunFinally", "PipelineRunPending"]; + if ((status === "absent" && !allowAbsent) || (status !== "absent" && !known.includes(status))) throw new Error(`${path} must be a known Tekton v1 PipelineRun spec status${allowAbsent ? " or absent" : ""}`); + return status; +} + function stringArray(value: unknown, path: string): string[] { if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be a non-empty string array`); return value as string[];