fix: 归一 PaC admission 等价差异

This commit is contained in:
Codex
2026-07-13 09:06:36 +02:00
parent 84fa08572e
commit b929e4356e
3 changed files with 119 additions and 9 deletions
@@ -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")}`;
}
@@ -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");
});
@@ -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=<semantic Go duration>",
"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<string, unknown>):
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<string, unknown> = {};
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<Record<string, bigint>> = {
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}`,