diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index 381623b2..3b3a54be 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -88,10 +88,15 @@ test("versioned admission desired state protects controller proof, candidate ide const oldRun = { spec: { pipelineSpec: { tasks: [{ name: "plan-artifacts" }] }, workspaces: [], timeouts: { pipeline: "1h" } } }; const mutatedRun = structuredClone(oldRun); mutatedRun.spec.pipelineSpec.tasks[0].name = "forged-task"; - for (const field of ["managedBy", "params", "pipelineRef", "pipelineSpec", "taskRunSpecs", "taskRunTemplate", "timeouts", "workspaces"]) { + for (const field of ["managedBy", "params", "pipelineRef", "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).toContain("has(dyn(object.spec).pipelineSpec)"); + expect(specGuard.expression).toContain("has(dyn(oldObject.spec).pipelineSpec)"); + expect(specGuard.expression).toContain("dyn(object.spec).pipelineSpec == dyn(oldObject.spec).pipelineSpec"); + expect(specGuard.expression).not.toContain("has(object.spec.pipelineSpec)"); + expect(specGuard.expression).not.toContain("object.spec.pipelineSpec == oldObject.spec.pipelineSpec"); 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"'); @@ -141,6 +146,21 @@ test("live admission evaluator requires exact desired hashes, observed generatio }; 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, + policy: { + ...policy, + status: { + ...policy.status, + typeChecking: { + expressionWarnings: [{ + fieldRef: "spec.validations[7].expression", + warning: "undefined field 'pipelineSpec'", + }], + }, + }, + }, + })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["policy-expression-warning"]) }); const apiDefaultedPolicy = { ...policy, spec: { diff --git a/scripts/src/platform-infra-pac-provenance.ts b/scripts/src/platform-infra-pac-provenance.ts index 94983b12..434bbb02 100644 --- a/scripts/src/platform-infra-pac-provenance.ts +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -319,11 +319,19 @@ function pipelineRefEqual(): string { } function specFieldEqual(field: string): string { + if (field === "pipelineSpec") return schemalessSpecFieldEqual(field); const current = `has(object.spec.${field})`; const previous = `has(oldObject.spec.${field})`; return `((${current}) == (${previous}) && (!(${current}) || object.spec.${field} == oldObject.spec.${field}))`; } +function schemalessSpecFieldEqual(field: string): string { + // Tekton pipelineSpec 保留未知字段且没有静态类型,必须在 dyn 根上检查存在性并做完整深度相等。 + const current = `has(dyn(object.spec).${field})`; + const previous = `has(dyn(oldObject.spec).${field})`; + return `((${current}) == (${previous}) && (!(${current}) || dyn(object.spec).${field} == dyn(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)}`;