fix(cicd): 精确允许 PaC watcher 启动队列

This commit is contained in:
Codex
2026-07-12 00:50:40 +02:00
parent 7e218bea0d
commit ff5b2d1732
3 changed files with 142 additions and 11 deletions
+15 -4
View File
@@ -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
@@ -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", () => {
+79 -2
View File
@@ -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<string, unknown>): 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<string, unknown>): 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[];