From 9f3be5ebbb2ecd3d2527d3ace70f587d6d412119 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 00:32:34 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(cicd):=20=E6=A0=A1=E5=87=86=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E4=BA=A4=E4=BB=98=E6=9D=83=E5=A8=81=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/native/cicd/pac-status-evaluator.cjs | 14 ++ scripts/src/platform-infra-gitea-remote.sh | 182 +++++++++++++++--- ...tform-infra-gitea-status-evaluator.test.ts | 118 +++++++++++- .../src/platform-infra-pac-provenance.test.ts | 42 ++++ .../platform_infra_gitea_status_evaluator.py | 95 ++++++++- 5 files changed, 409 insertions(+), 42 deletions(-) diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index 82c6e755..f14ffb46 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -76,6 +76,16 @@ function normalizeAdmissionPolicySpec(value) { if (Object.prototype.hasOwnProperty.call(spec, "matchConstraints")) { const matchConstraints = { ...record(spec.matchConstraints) }; if (!Object.prototype.hasOwnProperty.call(matchConstraints, "matchPolicy")) matchConstraints.matchPolicy = "Equivalent"; + for (const selector of ["namespaceSelector", "objectSelector"]) { + if (isEmptyRecord(matchConstraints[selector])) delete matchConstraints[selector]; + } + if (Array.isArray(matchConstraints.resourceRules)) { + matchConstraints.resourceRules = matchConstraints.resourceRules.map((value) => { + const rule = { ...record(value) }; + if (rule.scope === "*") delete rule.scope; + return rule; + }); + } spec.matchConstraints = matchConstraints; } return spec; @@ -91,6 +101,10 @@ function normalizeAdmissionBindingSpec(value) { return spec; } +function isEmptyRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0; +} + function evaluatePacAdmissionState(inputValue) { const input = record(inputValue); const policy = record(input.policy); diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index f665c42d..0e85acab 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -632,7 +632,7 @@ PY python3 - "$status_rc" "$tmp/repo-meta.json" "$tmp" <<'PY' import json, os, sys sys.path.insert(0, sys.argv[3]) -from platform_infra_gitea_status_evaluator import select_snapshot +from platform_infra_gitea_status_evaluator import parse_ls_remote_lines, select_snapshot status_rc = int(sys.argv[1]) meta = json.load(open(sys.argv[2], encoding="utf-8")) tmp = sys.argv[3] @@ -643,12 +643,11 @@ def text(path, limit=1200): return "" repos = [] for repo in meta: - out = text(os.path.join(tmp, f"{repo['key']}.ls.out"), 20000) - refs = {} - for line in out.splitlines(): - parts = line.split() - if len(parts) == 2: - refs[parts[1]] = parts[0] + try: + with open(os.path.join(tmp, f"{repo['key']}.ls.out"), encoding="utf-8", errors="replace") as handle: + refs = parse_ls_remote_lines(handle) + except FileNotFoundError: + refs = {} branch_ref = f"refs/heads/{repo['branch']}" branch_commit = refs.get(branch_ref) selected_snapshot = select_snapshot(branch_commit, repo["snapshotPrefix"], refs) @@ -755,7 +754,7 @@ NODE python3 - "$tmp/repos.json" "$bridge_ready" "$service_exists" "$mirror_status_rc" "$bridge_logs_rc" "$inbox_status_rc" "$tmp/mirror-status.json" "$tmp/mirror-status.err" "$tmp/bridge.log" "$tmp/bridge.err" "$tmp/inbox-status.json" "$tmp/inbox-status.err" <<'PY' import json, os, sys, urllib.error, urllib.parse, urllib.request sys.path.insert(0, os.environ["tmp"]) -from platform_infra_gitea_status_evaluator import evaluate_repository +from platform_infra_gitea_status_evaluator import evaluate_repository, select_committed_head_record, select_target_delivery repos = json.load(open(sys.argv[1], encoding="utf-8")) bridge_ready = sys.argv[2] service_exists = bool(sys.argv[3]) @@ -782,9 +781,9 @@ def request(api_path): req.add_header("X-GitHub-Api-Version", "2022-11-28") try: with urllib.request.urlopen(req, timeout=30) as resp: - return {"ok": True, "status": resp.status, "body": resp.read().decode("utf-8", errors="replace")} + return {"ok": True, "status": resp.status, "body": resp.read().decode("utf-8", errors="replace"), "link": resp.headers.get("Link")} except urllib.error.HTTPError as exc: - return {"ok": False, "status": exc.code, "body": exc.read().decode("utf-8", errors="replace")[:1000]} + return {"ok": False, "status": exc.code, "body": exc.read().decode("utf-8", errors="replace")[:1000], "link": exc.headers.get("Link") if exc.headers else None} def github_head(repository, branch): result = request(f"/repos/{repository}/git/ref/heads/{urllib.parse.quote(branch, safe='')}") try: @@ -792,14 +791,42 @@ def github_head(repository, branch): except Exception: payload = {} return {"ok": result.get("ok"), "status": result.get("status"), "sha": payload.get("object", {}).get("sha"), "error": None if result.get("ok") else result.get("body", "")[:300]} -def hook_deliveries(repository, hook_id): - if not hook_id: - return {"ok": False, "status": None, "latest": None, "latestPush": None, "error": "hook not found"} - result = request(f"/repos/{repository}/hooks/{hook_id}/deliveries?per_page=8") +def hook_delivery_detail(repository, hook_id, delivery): + delivery_id = delivery.get("id") if isinstance(delivery, dict) else None + if not delivery_id: + return {"ok": False, "error": "delivery id missing"} + result = request(f"/repos/{repository}/hooks/{hook_id}/deliveries/{delivery_id}") try: - rows = json.loads(result.get("body") or "[]") + body = json.loads(result.get("body") or "{}") except Exception: - rows = [] + body = {} + request_payload = body.get("request", {}).get("payload", {}) if isinstance(body.get("request"), dict) else {} + payload_repository = request_payload.get("repository", {}) if isinstance(request_payload.get("repository"), dict) else {} + return { + "ok": result.get("ok"), + "repository": payload_repository.get("full_name"), + "ref": request_payload.get("ref"), + "requestedCommit": request_payload.get("after"), + "error": None if result.get("ok") else result.get("body", "")[:300], + } +def next_delivery_cursor(link): + if not isinstance(link, str): + return None + for part in link.split(","): + if 'rel="next"' not in part: + continue + start = part.find("<") + end = part.find(">", start + 1) + if start < 0 or end < 0: + continue + query = urllib.parse.parse_qs(urllib.parse.urlparse(part[start + 1:end]).query) + values = query.get("cursor") or [] + if values: + return values[0] + return None +def hook_deliveries(repository, expected_ref, github_head, head_delivery_id, hook_id): + if not hook_id: + return {"ok": False, "status": None, "latest": None, "latestPush": None, "targetPush": None, "rows": [], "error": "hook not found"} def compact(item): if not isinstance(item, dict): return None @@ -813,9 +840,71 @@ def hook_deliveries(repository, hook_id): "deliveredAt": item.get("delivered_at"), "duration": item.get("duration"), } - latest = compact(rows[0]) if rows else None - latest_push = next((compact(item) for item in rows if isinstance(item, dict) and item.get("event") == "push"), None) - return {"ok": result.get("ok"), "status": result.get("status"), "latest": latest, "latestPush": latest_push, "error": None if result.get("ok") else result.get("body", "")[:300]} + compact_rows = [] + cursor = None + list_error = None + last_status = None + history_has_more = False + max_pages = 10 if head_delivery_id else 1 + for _ in range(max_pages): + query = "?per_page=100" + ("&cursor=" + urllib.parse.quote(cursor, safe="") if cursor else "") + result = request(f"/repos/{repository}/hooks/{hook_id}/deliveries{query}") + last_status = result.get("status") + if not result.get("ok"): + list_error = result.get("body", "")[:300] + break + try: + page_rows = json.loads(result.get("body") or "[]") + except Exception: + page_rows = [] + compact_rows.extend(item for item in (compact(value) for value in page_rows if isinstance(value, dict)) if item is not None) + if head_delivery_id and any(str(item.get("deliveryId")) == str(head_delivery_id) for item in compact_rows): + break + cursor = next_delivery_cursor(result.get("link")) + history_has_more = cursor is not None + if cursor is None: + break + latest = compact_rows[0] if compact_rows else None + latest_push = next((item for item in compact_rows if item.get("event") == "push"), None) + target_push = None + detail_error = None + if head_delivery_id: + delivery_candidates = [item for item in compact_rows if str(item.get("deliveryId")) == str(head_delivery_id)] + if not delivery_candidates: + detail_error = "committed head delivery guid not found in bounded github history" + else: + delivery_candidates = [item for item in compact_rows if item.get("event") == "push"][:100] + detailed_candidates = [] + for delivery in delivery_candidates: + detail = hook_delivery_detail(repository, hook_id, delivery) + if not detail.get("ok"): + detail_error = detail.get("error") or "delivery detail unavailable" + break + detailed = {**delivery, **{key: detail.get(key) for key in ("repository", "ref", "requestedCommit")}} + detailed_candidates.append(detailed) + target_push = select_target_delivery( + detailed_candidates, + repository, + expected_ref, + github_head if head_delivery_id else None, + head_delivery_id, + ) + if target_push is not None: + break + if head_delivery_id is None and target_push is None and detail_error is None: + detail_error = "target branch delivery not found in bounded github history" + return { + "ok": list_error is None and detail_error is None, + "status": last_status, + "latest": latest, + "latestPush": latest_push, + "targetPush": target_push, + "rows": compact_rows, + "historyHasMore": history_has_more, + "error": detail_error or list_error, + } +def newest_delivery(rows): + return max(rows, key=lambda item: (str(item.get("deliveredAt") or ""), str(item.get("id") or "")), default=None) try: mirror_status = json.load(open(mirror_status_path, encoding="utf-8")) except Exception: @@ -861,22 +950,55 @@ for repo in repos: hooks = json.loads(result.get("body") or "[]") except Exception: hooks = [] - match = next((item for item in hooks if (item.get("config") or {}).get("url") == url), None) - hook_id = match.get("id") if match else None + matches = [item for item in hooks if (item.get("config") or {}).get("url") == url] head = github_head(repository, branch) - delivery = hook_deliveries(repository, hook_id) + head_record = select_committed_head_record(repo["key"], head.get("sha"), inbox_records) + head_delivery_id = head_record.get("deliveryId") if isinstance(head_record, dict) else None + observations = [] + for match in matches: + hook_id = match.get("id") + delivery = hook_deliveries(repository, f"refs/heads/{branch}", head.get("sha"), head_delivery_id, hook_id) + observations.append({"hook": match, "delivery": delivery}) + all_deliveries = [ + {**item, "hookId": observation["hook"].get("id")} + for observation in observations + for item in observation["delivery"].get("rows", []) + ] + push_deliveries = [item for item in all_deliveries if item.get("event") == "push"] + latest_delivery = newest_delivery(all_deliveries) + latest_push_delivery = newest_delivery(push_deliveries) + target_push_deliveries = [ + {**observation["delivery"]["targetPush"], "hookId": observation["hook"].get("id")} + for observation in observations + if observation["delivery"].get("targetPush") is not None + ] + selected_push_delivery = newest_delivery(target_push_deliveries) + selected_hook_id = selected_push_delivery.get("hookId") if selected_push_delivery else (matches[0].get("id") if matches else None) + selected_match = next((item for item in matches if item.get("id") == selected_hook_id), None) + topology_ready = len(matches) == 1 and matches[0].get("active") is True + topology_reason = None if topology_ready else ( + "github-hook-missing" if len(matches) == 0 + else "github-hook-duplicate-exact-url" if len(matches) > 1 + else "github-hook-inactive" + ) + delivery_errors = [observation["delivery"].get("error") for observation in observations if observation["delivery"].get("error")] mirror = mirror_by_key.get(repo["key"], {}) - latest_push_delivery = delivery.get("latestPush") hook = { - "hookId": hook_id, - "hookReady": bool(match and match.get("active") is True), - "active": match.get("active") if match else None, + "hookId": selected_hook_id, + "hookReady": topology_ready, + "active": selected_match.get("active") if selected_match else None, "status": result.get("status"), - "latestDelivery": delivery.get("latest"), + "latestDelivery": latest_delivery, "latestPushDelivery": latest_push_delivery, - "latest": delivery.get("latest"), - "latestPush": delivery.get("latestPush"), - "error": result.get("body", "")[:500] if not result.get("ok") else delivery.get("error"), + "selectedPushDelivery": selected_push_delivery, + "deliverySelectionReason": ("committed-head-guid-repository-branch-after" if head_delivery_id else "latest-repository-branch-push-delivery") if selected_push_delivery else "target-branch-push-delivery-unavailable", + "matchingHookCount": len(matches), + "matchingHookIds": [item.get("id") for item in matches], + "hookTopologyReason": topology_reason, + "latest": latest_delivery, + "latestPush": latest_push_delivery, + "selectedPush": selected_push_delivery, + "error": result.get("body", "")[:500] if not result.get("ok") else (delivery_errors[0] if delivery_errors else topology_reason), } rows.append(evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records, inbox_status.get("journal"))) bridge = { diff --git a/scripts/src/platform-infra-gitea-status-evaluator.test.ts b/scripts/src/platform-infra-gitea-status-evaluator.test.ts index a499d2d6..7ae08db8 100644 --- a/scripts/src/platform-infra-gitea-status-evaluator.test.ts +++ b/scripts/src/platform-infra-gitea-status-evaluator.test.ts @@ -5,6 +5,76 @@ import { resolve } from "node:path"; const evaluator = resolve(import.meta.dir, "platform_infra_gitea_status_evaluator.py"); describe("Gitea source authority status evaluator", () => { + test("parses branch and exact snapshot from ls-remote output larger than the display tail budget", () => { + const head = "2".repeat(40); + const prefix = "refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01"; + const historical = Array.from({ length: 420 }, (_, index) => { + const sha = index.toString(16).padStart(40, "0"); + return `${sha}\t${prefix}/${sha}`; + }); + const output = [ + `${head}\trefs/heads/master`, + `${head}\t${prefix}/${head}`, + ...historical, + ].join("\n"); + expect(Buffer.byteLength(output)).toBeGreaterThan(20_000); + const refs = evaluate({ action: "parse-ls-remote", output }); + expect(refs["refs/heads/master"]).toBe(head); + expect(refs[`${prefix}/${head}`]).toBe(head); + }); + + test("anchors the current committed head beyond more than fifty unrelated feature pushes", () => { + const head = "7".repeat(40); + const committed = committedInbox("master-durable-guid", head); + const headRecord = evaluate({ + action: "select-committed-head-record", + repoKey: "fixture", + githubHead: head, + records: [committedInbox("old-guid", "6".repeat(40)), committed], + }); + expect(headRecord.deliveryId).toBe("master-durable-guid"); + expect(evaluate({ + action: "select-committed-head-record", + repoKey: "fixture", + githubHead: "8".repeat(40), + records: [committed], + })).toBeNull(); + const featureDeliveries = Array.from({ length: 75 }, (_, index) => ({ + id: String(10_000 - index), + deliveryId: `feature-${index}`, + deliveredAt: `2026-07-11T23:${String(index % 60).padStart(2, "0")}:00Z`, + repository: "pikasTech/fixture", + ref: `refs/heads/feature-${index}`, + requestedCommit: index.toString(16).padStart(40, "0"), + })); + const masterDelivery = { + id: "42", + deliveryId: "master-durable-guid", + deliveredAt: "2026-07-11T22:00:00Z", + repository: "pikasTech/fixture", + ref: "refs/heads/main", + requestedCommit: head, + }; + const failedRedelivery = { + id: "43", + deliveryId: "failed-redelivery-same-head", + deliveredAt: "2026-07-11T23:59:59Z", + statusCode: 502, + repository: "pikasTech/fixture", + ref: "refs/heads/main", + requestedCommit: head, + }; + const selected = evaluate({ + action: "select-target-delivery", + deliveries: [...featureDeliveries, failedRedelivery, masterDelivery], + repository: "pikasTech/fixture", + ref: "refs/heads/main", + requestedCommit: head, + deliveryId: headRecord.deliveryId, + }); + expect(selected).toEqual(masterDelivery); + }); + test("selects only the exact branch-derived immutable snapshot", () => { const head = "b".repeat(40); const result = evaluate({ @@ -48,7 +118,48 @@ describe("Gitea source authority status evaluator", () => { expect(row.deliveryAccepted).toBe(false); expect(row.correlatedBridgeEvent).toBeNull(); expect(row.bridgeEventStale).toBe(true); - expect(row.staleReason).toBe("latest-push-delivery-failed-or-missing"); + expect(row.staleReason).toBe("target-branch-delivery-failed"); + }); + + test("selects the exact durable proof across duplicate exact-url hooks without hiding topology drift", () => { + const head = "8".repeat(40); + const row = evaluate({ + action: "evaluate-repository", + repo: { + key: "fixture", + upstream: { repository: "pikasTech/fixture", branch: "main" }, + snapshot: { prefix: "refs/snapshots/source" }, + }, + hook: { + hookId: 22, + hookReady: false, + active: true, + status: 200, + latest: { hookId: 11, deliveryId: "duplicate-unpersisted", event: "push", statusCode: 202 }, + latestPush: { hookId: 11, deliveryId: "duplicate-unpersisted", event: "push", statusCode: 202 }, + selectedPush: { hookId: 22, deliveryId: "durable-exact", event: "push", statusCode: 202, repository: "pikasTech/fixture", ref: "refs/heads/main", requestedCommit: head }, + deliverySelectionReason: "committed-durable-proof-for-github-head", + matchingHookCount: 2, + matchingHookIds: [11, 22], + hookTopologyReason: "github-hook-duplicate-exact-url", + }, + head: { ok: true, sha: head }, + mirror: { + branchCommit: head, + snapshotCommit: head, + snapshotRef: `refs/snapshots/source/${head}`, + }, + bridgeEvents: [terminalEvent("durable-exact", head)], + inboxRecords: [committedInbox("durable-exact", head)], + }); + expect(row.latestPushDelivery.deliveryId).toBe("duplicate-unpersisted"); + expect(row.selectedPushDelivery.deliveryId).toBe("durable-exact"); + expect(row.deliveryId).toBe("durable-exact"); + expect(row.deliverySelectionReason).toBe("committed-durable-proof-for-github-head"); + expect(row.durableInboxCommitted).toBe(true); + expect(row.bridgeEventStale).toBe(false); + expect(row.hookReady).toBe(false); + expect(row.hookTopologyReason).toBe("github-hook-duplicate-exact-url"); }); test("MATCH=false is always STALE=true and exact delivery correlation is repo isolated", () => { @@ -98,6 +209,7 @@ describe("Gitea source authority status evaluator", () => { branchCommit: head, snapshotCommit: head, deliveryId: "delivery-old", + requestedCommit: older, statusCode: 202, bridgeEvents: [event], inboxRecords: [committedInbox("delivery-old", older, head, "superseded-with-immutable-snapshot")], @@ -182,6 +294,7 @@ function evaluateRow(input: { inboxRecords?: Array>; deliveredAt?: string; journalCreatedAt?: string; + requestedCommit?: string; }): Record { return evaluate({ action: "evaluate-repository", @@ -196,7 +309,7 @@ function evaluateRow(input: { active: true, status: 200, latest: { deliveryId: input.deliveryId, event: "push", statusCode: input.statusCode, deliveredAt: input.deliveredAt }, - latestPush: { deliveryId: input.deliveryId, event: "push", statusCode: input.statusCode, deliveredAt: input.deliveredAt }, + latestPush: { deliveryId: input.deliveryId, event: "push", statusCode: input.statusCode, deliveredAt: input.deliveredAt, repository: "pikasTech/fixture", ref: "refs/heads/main", requestedCommit: input.requestedCommit ?? input.head }, }, head: { ok: true, sha: input.head }, mirror: { @@ -214,6 +327,7 @@ function committedInbox(deliveryId: string, sourceCommit: string, authorityCommi return { deliveryId, repo: "fixture", + requestedCommit: sourceCommit, state: "committed", result: { sourceCommit, diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index 7a20cb7b..11c558be 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -98,6 +98,22 @@ 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 }); + const apiDefaultedPolicy = { + ...policy, + spec: { + ...policy.spec, + matchConstraints: { + ...policy.spec.matchConstraints, + namespaceSelector: {}, + objectSelector: {}, + resourceRules: policy.spec.matchConstraints.resourceRules.map((rule: any) => ({ ...rule, scope: "*" })), + }, + }, + }; + expect(evaluator.evaluatePacAdmissionState({ ...input, policy: apiDefaultedPolicy })).toMatchObject({ + ready: true, + liveSpecSha256: expected.configSha256, + }); expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: true })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["default-serviceaccount-can-mutate-tekton-runs"]) }); expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: null })).toMatchObject({ ready: false, defaultCanMutate: null, reasons: expect.arrayContaining(["default-serviceaccount-mutation-probe-unavailable"]) }); expect(evaluator.evaluatePacAdmissionState({ ...input, role: { ...rbac[0], rules: [rbac[0].rules[0]] } })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["default-role-rules-mismatch"]) }); @@ -121,6 +137,32 @@ test("live admission evaluator requires exact desired hashes, observed generatio ...input, binding: { ...binding, spec: { ...binding.spec, matchResources: { namespaceSelector: { matchLabels: { disabled: "true" } } } } }, })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["admission-live-spec-sha256-mismatch"]) }); + expect(evaluator.evaluatePacAdmissionState({ + ...input, + policy: { + ...apiDefaultedPolicy, + spec: { + ...apiDefaultedPolicy.spec, + matchConstraints: { + ...apiDefaultedPolicy.spec.matchConstraints, + namespaceSelector: { matchLabels: { disabled: "true" } }, + }, + }, + }, + })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["admission-live-spec-sha256-mismatch"]) }); + expect(evaluator.evaluatePacAdmissionState({ + ...input, + policy: { + ...apiDefaultedPolicy, + spec: { + ...apiDefaultedPolicy.spec, + matchConstraints: { + ...apiDefaultedPolicy.spec.matchConstraints, + resourceRules: apiDefaultedPolicy.spec.matchConstraints.resourceRules.map((rule: any) => ({ ...rule, scope: "Namespaced" })), + }, + }, + }, + })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["admission-live-spec-sha256-mismatch"]) }); }); test("AgentRun owning renderer emits ordered terminal roles over the shared workspace without changing build and publish steps", () => { diff --git a/scripts/src/platform_infra_gitea_status_evaluator.py b/scripts/src/platform_infra_gitea_status_evaluator.py index b82ba33b..0e3f7f82 100644 --- a/scripts/src/platform_infra_gitea_status_evaluator.py +++ b/scripts/src/platform_infra_gitea_status_evaluator.py @@ -1,8 +1,47 @@ #!/usr/bin/env python3 import json +import re import sys +def parse_ls_remote_lines(lines): + refs = {} + for line in lines: + parts = line.split() + if len(parts) == 2: + refs[parts[1]] = parts[0] + return refs + + +def select_committed_head_record(repo_key, github_head, records): + candidates = [] + for record in records: + if not isinstance(record, dict) or record.get("repo") != repo_key or record.get("state") != "committed": + continue + result = record.get("result") if isinstance(record.get("result"), dict) else {} + if record.get("requestedCommit") != github_head or result.get("sourceCommit") != github_head: + continue + if result.get("authorityCommit") not in (None, github_head): + continue + candidates.append(record) + return max(candidates, key=lambda item: (int(item.get("acceptedSequence") or 0), str(item.get("committedAt") or "")), default=None) + + +def select_target_delivery(deliveries, repository, ref, requested_commit=None, delivery_id=None): + candidates = [] + for delivery in deliveries: + if not isinstance(delivery, dict): + continue + if delivery.get("repository") != repository or delivery.get("ref") != ref: + continue + if requested_commit is not None and delivery.get("requestedCommit") != requested_commit: + continue + if delivery_id is not None and _delivery_id(delivery) != str(delivery_id): + continue + candidates.append(delivery) + return max(candidates, key=lambda item: (str(item.get("deliveredAt") or ""), str(item.get("id") or "")), default=None) + + def select_snapshot(branch_commit, snapshot_prefix, refs): if not branch_commit: return { @@ -30,10 +69,21 @@ def evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records=N snapshot_ref = mirror.get("snapshotRef") latest_delivery = hook.get("latest") latest_push_delivery = hook.get("latestPush") - delivery_id = _delivery_id(latest_push_delivery) + selected_push_delivery = hook.get("selectedPush") or latest_push_delivery + delivery_id = _delivery_id(selected_push_delivery) + delivery_repository = selected_push_delivery.get("repository") if isinstance(selected_push_delivery, dict) else None + delivery_ref = selected_push_delivery.get("ref") if isinstance(selected_push_delivery, dict) else None + delivery_requested_commit = selected_push_delivery.get("requestedCommit") if isinstance(selected_push_delivery, dict) else None + delivery_scope_matches = bool( + delivery_repository == repo["upstream"]["repository"] + and delivery_ref == f"refs/heads/{repo['upstream']['branch']}" + and isinstance(delivery_requested_commit, str) + and re.fullmatch(r"[0-9a-f]{40,64}", delivery_requested_commit) + ) delivery_accepted = bool( - isinstance(latest_push_delivery, dict) - and latest_push_delivery.get("statusCode") in (200, 202) + isinstance(selected_push_delivery, dict) + and delivery_scope_matches + and selected_push_delivery.get("statusCode") in (200, 202) ) correlated_inbox_records = [ record for record in inbox_records @@ -41,7 +91,7 @@ def evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records=N and record.get("deliveryId") == delivery_id ] inbox_record = correlated_inbox_records[-1] if correlated_inbox_records else None - pre_durable_bootstrap = _is_pre_durable_bootstrap(latest_push_delivery, inbox_record, journal) + pre_durable_bootstrap = _is_pre_durable_bootstrap(selected_push_delivery, inbox_record, journal) inbox_state = inbox_record.get("state") if inbox_record else ("pre-durable-bootstrap" if pre_durable_bootstrap else None) inbox_result = inbox_record.get("result") if isinstance(inbox_record, dict) and isinstance(inbox_record.get("result"), dict) else {} correlated = [ @@ -63,15 +113,17 @@ def evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records=N inbox_committed = bool( inbox_record and inbox_state == "committed" + and inbox_record.get("requestedCommit") == delivery_requested_commit + and inbox_result.get("sourceCommit") == delivery_requested_commit and ( - inbox_result.get("sourceCommit") == github_head + delivery_requested_commit == github_head or ( inbox_result.get("disposition") == "superseded-with-immutable-snapshot" and inbox_result.get("authorityCommit") == github_head ) ) ) - durable_proof_acceptable = inbox_committed or pre_durable_bootstrap + durable_proof_acceptable = inbox_committed or (pre_durable_bootstrap and delivery_requested_commit == github_head) stale = not ( authority_matches and delivery_accepted @@ -81,6 +133,7 @@ def evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records=N hook, head, delivery_accepted, + delivery_scope_matches, inbox_record, pre_durable_bootstrap, github_head, @@ -106,7 +159,16 @@ def evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records=N "authorityMatches": authority_matches, "latestDelivery": latest_delivery, "latestPushDelivery": latest_push_delivery, + "selectedPushDelivery": selected_push_delivery, + "deliverySelectionReason": hook.get("deliverySelectionReason") or "latest-push-delivery", + "matchingHookCount": hook.get("matchingHookCount"), + "matchingHookIds": hook.get("matchingHookIds") or [], + "hookTopologyReason": hook.get("hookTopologyReason"), "deliveryId": delivery_id, + "deliveryRepository": delivery_repository, + "deliveryRef": delivery_ref, + "deliveryRequestedCommit": delivery_requested_commit, + "deliveryScopeMatches": delivery_scope_matches, "correlatedBridgeEvent": correlated_event, "durableInboxRecord": inbox_record, "durableInboxState": inbox_state, @@ -126,13 +188,15 @@ def _delivery_id(delivery): return str(delivery.get("deliveryId") or delivery.get("guid") or delivery.get("id") or "") -def _stale_reason(hook, head, delivery_accepted, inbox_record, pre_durable_bootstrap, github_head, branch_matches, snapshot_matches): +def _stale_reason(hook, head, delivery_accepted, delivery_scope_matches, inbox_record, pre_durable_bootstrap, github_head, branch_matches, snapshot_matches): if hook.get("hookReady") is not True: return "github-hook-not-ready" if head.get("ok") is not True or not github_head: return "github-head-unavailable" + if not delivery_scope_matches: + return "target-branch-delivery-scope-mismatch-or-missing" if not delivery_accepted: - return "latest-push-delivery-failed-or-missing" + return "target-branch-delivery-failed" if not inbox_record and not pre_durable_bootstrap: return "no-correlated-durable-inbox-record" if not pre_durable_bootstrap: @@ -140,12 +204,17 @@ def _stale_reason(hook, head, delivery_accepted, inbox_record, pre_durable_boots if inbox_state != "committed": return f"durable-inbox-{inbox_state or 'unknown'}-not-committed" result = inbox_record.get("result") if isinstance(inbox_record.get("result"), dict) else {} + delivery_requested_commit = (hook.get("selectedPush") or hook.get("latestPush") or {}).get("requestedCommit") inbox_proves_head = ( - result.get("sourceCommit") == github_head + inbox_record.get("requestedCommit") == delivery_requested_commit + and result.get("sourceCommit") == delivery_requested_commit + and ( + delivery_requested_commit == github_head or ( result.get("disposition") == "superseded-with-immutable-snapshot" and result.get("authorityCommit") == github_head ) + ) ) if not inbox_proves_head: return "correlated-inbox-proof-head-mismatch" @@ -183,7 +252,13 @@ def _error(hook, head, stale_reason): def main(): payload = json.load(sys.stdin) action = payload.get("action") - if action == "select-snapshot": + if action == "parse-ls-remote": + result = parse_ls_remote_lines(str(payload.get("output") or "").splitlines()) + elif action == "select-committed-head-record": + result = select_committed_head_record(payload.get("repoKey"), payload.get("githubHead"), payload.get("records", [])) + elif action == "select-target-delivery": + result = select_target_delivery(payload.get("deliveries", []), payload.get("repository"), payload.get("ref"), payload.get("requestedCommit"), payload.get("deliveryId")) + elif action == "select-snapshot": result = select_snapshot(payload.get("branchCommit"), payload["snapshotPrefix"], payload.get("refs", {})) elif action == "evaluate-repository": result = evaluate_repository(payload["repo"], payload["hook"], payload["head"], payload.get("mirror", {}), payload.get("bridgeEvents", []), payload.get("inboxRecords", []), payload.get("journal")) From ff5b2d173210b96d29e07edf7009526c954a0cc1 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 00:50:40 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(cicd):=20=E7=B2=BE=E7=A1=AE=E5=85=81?= =?UTF-8?q?=E8=AE=B8=20PaC=20watcher=20=E5=90=AF=E5=8A=A8=E9=98=9F?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/pipelines-as-code.yaml | 19 ++++- .../src/platform-infra-pac-provenance.test.ts | 53 ++++++++++-- scripts/src/platform-infra-pac-provenance.ts | 81 ++++++++++++++++++- 3 files changed, 142 insertions(+), 11 deletions(-) diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 35272334..4b53e015 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -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 diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index 11c558be..381623b2 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -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", () => { diff --git a/scripts/src/platform-infra-pac-provenance.ts b/scripts/src/platform-infra-pac-provenance.ts index f7df6f5d..94983b12 100644 --- a/scripts/src/platform-infra-pac-provenance.ts +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -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): 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): 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[]; From 08071ef432b89d62835d8d533b17f4184ffff5a5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 01:00:29 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(cicd):=20=E4=BF=AE=E6=AD=A3=20PaC=20pip?= =?UTF-8?q?elineSpec=20CEL=20=E5=8A=A8=E6=80=81=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/platform-infra-pac-provenance.test.ts | 22 ++++++++++++++++++- scripts/src/platform-infra-pac-provenance.ts | 8 +++++++ 2 files changed, 29 insertions(+), 1 deletion(-) 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)}`;