From b929e4356e750e41cc1c6ffbc62d63b6c05f6adb Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 09:06:36 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BD=92=E4=B8=80=20PaC=20admission=20?= =?UTF-8?q?=E7=AD=89=E4=BB=B7=E5=B7=AE=E5=BC=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cicd/pac-source-artifact-runtime.mjs | 38 ++++++++++++ ...-pipelines-as-code-source-artifact.test.ts | 28 +++++++++ ...infra-pipelines-as-code-source-artifact.ts | 62 ++++++++++++++++--- 3 files changed, 119 insertions(+), 9 deletions(-) diff --git a/scripts/native/cicd/pac-source-artifact-runtime.mjs b/scripts/native/cicd/pac-source-artifact-runtime.mjs index 50e003a0..9cd3410e 100644 --- a/scripts/native/cicd/pac-source-artifact-runtime.mjs +++ b/scripts/native/cicd/pac-source-artifact-runtime.mjs @@ -94,6 +94,10 @@ function missing(reason, sourceCommit = null) { function isAdmissionDefault(path, value) { const normalized = path.map((segment) => typeof segment === "number" ? "*" : segment).join("."); const emptyRecord = value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0; + if ([ + "spec.workspaces.*.volumeClaimTemplate.metadata", + "spec.workspaces.*.volumeClaimTemplate.status", + ].includes(normalized)) return emptyRecord; const atTaskSpec = (suffix) => [ `tasks.*.taskSpec.${suffix}`, `spec.tasks.*.taskSpec.${suffix}`, @@ -110,6 +114,8 @@ function isAdmissionDefault(path, value) { export function canonicalizePacPipelineSpec(value, path = []) { if (Array.isArray(value)) return value.map((item, index) => canonicalizePacPipelineSpec(item, [...path, index])); + const duration = canonicalTektonPipelineTimeout(path, value); + if (duration !== null) return duration; if (value === null || typeof value !== "object") return value; const output = {}; for (const key of Object.keys(value).sort()) { @@ -121,6 +127,38 @@ export function canonicalizePacPipelineSpec(value, path = []) { return output; } +function canonicalTektonPipelineTimeout(path, value) { + if (path.join(".") !== "spec.timeouts.pipeline" || typeof value !== "string") return null; + const matchSign = /^([+-]?)(.*)$/u.exec(value); + if (matchSign === null) return null; + const source = matchSign[2] ?? ""; + const units = { + h: 3_600_000_000_000n, + m: 60_000_000_000n, + s: 1_000_000_000n, + ms: 1_000_000n, + us: 1_000n, + "µs": 1_000n, + "μs": 1_000n, + ns: 1n, + }; + const segment = /([0-9]+)(ns|us|µs|μs|ms|s|m|h)/uy; + let offset = 0; + let nanoseconds = 0n; + let count = 0; + while (offset < source.length) { + segment.lastIndex = offset; + const matched = segment.exec(source); + if (matched === null) return null; + nanoseconds += BigInt(matched[1] ?? "0") * (units[matched[2] ?? ""] ?? 0n); + offset = segment.lastIndex; + count += 1; + } + if (count === 0) return null; + if (matchSign[1] === "-") nanoseconds = -nanoseconds; + return `tekton-duration-ns:${nanoseconds}`; +} + export function canonicalPacPipelineSha256(value) { return `sha256:${createHash("sha256").update(JSON.stringify(canonicalizePacPipelineSpec(value))).digest("hex")}`; } diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index a2d5558f..42ac07e9 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -331,6 +331,31 @@ describe("Tekton admission canonicalization", () => { expect(canonicalizePacPipelineSpec(negative)).toEqual(negative); expect(nativeCanonicalize(negative)).toEqual(negative); }); + + test("normalizes equivalent PipelineRun timeout spellings without hiding different values", () => { + const seconds = { spec: { timeouts: { pipeline: "600s" } } }; + const admitted = { spec: { timeouts: { pipeline: "10m0s" } } }; + const different = { spec: { timeouts: { pipeline: "601s" } } }; + const unrelated = { timeouts: { pipeline: "600s" } }; + expect(canonicalizePacPipelineSpec(seconds)).toEqual(canonicalizePacPipelineSpec(admitted)); + expect(nativeCanonicalize(seconds)).toEqual(nativeCanonicalize(admitted)); + expect(canonicalSha256(seconds)).toBe(canonicalSha256(admitted)); + expect(nativeCanonicalSha256(admitted)).toBe(canonicalSha256(seconds)); + expect(firstPacSourceArtifactDrift(seconds, admitted)).toBeNull(); + expect(firstPacSourceArtifactDrift(seconds, different)?.path).toBe("$.spec.timeouts.pipeline"); + expect(canonicalizePacPipelineSpec(unrelated)).toEqual(unrelated); + expect(nativeCanonicalize(unrelated)).toEqual(unrelated); + }); + + test("removes only empty workspace claim metadata and status added by admission", () => { + const desired = { spec: { workspaces: [{ name: "source", volumeClaimTemplate: { spec: { accessModes: ["ReadWriteOnce"] } } }] } }; + const admitted = { spec: { workspaces: [{ name: "source", volumeClaimTemplate: { metadata: {}, spec: { accessModes: ["ReadWriteOnce"] }, status: {} } }] } }; + const labeled = { spec: { workspaces: [{ name: "source", volumeClaimTemplate: { metadata: { labels: { owner: "test" } }, spec: { accessModes: ["ReadWriteOnce"] } } }] } }; + expect(canonicalizePacPipelineSpec(admitted)).toEqual(canonicalizePacPipelineSpec(desired)); + expect(nativeCanonicalize(admitted)).toEqual(nativeCanonicalize(desired)); + expect(firstPacSourceArtifactDrift(desired, admitted)).toBeNull(); + expect(firstPacSourceArtifactDrift(desired, labeled)?.path).toBe("$.spec.workspaces[0].volumeClaimTemplate.metadata"); + }); }); describe("source artifact serialization", () => { @@ -636,6 +661,9 @@ describe("runtime source-commit selection", () => { expect(sourceArtifactRuntimeAlignment(aligned, exact, sourceCommit)).toBe("aligned"); expect(sourceArtifactRuntimeAlignment(drifted, exact, sourceCommit)).toBe("drifted"); expect(sourceArtifactRuntimeAlignment(aligned, { ...exact, clean: false }, sourceCommit)).toBe("unavailable"); + const embeddedOnly = { ...structuredClone(aligned), live: { ...aligned.live, status: "missing" as const, provenanceAligned: false } }; + expect(sourceArtifactRuntimeAlignment(embeddedOnly, exact, sourceCommit, "embedded-pipeline-spec")).toBe("aligned"); + expect(sourceArtifactRuntimeAlignment(embeddedOnly, exact, sourceCommit, "remote-pipeline-annotation")).toBe("missing"); expect(JSON.stringify([aligned, drifted])).not.toContain("pending"); }); diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index afbde7b0..626d1c4d 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -284,7 +284,7 @@ export async function runPacSourceArtifact( }) : null; const sourceOk = source.aligned && source.provenanceAligned; - const runtimeAlignment = sourceArtifactRuntimeAlignment(runtime, sourceWorktreeState, options.sourceCommit); + const runtimeAlignment = sourceArtifactRuntimeAlignment(runtime, sourceWorktreeState, options.sourceCommit, binding.consumer.sourceArtifact.mode); const runtimeOk = runtimeAlignment === "aligned"; const ok = options.action === "check" ? sourceOk @@ -326,6 +326,9 @@ export async function runPacSourceArtifact( liveExportAllowed: false, sourceWorktreeExplicit: true, canonicalAdmissionDefaultsRemoved: [ + "PipelineRun.spec.timeouts.pipeline=", + "workspaces[].volumeClaimTemplate.metadata={}", + "workspaces[].volumeClaimTemplate.status={}", "tasks[].taskSpec.metadata={}", "tasks[].taskSpec.spec=null", "tasks[].taskSpec.params[].type=string", @@ -359,13 +362,14 @@ export function sourceArtifactRuntimeAlignment( runtime: PacSourceArtifactRuntimeObservation | null, worktree: SourceWorktreeState, sourceCommit: string | null, + mode: PacSourceArtifactMode = "remote-pipeline-annotation", ): PacSourceArtifactRuntimeAlignment { if (runtime === null) return "not-requested"; if (sourceCommit !== null && (!worktree.clean || worktree.matchesSourceCommit !== true)) return "unavailable"; - if (runtime.live.status === "unavailable" || runtime.embedded.status === "unavailable") return "unavailable"; - if (runtime.live.status === "missing" || runtime.embedded.status === "missing") return "missing"; - if (runtime.live.status === "drifted" || runtime.embedded.status === "drifted") return "drifted"; - if (!runtime.live.provenanceAligned || !runtime.embedded.provenanceAligned) return "drifted"; + const required = mode === "embedded-pipeline-spec" ? [runtime.embedded] : [runtime.live, runtime.embedded]; + if (required.some((item) => item.status === "unavailable")) return "unavailable"; + if (required.some((item) => item.status === "missing")) return "missing"; + if (required.some((item) => item.status === "drifted" || !item.provenanceAligned)) return "drifted"; return "aligned"; } @@ -386,14 +390,16 @@ function sourceArtifactNext( if (!worktree.clean || (options.sourceCommit !== null && worktree.matchesSourceCommit !== true)) { return "Create a clean worktree at the exact GitHub merge commit, then rerun status or verify-runtime with that full source commit."; } - if (runtime.live.status === "unavailable") return "Repair the owning control-plane runtime observer or RBAC, then rerun status before verification."; - if (runtime.live.status === "missing") return "Apply the owning YAML control plane, confirm the live Pipeline exists, then rerun verify-runtime."; - if (runtime.live.status === "drifted" || !runtime.live.provenanceAligned) return "Apply the owning YAML control plane so the live Pipeline spec and provenance align, then rerun verify-runtime."; + if (binding.consumer.sourceArtifact.mode === "remote-pipeline-annotation" && runtime.live.status === "unavailable") return "Repair the owning control-plane runtime observer or RBAC, then rerun status before verification."; + if (binding.consumer.sourceArtifact.mode === "remote-pipeline-annotation" && runtime.live.status === "missing") return "Apply the owning YAML control plane, confirm the live Pipeline exists, then rerun verify-runtime."; + if (binding.consumer.sourceArtifact.mode === "remote-pipeline-annotation" && (runtime.live.status === "drifted" || !runtime.live.provenanceAligned)) return "Apply the owning YAML control plane so the live Pipeline spec and provenance align, then rerun verify-runtime."; if (runtime.embedded.status === "unavailable") return "Inspect the exact-commit PaC PipelineRun identity and observer access, then rerun verify-runtime."; if (runtime.embedded.status === "missing") return "Wait for or repair the exact-commit PaC PipelineRun; do not validate a prefix-selected run."; if (runtime.embedded.status === "drifted" || !runtime.embedded.provenanceAligned) return "Regenerate and merge the consumer source artifact, then verify the new exact-commit PipelineRun."; return runtimeAlignment === "aligned" - ? "Exact source, live Pipeline, embedded PipelineRun, and provenance are aligned; continue consumer closeout." + ? binding.consumer.sourceArtifact.mode === "embedded-pipeline-spec" + ? "Exact source, embedded PipelineRun, and provenance are aligned; continue consumer closeout." + : "Exact source, live Pipeline, embedded PipelineRun, and provenance are aligned; continue consumer closeout." : "Resolve the reported runtime layer and rerun verify-runtime."; } @@ -428,6 +434,8 @@ export function renderPacSourceArtifactResult(result: Record): export function canonicalizePacPipelineSpec(value: unknown, path: readonly (string | number)[] = []): unknown { if (Array.isArray(value)) return value.map((item, index) => canonicalizePacPipelineSpec(item, [...path, index])); + const duration = canonicalTektonPipelineTimeout(path, value); + if (duration !== null) return duration; if (!isRecord(value)) return value; const output: Record = {}; for (const key of Object.keys(value).sort()) { @@ -439,6 +447,38 @@ export function canonicalizePacPipelineSpec(value: unknown, path: readonly (stri return output; } +function canonicalTektonPipelineTimeout(path: readonly (string | number)[], value: unknown): string | null { + if (path.join(".") !== "spec.timeouts.pipeline" || typeof value !== "string") return null; + const matchSign = /^([+-]?)(.*)$/u.exec(value); + if (matchSign === null) return null; + const source = matchSign[2] ?? ""; + const units: Readonly> = { + h: 3_600_000_000_000n, + m: 60_000_000_000n, + s: 1_000_000_000n, + ms: 1_000_000n, + us: 1_000n, + "µs": 1_000n, + "μs": 1_000n, + ns: 1n, + }; + const segment = /([0-9]+)(ns|us|µs|μs|ms|s|m|h)/uy; + let offset = 0; + let nanoseconds = 0n; + let count = 0; + while (offset < source.length) { + segment.lastIndex = offset; + const matched = segment.exec(source); + if (matched === null) return null; + nanoseconds += BigInt(matched[1] ?? "0") * (units[matched[2] ?? ""] ?? 0n); + offset = segment.lastIndex; + count += 1; + } + if (count === 0) return null; + if (matchSign[1] === "-") nanoseconds = -nanoseconds; + return `tekton-duration-ns:${nanoseconds}`; +} + export function canonicalSha256(value: unknown): string { return `sha256:${createHash("sha256").update(JSON.stringify(canonicalizePacPipelineSpec(value))).digest("hex")}`; } @@ -1027,6 +1067,10 @@ function firstCanonicalDrift(expected: unknown, actual: unknown, path: string): function isProvenTektonAdmissionDefault(path: readonly (string | number)[], value: unknown): boolean { const normalized = path.map((segment) => typeof segment === "number" ? "*" : segment).join("."); const emptyRecord = isRecord(value) && Object.keys(value).length === 0; + if ([ + "spec.workspaces.*.volumeClaimTemplate.metadata", + "spec.workspaces.*.volumeClaimTemplate.status", + ].includes(normalized)) return emptyRecord; const atTaskSpec = (suffix: string): boolean => [ `tasks.*.taskSpec.${suffix}`, `spec.tasks.*.taskSpec.${suffix}`,