Merge pull request #1812 from pikasTech/fix/1811-pac-watcher-queue-transition
fix(cicd): 精确允许 PaC watcher 启动队列
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user