fix(cicd): 收紧 PaC admission 更新与探针

This commit is contained in:
Codex
2026-07-12 00:04:24 +02:00
parent eb865325ca
commit 810191d07a
5 changed files with 43 additions and 14 deletions
+6 -1
View File
@@ -400,6 +400,11 @@ function extractPacSourceObservation(recordsInput) {
reason = "delivery-terminal-evidence-incomplete";
}
const planMarkerSource = plan?.event === "pac-delivery-plan"
? "pac-delivery-plan-structured-log"
: plan?.event === "g14-ci-plan"
? "g14-ci-plan-structured-log"
: "delivery-plan-structured-log";
return {
schemaVersion: "v1",
contract: plan?.event === "pac-delivery-plan" ? "pac-delivery-plan-v1" : "hwlab-g14-ci-plan",
@@ -419,7 +424,7 @@ function extractPacSourceObservation(recordsInput) {
gitopsPromote: promoteMarker !== null ? "no-build-no-rollout-plan" : promoteTask?.status || "missing",
},
terminalSources: {
planArtifacts: terminalSource(planTask, plan, "observed", "g14-ci-plan-structured-log"),
planArtifacts: terminalSource(planTask, plan, "observed", planMarkerSource),
collectArtifacts: terminalSource(collectTask, collectMarker),
gitopsPromote: terminalSource(promoteTask, promoteMarker),
},
@@ -32,6 +32,8 @@ test("admission contract rejects malformed required declarations instead of sile
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.child.enabled).toBe(false);
@@ -50,6 +52,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).toContain("taskRunTemplate.serviceAccountName == oldObject.spec.taskRunTemplate.serviceAccountName");
expect(expressions).toContain("object.metadata.name.startsWith");
expect(expressions).toContain('object.metadata.labels["tekton.dev/pipeline"]');
@@ -58,6 +61,12 @@ 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 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);
expect(mutatedRun.spec).not.toEqual(oldRun.spec);
});
test("required consumer default RBAC has one automatic desired owner and no Tekton mutation verbs", () => {
@@ -184,6 +193,9 @@ test("AgentRun rendered delivery plan and real TaskRun terminals close the statu
collectArtifacts: "succeeded",
gitopsPromote: "succeeded",
},
terminalSources: {
planArtifacts: { markerSource: "pac-delivery-plan-structured-log" },
},
});
expect(evaluator.evaluatePacStatus({
sourceCommit,
@@ -213,6 +225,15 @@ test("history evaluates admission and default RBAC in each consumer namespace",
expect(remote).toContain("kubectlJson(['-n', consumer.namespace, 'get', 'role', 'unidesk-pac-consumer-runner'");
expect(remote).toContain("kubectlJson(['-n', consumer.namespace, 'get', 'rolebinding', 'unidesk-pac-consumer-runner-default'");
expect(remote).toContain("defaultCanMutate(consumer.namespace)");
expect(remote).toContain("for verb in create patch update delete deletecollection; do");
expect(remote).toContain('timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" kubectl auth can-i');
expect(remote).toContain("for (const verb of ['create', 'patch', 'update', 'delete', 'deletecollection'])");
const defaultMutationProbe = remote.slice(remote.indexOf("function defaultCanMutate(namespace)"), remote.indexOf("function admissionStateForConsumer(consumer)"));
expect(defaultMutationProbe).toContain("timeout: waitTimeoutSeconds * 1000");
expect(defaultMutationProbe).not.toContain("timeout: 12000");
expect(remote).toContain("'delivery plan structured evidence is missing'");
expect(remote).toContain("'delivery plan source commit does not match the selected PipelineRun'");
expect(remote).not.toContain("'g14-ci-plan structured evidence is missing'");
expect(remote).not.toContain("for (const consumer of consumers) consumer.admissionState = admissionState");
});
+5 -2
View File
@@ -25,7 +25,6 @@ export interface PacAdmissionContract {
readonly bindingName: string;
readonly failurePolicy: "Fail";
readonly validationActions: readonly ["Deny"];
readonly terminalRoles: readonly string[];
readonly child: {
readonly enabled: false;
readonly parentNameAnnotation: string;
@@ -94,7 +93,6 @@ export function parsePacAdmissionContract(source: Record<string, unknown>): PacA
bindingName,
failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Fail"),
validationActions: ["Deny"],
terminalRoles: stringArray(provenance.terminalRoles, "deliveryProvenance.terminalRoles"),
child: {
enabled: literal(child.enabled, "deliveryProvenance.child.enabled", false),
parentNameAnnotation: required(child.parentNameAnnotation, "deliveryProvenance.child.parentNameAnnotation"),
@@ -172,6 +170,11 @@ export function renderPacAdmissionDesiredFragment(targetId: string): string {
message: "PaC referenced Pipeline identity is immutable",
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",
reason: "Invalid",
},
...consumers.flatMap((consumer) => [{
expression: `request.operation != 'CREATE' || !(${candidateExpression(consumer, "object")}) || (request.userInfo.username == ${cel(contract.controllerUsername)} && (${markerPresent}) && object.metadata.annotations[${cel(annotation)}] == ${cel(consumer.markerValue)} && ${usesServiceAccount(consumer, "object")})`,
message: `PipelineRun candidates for ${consumer.id} require its exact admission marker and execution ServiceAccount`,
@@ -278,9 +278,9 @@ admission_provenance_state() {
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get role unidesk-pac-consumer-runner -o json >"$role_file" 2>/dev/null || printf '{}' >"$role_file"
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get rolebinding unidesk-pac-consumer-runner-default -o json >"$role_binding_file" 2>/dev/null || printf '{}' >"$role_binding_file"
default_can_mutate=false
for verb in create patch update; do
for verb in create patch update delete deletecollection; do
for resource in pipelineruns.tekton.dev taskruns.tekton.dev; do
if answer=$(kubectl auth can-i --as="system:serviceaccount:$UNIDESK_PAC_TARGET_NAMESPACE:default" "$verb" "$resource" -n "$UNIDESK_PAC_TARGET_NAMESPACE" 2>/dev/null); then :; else :; fi
if answer=$(timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" kubectl auth can-i --as="system:serviceaccount:$UNIDESK_PAC_TARGET_NAMESPACE:default" "$verb" "$resource" -n "$UNIDESK_PAC_TARGET_NAMESPACE" 2>/dev/null); then :; else :; fi
case "$answer" in
yes) default_can_mutate=true ;;
no) ;;
@@ -472,12 +472,16 @@ let admissionBinding = null;
const admissionRbac = new Map();
function defaultCanMutate(namespace) {
for (const verb of ['create', 'patch', 'update']) {
const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10);
if (!Number.isInteger(waitTimeoutSeconds) || waitTimeoutSeconds <= 0) {
kubectlJsonErrors.push({ context: `${namespace}:default-can-mutation`, status: null, error: 'invalid-yaml-wait-timeout', stderr: '' });
return null;
}
for (const verb of ['create', 'patch', 'update', 'delete', 'deletecollection']) {
for (const resource of ['pipelineruns.tekton.dev', 'taskruns.tekton.dev']) {
const result = cp.spawnSync('kubectl', ['auth', 'can-i', `--as=system:serviceaccount:${namespace}:default`, verb, resource, '-n', namespace], {
encoding: 'utf8',
timeout: 12000,
maxBuffer: 64 * 1024,
timeout: waitTimeoutSeconds * 1000,
});
const answer = String(result.stdout || '').trim();
if (answer === 'yes') return true;
@@ -1424,8 +1428,8 @@ if (historyErrors.length > 0) breakAt('target-read-failed', 'target-read', histo
if (Object.keys(row).length === 0) breakAt('pipeline-run-not-found', 'pipeline-run', `PipelineRun ${process.env.UNIDESK_PAC_HISTORY_ID || '-'} was not found for the selected consumer`);
if (Object.keys(row).length > 0 && row.deliveryAuthorityEligible !== true) breakAt('pipeline-run-not-delivery-authority', 'pipeline-run', `PipelineRun is classified as ${row.deliveryClass || 'unknown'}; only an outer PaC event can drive delivery closeout`);
if (Object.keys(row).length > 0 && row.status !== 'True') breakAt('pipeline-run-not-succeeded', 'pipeline-run', `PipelineRun terminal status is ${row.status || row.reason || 'unknown'}`);
if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'g14-ci-plan structured evidence is missing');
if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'g14-ci-plan source commit does not match the selected PipelineRun');
if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'delivery plan structured evidence is missing');
if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'delivery plan source commit does not match the selected PipelineRun');
if (sourceObservation.valid !== true) breakAt('source-observation-invalid', 'plan-artifacts', sourceObservation.reason || 'delivery plan is incomplete or contradictory');
for (const item of terminal) {
if (item.present !== true || item.status !== 'succeeded') breakAt('terminal-evidence-incomplete', item.step, `${item.step} TaskRun terminal status is ${item.status}`);