diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index e23e686d..4b53e015 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -11,6 +11,9 @@ metadata: - 1560 - 1649 - 1760 + - 1769 + - 1802 + - 1811 defaults: targetId: NC01 consumerId: hwlab-nc01-v03 @@ -23,6 +26,32 @@ release: controllerServiceName: pipelines-as-code-controller controllerServicePort: 8080 waitTimeoutSeconds: 55 +deliveryProvenance: + version: admission-pac-v2 + markerAnnotation: unidesk.ai/pac-admission-provenance + child: + enabled: false + parentNameAnnotation: unidesk.ai/pac-parent-pipelinerun + parentUidAnnotation: unidesk.ai/pac-parent-pipelinerun-uid + 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-v2 + bindingName: unidesk-pac-delivery-provenance-v2 + failurePolicy: Fail + validationActions: + - Deny capabilities: sentinelInternalPublish: enabled: false @@ -110,6 +139,13 @@ consumers: variables: NODE: NC01 LANE: v02 + deliveryProvenance: + required: true + 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 + targetRevision: nc01-v0.2-gitops sourceArtifact: mode: embedded-pipeline-spec renderer: agentrun-control-plane diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index e641be48..f14ffb46 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -1,5 +1,7 @@ "use strict"; +const { createHash } = require("node:crypto"); + function record(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } @@ -37,12 +39,160 @@ function pipelineRunMatchesConsumer(consumerInput, itemInput) { const item = record(itemInput); const metadata = record(item.metadata); const labels = record(metadata.labels); + const spec = record(item.spec); + const pipelineRef = record(spec.pipelineRef); const name = stringOrNull(metadata.name) || ""; const prefix = stringOrNull(consumer.pipelineRunPrefix) || ""; const pipeline = stringOrNull(consumer.pipeline) || ""; return (prefix.length > 0 && name.startsWith(prefix)) || (pipeline.length > 0 && labels["tekton.dev/pipeline"] === pipeline) - || (pipeline.length > 0 && labels["tekton.dev/pipelineName"] === pipeline); + || (pipeline.length > 0 && labels["tekton.dev/pipelineName"] === pipeline) + || (pipeline.length > 0 && pipelineRef.name === pipeline); +} + +function stableCanonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(stableCanonicalJson).join(",")}]`; + if (typeof value === "object" && value !== null) { + return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${stableCanonicalJson(item)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function liveAdmissionSpecSha256(targetId, policyName, bindingName, policySpecInput, bindingSpecInput) { + const policySpec = normalizeAdmissionPolicySpec(policySpecInput); + const bindingSpec = normalizeAdmissionBindingSpec(bindingSpecInput); + const normalized = { + targetId, + policyName, + bindingName, + policySpec, + bindingSpec, + }; + return `sha256:${createHash("sha256").update(stableCanonicalJson(normalized)).digest("hex")}`; +} + +function normalizeAdmissionPolicySpec(value) { + const spec = { ...record(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; +} + +function normalizeAdmissionBindingSpec(value) { + const spec = { ...record(value) }; + if (Object.prototype.hasOwnProperty.call(spec, "matchResources")) { + const matchResources = { ...record(spec.matchResources) }; + if (!Object.prototype.hasOwnProperty.call(matchResources, "matchPolicy")) matchResources.matchPolicy = "Equivalent"; + spec.matchResources = matchResources; + } + 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); + const binding = record(input.binding); + const role = record(input.role); + const roleBinding = record(input.roleBinding); + const expected = record(input.expected); + const namespace = stringOrNull(input.namespace); + const policyMetadata = record(policy.metadata); + const bindingMetadata = record(binding.metadata); + const roleMetadata = record(role.metadata); + const roleBindingMetadata = record(roleBinding.metadata); + const policyAnnotations = record(policyMetadata.annotations); + const bindingAnnotations = record(bindingMetadata.annotations); + const roleAnnotations = record(roleMetadata.annotations); + const roleBindingAnnotations = record(roleBindingMetadata.annotations); + const policySpec = record(policy.spec); + const bindingSpec = record(binding.spec); + const policyStatus = record(policy.status); + const typeChecking = record(policyStatus.typeChecking); + const warnings = Array.isArray(typeChecking.expressionWarnings) ? typeChecking.expressionWarnings : []; + const reasons = []; + const targetId = stringOrNull(expected.targetId); + const liveSpecSha256 = targetId === null + ? null + : liveAdmissionSpecSha256(targetId, expected.policyName, expected.bindingName, policySpec, bindingSpec); + if (targetId === null) reasons.push("admission-target-id-missing"); + if (liveSpecSha256 !== expected.configSha256) reasons.push("admission-live-spec-sha256-mismatch"); + if (policyMetadata.name !== expected.policyName) reasons.push("policy-missing"); + if (bindingMetadata.name !== expected.bindingName) reasons.push("binding-missing"); + for (const [kind, annotations] of [["policy", policyAnnotations], ["binding", bindingAnnotations]]) { + if (annotations["unidesk.ai/pac-provenance-version"] !== expected.version) reasons.push(`${kind}-version-mismatch`); + if (annotations["unidesk.ai/pac-controller-identity"] !== expected.controllerIdentity) reasons.push(`${kind}-controller-identity-mismatch`); + if (annotations["unidesk.ai/effective-config-sha256"] !== expected.configSha256) reasons.push(`${kind}-config-sha256-mismatch`); + } + if (policySpec.failurePolicy !== "Fail") reasons.push("policy-failure-policy-not-fail"); + if (bindingSpec.policyName !== expected.policyName) reasons.push("binding-policy-name-mismatch"); + if (!Array.isArray(bindingSpec.validationActions) || bindingSpec.validationActions.length !== 1 || bindingSpec.validationActions[0] !== "Deny") reasons.push("binding-validation-actions-not-deny"); + if (!Number.isInteger(policyMetadata.generation) || !Number.isInteger(policyStatus.observedGeneration) || policyStatus.observedGeneration < policyMetadata.generation) reasons.push("policy-generation-not-observed"); + if (warnings.length > 0) reasons.push("policy-expression-warning"); + if (roleMetadata.name !== "unidesk-pac-consumer-runner") reasons.push("default-role-missing"); + if (roleBindingMetadata.name !== "unidesk-pac-consumer-runner-default") reasons.push("default-rolebinding-missing"); + if (roleAnnotations["unidesk.ai/effective-config-sha256"] !== expected.rbacConfigSha256) reasons.push("default-role-config-sha256-mismatch"); + if (roleBindingAnnotations["unidesk.ai/effective-config-sha256"] !== expected.rbacConfigSha256) reasons.push("default-rolebinding-config-sha256-mismatch"); + const forbiddenVerbs = new Set(["create", "patch", "update", "delete", "deletecollection"]); + const roleRules = Array.isArray(role.rules) ? role.rules : []; + const roleHasMutation = roleRules.some((ruleValue) => { + const rule = record(ruleValue); + return (Array.isArray(rule.verbs) ? rule.verbs : []).some((verb) => forbiddenVerbs.has(verb)); + }); + if (roleHasMutation) reasons.push("default-role-declares-mutation"); + const expectedRoleRules = [ + { apiGroups: ["tekton.dev"], resources: ["pipelineruns", "taskruns"], verbs: ["get", "list", "watch"] }, + { apiGroups: [""], resources: ["pods", "pods/log"], verbs: ["get", "list", "watch"] }, + ]; + if (stableCanonicalJson(roleRules) !== stableCanonicalJson(expectedRoleRules)) reasons.push("default-role-rules-mismatch"); + const roleRef = record(roleBinding.roleRef); + const subjects = Array.isArray(roleBinding.subjects) ? roleBinding.subjects.map(record) : []; + if (roleRef.apiGroup !== "rbac.authorization.k8s.io" || roleRef.kind !== "Role" || roleRef.name !== "unidesk-pac-consumer-runner") reasons.push("default-rolebinding-role-ref-mismatch"); + if (namespace === null || subjects.length !== 1 || subjects[0].kind !== "ServiceAccount" || subjects[0].name !== "default" || subjects[0].namespace !== namespace) reasons.push("default-rolebinding-subject-mismatch"); + if (typeof input.defaultCanMutate !== "boolean") reasons.push("default-serviceaccount-mutation-probe-unavailable"); + if (input.defaultCanMutate === true) reasons.push("default-serviceaccount-can-mutate-tekton-runs"); + const timestamps = [policyMetadata.creationTimestamp, bindingMetadata.creationTimestamp] + .filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value))) + .sort((left, right) => Date.parse(left) - Date.parse(right)); + const activeAfter = timestamps.length === 2 ? timestamps[1] : null; + if (activeAfter === null) reasons.push("admission-creation-time-missing"); + return { + configured: true, + required: true, + ready: reasons.length === 0, + source: "live-admissionregistration-v1", + policyName: stringOrNull(policyMetadata.name), + bindingName: stringOrNull(bindingMetadata.name), + version: stringOrNull(policyAnnotations["unidesk.ai/pac-provenance-version"]), + controllerIdentity: stringOrNull(policyAnnotations["unidesk.ai/pac-controller-identity"]), + configSha256: stringOrNull(policyAnnotations["unidesk.ai/effective-config-sha256"]), + liveSpecSha256, + policyUid: stringOrNull(policyMetadata.uid), + bindingUid: stringOrNull(bindingMetadata.uid), + policyGeneration: Number.isInteger(policyMetadata.generation) ? policyMetadata.generation : null, + observedGeneration: Number.isInteger(policyStatus.observedGeneration) ? policyStatus.observedGeneration : null, + rbacConfigSha256: stringOrNull(roleAnnotations["unidesk.ai/effective-config-sha256"]), + defaultCanMutate: typeof input.defaultCanMutate === "boolean" ? input.defaultCanMutate : null, + activeAfter, + reasons, + valuesPrinted: false, + }; } function classifyPacPipelineRun(consumerInput, itemInput) { @@ -63,6 +213,8 @@ function classifyPacPipelineRun(consumerInput, itemInput) { const managedBy = stringOrNull(labels["app.kubernetes.io/managed-by"]); const provider = stringOrNull(annotations["pipelinesascode.tekton.dev/git-provider"]); const expectedRepository = stringOrNull(consumer.repository); + const provenance = record(consumer.deliveryProvenance); + const admissionState = record(consumer.admissionState); const outer = candidate && eventType === "push" && expectedRepository !== null @@ -75,6 +227,33 @@ function classifyPacPipelineRun(consumerInput, itemInput) { && labels["unidesk.ai/ci-system"] === "tekton" && annotations["unidesk.ai/source-authority"] === "gitea-snapshot" && typeof annotations["unidesk.ai/publish-gitops"] === "string"; + const provenanceRequired = provenance.required === true; + const markerAnnotation = stringOrNull(provenance.markerAnnotation); + const markerValue = stringOrNull(provenance.markerValue); + const executionServiceAccountName = stringOrNull(provenance.executionServiceAccountName); + const taskRunTemplate = record(record(item.spec).taskRunTemplate); + const createdAt = stringOrNull(metadata.creationTimestamp); + const activeAfter = stringOrNull(admissionState.activeAfter); + const createdAfterAdmission = createdAt !== null + && activeAfter !== null + && Number.isFinite(Date.parse(createdAt)) + && Number.isFinite(Date.parse(activeAfter)) + && Date.parse(createdAt) >= Date.parse(activeAfter); + const admissionProvenanceVerified = outer + && provenanceRequired + && admissionState.ready === true + && admissionState.source === "live-admissionregistration-v1" + && admissionState.policyName === provenance.policyName + && admissionState.bindingName === provenance.bindingName + && admissionState.version === provenance.version + && admissionState.controllerIdentity === provenance.controllerIdentity + && admissionState.configSha256 === provenance.configSha256 + && markerAnnotation !== null + && markerValue !== null + && annotations[markerAnnotation] === markerValue + && executionServiceAccountName !== null + && taskRunTemplate.serviceAccountName === executionServiceAccountName + && createdAfterAdmission; const deliveryClass = outer ? "outer-pac-event" @@ -82,7 +261,11 @@ function classifyPacPipelineRun(consumerInput, itemInput) { ? "inner-deterministic-publish" : "unknown"; const reason = outer - ? "observed-pac-push-metadata-matches-consumer" + ? admissionProvenanceVerified + ? "admission-owned-pac-push-provenance-verified" + : provenanceRequired + ? "pac-push-metadata-observed-but-admission-provenance-unverified" + : "observed-pac-push-metadata-matches-consumer" : inner ? "deterministic-sentinel-publish-without-proven-parent" : candidate @@ -92,13 +275,19 @@ function classifyPacPipelineRun(consumerInput, itemInput) { deliveryClass, reason, candidate, - deliveryAuthorityEligible: outer, - deliveryOwner: outer ? "pac-controller-observed" : null, + deliveryAuthorityEligible: outer && (!provenanceRequired || admissionProvenanceVerified), + deliveryOwner: outer ? admissionProvenanceVerified ? "pac-controller-admission" : "pac-controller-observed" : null, executionOwner: inner ? "tekton-child-unattached" : null, parentPipelineRun: null, parentRelation: inner ? "unproven" : null, - metadataObservationOnly: outer, - admissionProvenanceVerified: false, + metadataObservationOnly: outer && !admissionProvenanceVerified, + admissionProvenanceVerified, + admissionPolicyReady: admissionState.ready === true, + admissionPolicyName: stringOrNull(admissionState.policyName), + admissionBindingName: stringOrNull(admissionState.bindingName), + admissionConfigSha256: stringOrNull(admissionState.configSha256), + admissionActiveAfter: activeAfter, + createdAfterAdmission, valuesPrinted: false, }; } @@ -173,7 +362,7 @@ function terminalSource(task, marker, markerStatus = "skipped", markerSource = " function extractPacSourceObservation(recordsInput) { const records = Array.isArray(recordsInput) ? recordsInput.map(record) : []; - const plan = [...records].reverse().find((item) => item.event === "g14-ci-plan") || null; + const plan = [...records].reverse().find((item) => item.event === "pac-delivery-plan" || item.event === "g14-ci-plan") || null; const collectMarker = exactSkipMarker(records, "collect-artifacts"); const promoteMarker = exactSkipMarker(records, "gitops-promote"); const planTask = exactTaskTerminal(records, "plan-artifacts"); @@ -225,9 +414,14 @@ 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: "hwlab-g14-ci-plan", + contract: plan?.event === "pac-delivery-plan" ? "pac-delivery-plan-v1" : "hwlab-g14-ci-plan", mode, valid, reason, @@ -244,7 +438,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), }, @@ -721,6 +915,18 @@ function runPacStatusFixtureChecks() { expectedCode: "pac-ready-gitops-exact", input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery, catalog: publishedCatalog() }, argo: exactArgo, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }), }, + { + id: "delivery-ready-descendant", + expectedOk: true, + expectedCode: "pac-ready-gitops-descendant", + input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery, catalog: publishedCatalog() }, argo: { ...exactArgo, revision: "d".repeat(40), revisionRelation: { relation: "descendant", expectedRevision: revision, observedRevision: "d".repeat(40) } }, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }), + }, + { + id: "delivery-stale-gitops-fails-closed", + expectedOk: false, + expectedCode: "pac-argo-revision-stale", + input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery, catalog: publishedCatalog() }, argo: { ...exactArgo, revision: "d".repeat(40), revisionRelation: { relation: "stale", expectedRevision: revision, observedRevision: "d".repeat(40) } }, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }), + }, { id: "delivery-gitops-missing", expectedOk: false, @@ -802,11 +1008,77 @@ function runPacStatusFixtureChecks() { actualCode: result.code, }; }); + const provenance = { + required: true, + version: "admission-pac-v1", + controllerIdentity: "system:serviceaccount:pipelines-as-code:pipelines-as-code-controller", + policyName: "unidesk-pac-delivery-provenance-v1", + bindingName: "unidesk-pac-delivery-provenance-v1", + configSha256: `sha256:${"1".repeat(64)}`, + markerAnnotation: "unidesk.ai/pac-admission-provenance", + markerValue: "admission-pac-v1:fixture", + executionServiceAccountName: "fixture-pac-runner", + }; + const admissionState = { + ready: true, + source: "live-admissionregistration-v1", + policyName: provenance.policyName, + bindingName: provenance.bindingName, + version: provenance.version, + controllerIdentity: provenance.controllerIdentity, + configSha256: provenance.configSha256, + activeAfter: "2026-01-01T00:00:00Z", + }; + const consumer = { + id: "fixture", + repository: "fixture-repository", + pipeline: "fixture-pipeline", + pipelineRunPrefix: "fixture-run-", + deliveryProvenance: provenance, + admissionState, + }; + const verifiedRun = { + metadata: { + name: "fixture-run-verified", + creationTimestamp: "2026-01-01T00:00:01Z", + labels: { + "app.kubernetes.io/managed-by": "pipelinesascode.tekton.dev", + "pipelinesascode.tekton.dev/event-type": "push", + "pipelinesascode.tekton.dev/repository": "fixture-repository", + }, + annotations: { + "pipelinesascode.tekton.dev/git-provider": "gitea", + [provenance.markerAnnotation]: provenance.markerValue, + }, + }, + spec: { taskRunTemplate: { serviceAccountName: provenance.executionServiceAccountName } }, + }; + const classificationCases = [ + { id: "admission-provenance-verified", expected: true, item: verifiedRun, state: admissionState }, + { id: "pre-bootstrap-run-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, creationTimestamp: "2025-12-31T23:59:59Z" } }, state: admissionState }, + { id: "forged-name-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState }, + { id: "forged-label-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", labels: { ...verifiedRun.metadata.labels, "tekton.dev/pipeline": consumer.pipeline }, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState }, + { id: "forged-pipeline-ref-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } }, spec: { ...verifiedRun.spec, pipelineRef: { name: consumer.pipeline } } }, state: admissionState }, + { id: "wrong-service-account-fails-closed", expected: false, item: { ...verifiedRun, spec: { taskRunTemplate: { serviceAccountName: "default" } } }, state: admissionState }, + { id: "policy-identity-mismatch-fails-closed", expected: false, item: verifiedRun, state: { ...admissionState, configSha256: `sha256:${"2".repeat(64)}` } }, + ]; + for (const item of classificationCases) { + const result = classifyPacPipelineRun({ ...consumer, admissionState: item.state }, item.item); + checks.push({ + id: item.id, + ok: result.admissionProvenanceVerified === item.expected && result.deliveryAuthorityEligible === item.expected, + expectedOk: item.expected, + actualOk: result.deliveryAuthorityEligible, + expectedCode: item.expected ? "admission-owned-pac-push-provenance-verified" : "pac-push-metadata-observed-but-admission-provenance-unverified", + actualCode: result.reason, + }); + } return { ok: checks.every((item) => item.ok), checks, valuesPrinted: false }; } module.exports = { classifyPacPipelineRun, + evaluatePacAdmissionState, evaluatePacStatus, extractPacArtifactEvidence, extractPacSourceObservation, diff --git a/scripts/src/agentrun-manifests.ts b/scripts/src/agentrun-manifests.ts index c1833aa5..05b67b80 100644 --- a/scripts/src/agentrun-manifests.ts +++ b/scripts/src/agentrun-manifests.ts @@ -218,83 +218,13 @@ export function renderAgentRunPipelineManifest(spec: AgentRunLaneSpec): Record { - return { - name: "build-publish", - workspaces: [{ name: "source", workspace: "source" }], - taskSpec: { - params: [ - { name: "git-read-url" }, - { name: "git-write-url" }, - { name: "source-branch" }, - { name: "gitops-branch" }, - { name: "revision" }, - { name: "source-stage-ref" }, - { name: "registry-prefix" }, - { name: "tools-image" }, - { name: "buildkit-image" }, - { name: "containerfile" }, - { name: "context-dir" }, - { name: "image-repository" }, - { name: "build-network" }, - { name: "build-args-json" }, - { name: "build-http-proxy" }, - { name: "build-https-proxy" }, - { name: "build-no-proxy" }, - { name: "container-http-proxy" }, - { name: "container-https-proxy" }, - { name: "container-no-proxy" }, - { name: "env-identity-files-json" }, - { name: "gitops-root" }, - { name: "artifact-catalog" }, - ], - workspaces: [{ name: "source" }], - steps: [ - { - name: "source-checkout", - image: "$(params.tools-image)", - script: agentRunTektonSourceCheckoutScript(), - }, - { - name: "probe-env-image", - image: "$(params.tools-image)", - script: agentRunTektonProbeImageScript(), - }, - { - name: "build-env-image", - image: "$(params.buildkit-image)", - env: [ - { name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" }, - { name: "HTTP_PROXY", value: "$(params.build-http-proxy)" }, - { name: "http_proxy", value: "$(params.build-http-proxy)" }, - { name: "HTTPS_PROXY", value: "$(params.build-https-proxy)" }, - { name: "https_proxy", value: "$(params.build-https-proxy)" }, - { name: "ALL_PROXY", value: "$(params.build-https-proxy)" }, - { name: "all_proxy", value: "$(params.build-https-proxy)" }, - { name: "NO_PROXY", value: "$(params.build-no-proxy)" }, - { name: "no_proxy", value: "$(params.build-no-proxy)" }, - ], - securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 }, - script: agentRunTektonBuildImageScript(), - }, - { - name: "publish-gitops", - image: "$(params.tools-image)", - env: [ - { name: "GITEA_TOKEN", valueFrom: { secretKeyRef: { name: agentRunPacGiteaSecretName(spec), key: "token", optional: true } } }, - ], - script: agentRunTektonGitopsPublishScript(spec), - }, - ], - }, - params: [ +function agentRunBuildPublishTasks(spec: AgentRunLaneSpec): readonly Record[] { + const params = [ { name: "git-read-url", value: "$(params.git-read-url)" }, { name: "git-write-url", value: "$(params.git-write-url)" }, { name: "source-branch", value: "$(params.source-branch)" }, @@ -318,9 +248,45 @@ function agentRunBuildPublishTask(spec: AgentRunLaneSpec): Record ({ name })); + const task = (name: string, steps: readonly Record[], runAfter: readonly string[] = []): Record => ({ + name, + ...(runAfter.length === 0 ? {} : { runAfter }), + workspaces: [{ name: "source", workspace: "source" }], + taskSpec: { params: taskParams, workspaces: [{ name: "source" }], steps }, + params, when: [{ input: spec.deployment.format, operator: "in", values: ["unidesk-yaml-only"] }], - }; + }); + return [ + task("plan-artifacts", [ + { name: "source-checkout", image: "$(params.tools-image)", script: agentRunTektonSourceCheckoutScript() }, + { name: "probe-env-image", image: "$(params.tools-image)", script: agentRunTektonProbeImageScript() }, + ]), + task("collect-artifacts", [{ + name: "build-env-image", + image: "$(params.buildkit-image)", + env: [ + { name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" }, + { name: "HTTP_PROXY", value: "$(params.build-http-proxy)" }, + { name: "http_proxy", value: "$(params.build-http-proxy)" }, + { name: "HTTPS_PROXY", value: "$(params.build-https-proxy)" }, + { name: "https_proxy", value: "$(params.build-https-proxy)" }, + { name: "ALL_PROXY", value: "$(params.build-https-proxy)" }, + { name: "all_proxy", value: "$(params.build-https-proxy)" }, + { name: "NO_PROXY", value: "$(params.build-no-proxy)" }, + { name: "no_proxy", value: "$(params.build-no-proxy)" }, + ], + securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 }, + script: agentRunTektonBuildImageScript(), + }], ["plan-artifacts"]), + task("gitops-promote", [{ + name: "publish-gitops", + image: "$(params.tools-image)", + env: [{ name: "GITEA_TOKEN", valueFrom: { secretKeyRef: { name: agentRunPacGiteaSecretName(spec), key: "token", optional: true } } }], + script: agentRunTektonGitopsPublishScript(spec), + }], ["collect-artifacts"]), + ]; } function agentRunTektonSourceCheckoutScript(): string { @@ -399,6 +365,8 @@ function agentRunTektonProbeImageScript(): string { "else", " printf '{\"ok\":false,\"status\":\"cache-miss\",\"sourceCommit\":\"%s\",\"envIdentity\":\"%s\",\"valuesPrinted\":false}\\n' \"$(params.revision)\" \"$env_identity\" > \"$root/build-result.json\"", "fi", + "if [ -f \"$root/skip-build\" ]; then build_services='[]'; reused_services='[\"agentrun-mgr\"]'; else build_services='[\"agentrun-mgr\"]'; reused_services='[]'; fi", + "printf '{\"event\":\"pac-delivery-plan\",\"schemaVersion\":\"v1\",\"sourceCommitId\":\"%s\",\"affectedServices\":[\"agentrun-mgr\"],\"rolloutServices\":[\"agentrun-mgr\"],\"buildServices\":%s,\"reusedServices\":%s,\"valuesPrinted\":false}\\n' \"$(params.revision)\" \"$build_services\" \"$reused_services\"", "chmod a+rw \"$root/build-result.json\"", "cat \"$root/build-result.json\"", ].join("\n"); diff --git a/scripts/src/platform-infra-gitea-desired-fragments.ts b/scripts/src/platform-infra-gitea-desired-fragments.ts new file mode 100644 index 00000000..00f6983a --- /dev/null +++ b/scripts/src/platform-infra-gitea-desired-fragments.ts @@ -0,0 +1,13 @@ +import { renderPacAdmissionDesiredFragment } from "./platform-infra-pac-provenance"; +import { renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac"; + +export function renderPlatformInfraGiteaDesiredFragments(targetId: string): string { + return [ + renderPacConsumerRbacDesiredFragment(targetId), + renderPacAdmissionDesiredFragment(targetId), + ].map(normalizeFragment).filter((item) => item.length > 0).join("\n---\n"); +} + +function normalizeFragment(value: string): string { + return value.trim().replace(/^(?:---\s*)+|(?:\s*---)+$/gu, ""); +} diff --git a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts index 39ce0515..c382600c 100644 --- a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts +++ b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts @@ -67,5 +67,17 @@ test("owning YAML renders one child Application and the complete durable bridge expect(manifest).toContain("name: candidate-inbox\n emptyDir: {}"); expect(manifest).toContain('gate: "gitea-github-sync-candidate-startup"'); const documents = manifest.split(/^---\s*$/mu).map((item) => item.trim()).filter(Boolean).map((item) => Bun.YAML.parse(item) as any); - expect(documents.map((item) => item.kind)).toEqual(["ConfigMap", "Job", "ServiceAccount", "ConfigMap", "PersistentVolumeClaim", "Service", "Deployment"]); + expect(documents.map((item) => item.kind)).toEqual([ + "ConfigMap", + "Job", + "ServiceAccount", + "ConfigMap", + "PersistentVolumeClaim", + "Service", + "Deployment", + "Role", + "RoleBinding", + "ValidatingAdmissionPolicy", + "ValidatingAdmissionPolicyBinding", + ]); }); 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-gitea.ts b/scripts/src/platform-infra-gitea.ts index fbc49396..05323626 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -21,6 +21,7 @@ import type { GiteaWebhookIngressRetry } from "./platform-infra-gitea-caddy"; import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } from "./platform-infra-gitea-payload"; import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets"; import { PAC_AUTOMATIC_DELIVERY_REFERENCE } from "./cicd-delivery-authority"; +import { renderPlatformInfraGiteaDesiredFragments } from "./platform-infra-gitea-desired-fragments"; const configFile = rootPath("config", "platform-infra", "gitea.yaml"); const configLabel = "config/platform-infra/gitea.yaml"; @@ -1628,7 +1629,10 @@ export function renderGiteaWebhookSyncDesiredManifest(targetId: string): string const target = resolveTarget(gitea, targetId); if (!delivery.enabled) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.enabled must be true`); if (target.id !== delivery.targetId) throw new Error(`GitOps delivery target ${delivery.targetId} does not match requested target ${target.id}`); - return renderGithubSyncManifest(gitea, target); + return [ + renderGithubSyncManifest(gitea, target).trim(), + renderPlatformInfraGiteaDesiredFragments(target.id).trim(), + ].filter((item) => item.length > 0).join("\n---\n") + "\n"; } export function readGiteaWebhookGitOpsDelivery(): GiteaWebhookGitOpsDelivery { diff --git a/scripts/src/platform-infra-pac-consumer-rbac.ts b/scripts/src/platform-infra-pac-consumer-rbac.ts new file mode 100644 index 00000000..58ee9a87 --- /dev/null +++ b/scripts/src/platform-infra-pac-consumer-rbac.ts @@ -0,0 +1,67 @@ +import { createHash } from "node:crypto"; + +import { readPacAdmissionContract } from "./platform-infra-pac-provenance"; + +export interface PacConsumerRbacDesiredIdentity { + readonly configSha256: string; + readonly namespaces: readonly string[]; +} + +const pacConsumerReadOnlyRules = [ + { apiGroups: ["tekton.dev"], resources: ["pipelineruns", "taskruns"], verbs: ["get", "list", "watch"] }, + { apiGroups: [""], resources: ["pods", "pods/log"], verbs: ["get", "list", "watch"] }, +] as const; + +const pacConsumerDefaultRoleRef = { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "unidesk-pac-consumer-runner" } as const; + +export function renderPacConsumerRbacDesiredFragment(targetId: string): string { + const consumers = readPacAdmissionContract().consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase()); + const namespaces = [...new Set(consumers.map((consumer) => consumer.namespace))]; + if (namespaces.length === 0) return ""; + const configSha256 = pacConsumerRbacDesiredIdentity(targetId).configSha256; + const objects = namespaces.flatMap((namespace) => { + const labels = { + "app.kubernetes.io/managed-by": "unidesk", + "app.kubernetes.io/part-of": "platform-infra", + "unidesk.ai/pac-rbac-contract": "admission-provenance-required", + }; + const annotations = { + "argocd.argoproj.io/sync-wave": "-1", + "unidesk.ai/effective-config-sha256": configSha256, + }; + return [ + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: "unidesk-pac-consumer-runner", namespace, labels, annotations }, + rules: pacConsumerReadOnlyRules, + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: "unidesk-pac-consumer-runner-default", namespace, labels, annotations }, + subjects: [{ kind: "ServiceAccount", name: "default", namespace }], + roleRef: pacConsumerDefaultRoleRef, + }, + ]; + }); + return `${objects.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; +} + +export function pacConsumerRbacDesiredIdentity(targetId: string): PacConsumerRbacDesiredIdentity { + const consumers = readPacAdmissionContract().consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase()); + const namespaces = [...new Set(consumers.map((consumer) => consumer.namespace))].sort(); + const desiredSpec = namespaces.map((namespace) => ({ + namespace, + role: { name: "unidesk-pac-consumer-runner", rules: pacConsumerReadOnlyRules }, + roleBinding: { + name: "unidesk-pac-consumer-runner-default", + subjects: [{ kind: "ServiceAccount", name: "default", namespace }], + roleRef: pacConsumerDefaultRoleRef, + }, + })); + return { + configSha256: `sha256:${createHash("sha256").update(JSON.stringify({ targetId, desiredSpec, contract: "admission-provenance-required-v1" })).digest("hex")}`, + namespaces, + }; +} diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts new file mode 100644 index 00000000..3b3a54be --- /dev/null +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -0,0 +1,354 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; + +import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; +import { renderAgentRunPipelineManifest } from "./agentrun-manifests"; +import { renderPlatformInfraGiteaDesiredFragments } from "./platform-infra-gitea-desired-fragments"; +import { pacConsumerRbacDesiredIdentity, renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac"; +import { parsePacAdmissionContract, readPacAdmissionContract, renderPacAdmissionDesiredFragment } from "./platform-infra-pac-provenance"; +import { pacDebugFixtureDisclosure } from "./platform-infra-pipelines-as-code"; + +const root = resolve(import.meta.dir, "../.."); +const evaluator = createRequire(import.meta.url)("../native/cicd/pac-status-evaluator.cjs") as { + evaluatePacAdmissionState: (input: Record) => Record; + evaluatePacStatus: (input: Record) => Record; + extractPacArtifactEvidence: (records: unknown[], logText: string) => Record; + taskTerminalRecord: (taskRun: Record) => Record | null; + runPacStatusFixtureChecks: () => { ok: boolean; checks: Array<{ id: string; ok: boolean }> }; +}; + +function documents(value: string): any[] { + return value.split(/^---\s*$/mu).map((item) => item.trim()).filter(Boolean).map((item) => Bun.YAML.parse(item)); +} + +test("admission contract rejects malformed required declarations instead of silently downgrading", () => { + const source = Bun.YAML.parse(readFileSync(resolve(root, "config/platform-infra/pipelines-as-code.yaml"), "utf8")) as any; + const consumer = source.consumers.find((item: any) => item.deliveryProvenance !== undefined); + consumer.deliveryProvenance.required = "true"; + 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("-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")); + expect(items.map((item) => item.kind)).toEqual(["ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]); + const policy = items[0]; + const expressions = policy.spec.validations.map((item: any) => item.expression).join("\n"); + const messages = policy.spec.validations.map((item: any) => item.message).join("\n"); + expect(policy.spec.failurePolicy).toBe("Fail"); + expect(policy.spec.matchConstraints.matchPolicy).toBe("Equivalent"); + expect(items[1].spec.validationActions).toEqual(["Deny"]); + expect(policy.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("0"); + expect(items[1].metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("1"); + expect(expressions).toContain("request.userInfo.username"); + 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).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"]'); + expect(expressions).toContain("agentrun-nc01-v02-tekton-runner"); + expect(messages).toContain("execution ServiceAccount"); + 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 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"; + 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"'); + 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", () => { + const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); + expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding"]); + const role = rbac[0]; + expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1"); + expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256); + const verbs = role.rules.flatMap((rule: any) => rule.verbs); + for (const verb of ["create", "patch", "update", "delete", "deletecollection"]) expect(verbs).not.toContain(verb); + const desiredKinds = documents(renderPlatformInfraGiteaDesiredFragments("NC01")).map((item) => item.kind); + expect(desiredKinds).toEqual(["Role", "RoleBinding", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]); +}); + +test("live admission evaluator requires exact desired hashes, observed generation, default-SA loss, and a new resource epoch", () => { + const contract = readPacAdmissionContract(); + const admission = documents(renderPacAdmissionDesiredFragment("NC01")); + const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); + const policy = { ...admission[0], metadata: { ...admission[0].metadata, uid: "policy-uid", generation: 2, creationTimestamp: "2026-01-01T00:00:00Z" }, status: { observedGeneration: 2, typeChecking: { expressionWarnings: [] } } }; + const binding = { ...admission[1], metadata: { ...admission[1].metadata, uid: "binding-uid", creationTimestamp: "2026-01-01T00:00:01Z" } }; + const expected = { + targetId: "NC01", + policyName: contract.policyName, + bindingName: contract.bindingName, + version: contract.version, + controllerIdentity: contract.controllerUsername, + configSha256: policy.metadata.annotations["unidesk.ai/effective-config-sha256"], + rbacConfigSha256: rbac[0].metadata.annotations["unidesk.ai/effective-config-sha256"], + }; + 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: { + ...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"]) }); + expect(evaluator.evaluatePacAdmissionState({ ...input, expected: { ...expected, configSha256: `sha256:${"0".repeat(64)}` } })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["policy-config-sha256-mismatch"]) }); + const mutatedPolicy = { + ...policy, + spec: { + ...policy.spec, + validations: policy.spec.validations.map((item: any, index: number) => index === 0 ? { ...item, expression: "true" } : item), + }, + }; + expect(evaluator.evaluatePacAdmissionState({ ...input, policy: mutatedPolicy })).toMatchObject({ + ready: false, + reasons: expect.arrayContaining(["admission-live-spec-sha256-mismatch"]), + }); + expect(evaluator.evaluatePacAdmissionState({ + ...input, + policy: { ...policy, spec: { ...policy.spec, matchConditions: [{ name: "disable-enforcement", expression: "false" }] } }, + })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["admission-live-spec-sha256-mismatch"]) }); + expect(evaluator.evaluatePacAdmissionState({ + ...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", () => { + const spec = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }).spec; + const pipeline = renderAgentRunPipelineManifest(spec) as any; + const tasks = pipeline.spec.tasks; + expect(tasks.map((item: any) => item.name)).toEqual(["plan-artifacts", "collect-artifacts", "gitops-promote"]); + expect(tasks[0].runAfter).toBeUndefined(); + expect(tasks[1].runAfter).toEqual(["plan-artifacts"]); + expect(tasks[2].runAfter).toEqual(["collect-artifacts"]); + for (const task of tasks) expect(task.workspaces).toEqual([{ name: "source", workspace: "source" }]); + expect(tasks[0].taskSpec.steps.map((item: any) => item.name)).toEqual(["source-checkout", "probe-env-image"]); + expect(tasks[1].taskSpec.steps.map((item: any) => item.name)).toEqual(["build-env-image"]); + expect(tasks[2].taskSpec.steps.map((item: any) => item.name)).toEqual(["publish-gitops"]); + expect(tasks[0].taskSpec.steps[1].script).toContain('"event":"pac-delivery-plan"'); + expect(tasks[1].taskSpec.steps[0].script).toContain("buildctl-daemonless.sh"); + expect(tasks[2].taskSpec.steps[0].script).toContain("gitops-publish"); +}); + +test("AgentRun rendered delivery plan and real TaskRun terminals close the status evaluator gate", () => { + const spec = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }).spec; + const pipeline = renderAgentRunPipelineManifest(spec) as any; + const tasks = pipeline.spec.tasks as any[]; + const sourceCommit = "c".repeat(40); + const gitopsCommit = "b".repeat(40); + const digest = `sha256:${"a".repeat(64)}`; + const planPrintf = String(tasks[0].taskSpec.steps[1].script) + .split("\n") + .find((line) => line.startsWith("printf '{\"event\":\"pac-delivery-plan\"")); + expect(planPrintf).toBeDefined(); + const planResult = Bun.spawnSync({ + cmd: ["sh", "-c", `build_services='["agentrun-mgr"]'; reused_services='[]'; ${planPrintf!.replaceAll("$(params.revision)", sourceCommit)}`], + stdout: "pipe", + stderr: "pipe", + }); + expect(planResult.exitCode).toBe(0); + const plan = JSON.parse(planResult.stdout.toString().trim()); + expect(plan).toMatchObject({ event: "pac-delivery-plan", sourceCommitId: sourceCommit, buildServices: ["agentrun-mgr"] }); + + const terminals = tasks.map((task) => evaluator.taskTerminalRecord({ + apiVersion: "tekton.dev/v1", + kind: "TaskRun", + metadata: { + name: `fixture-${task.name}`, + labels: { "tekton.dev/pipelineTask": task.name }, + }, + status: { + conditions: [{ type: "Succeeded", status: "True", reason: "Succeeded" }], + results: [], + }, + })); + expect(terminals).not.toContain(null); + const publish = { + ok: true, + status: "succeeded", + phase: "gitops-publish", + gitopsCommit, + sourceCommit, + imageStatus: "built", + digest, + valuesPrinted: false, + }; + const artifact = evaluator.extractPacArtifactEvidence([plan, ...terminals, publish], ""); + expect(artifact.sourceObservation).toMatchObject({ + contract: "pac-delivery-plan-v1", + mode: "delivery", + valid: true, + terminal: { + planArtifacts: "succeeded", + collectArtifacts: "succeeded", + gitopsPromote: "succeeded", + }, + terminalSources: { + planArtifacts: { markerSource: "pac-delivery-plan-structured-log" }, + }, + }); + expect(evaluator.evaluatePacStatus({ + sourceCommit, + artifact, + registryPresent: true, + argo: { + sync: "Synced", + health: "Healthy", + revision: gitopsCommit, + revisionRelation: { relation: "exact", expectedRevision: gitopsCommit, observedRevision: gitopsCommit }, + }, + runtime: { image: `registry/agentrun-mgr@${digest}`, digest, replicas: 1, readyReplicas: 1 }, + })).toMatchObject({ ok: true, code: "pac-ready-gitops-exact" }); +}); + +test("provenance fixtures fail closed for pre-bootstrap, forged marker, wrong SA, and policy mismatch", () => { + const result = evaluator.runPacStatusFixtureChecks(); + expect(result.ok).toBe(true); + for (const id of ["admission-provenance-verified", "pre-bootstrap-run-fails-closed", "forged-name-candidate-fails-closed", "forged-label-candidate-fails-closed", "forged-pipeline-ref-candidate-fails-closed", "wrong-service-account-fails-closed", "policy-identity-mismatch-fails-closed"]) { + expect(result.checks.find((item) => item.id === id)?.ok).toBe(true); + } +}); + +test("history evaluates admission and default RBAC in each consumer namespace", () => { + const remote = readFileSync(resolve(root, "scripts/src/platform-infra-pipelines-as-code-remote.sh"), "utf8"); + expect(remote).toContain("consumer.admissionState = admissionStateForConsumer(consumer)"); + 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"); +}); + +test("id-specific PaC debug keeps fixture failures but does not dump every passing check", () => { + const passing = Array.from({ length: 23 }, (_, index) => ({ id: `pass-${index}`, ok: true })); + expect(pacDebugFixtureDisclosure(passing, "agentrun-run")).toMatchObject({ + fixtureGate: { ok: true, total: 23, passed: 23, failed: 0, disclosure: "failed-checks-only-for-id-specific-debug" }, + checks: [], + }); + const withFailure = [...passing, { id: "failed", ok: false }]; + expect(pacDebugFixtureDisclosure(withFailure, "agentrun-run").checks).toEqual([{ id: "failed", ok: false }]); + expect(pacDebugFixtureDisclosure(passing, null).checks).toHaveLength(23); +}); diff --git a/scripts/src/platform-infra-pac-provenance.ts b/scripts/src/platform-infra-pac-provenance.ts new file mode 100644 index 00000000..434bbb02 --- /dev/null +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -0,0 +1,395 @@ +import { createHash } from "node:crypto"; + +import { rootPath } from "./config"; +import { readYamlRecord } from "./platform-infra-ops-library"; +import { materializeYamlComposition } from "./yaml-composition"; + +const configPath = "config/platform-infra/pipelines-as-code.yaml"; + +export interface PacAdmissionConsumerContract { + readonly id: string; + readonly node: string; + readonly namespace: string; + readonly pipeline: string; + readonly pipelineRunPrefix: string; + readonly markerValue: string; + readonly executionServiceAccountName: string; + 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"; + readonly validationActions: readonly ["Deny"]; + readonly child: { + readonly enabled: false; + readonly parentNameAnnotation: string; + readonly parentUidAnnotation: string; + }; + readonly consumers: readonly PacAdmissionConsumerContract[]; +} + +export interface PacAdmissionDesiredIdentity { + readonly configSha256: string; + readonly policyName: string; + readonly bindingName: string; + readonly version: string; + readonly controllerIdentity: string; +} + +export function readPacAdmissionContract(): PacAdmissionContract { + const source = readYamlRecord>(rootPath("config", "platform-infra", "pipelines-as-code.yaml"), "platform-infra-pipelines-as-code"); + return parsePacAdmissionContract(source); +} + +export function parsePacAdmissionContract(source: Record): PacAdmissionContract { + const root = record(materializeYamlComposition(source, { label: configPath }).value, configPath); + 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) => { + if (consumer.deliveryProvenance === undefined) return []; + const value = record(consumer.deliveryProvenance, `consumers[${index}].deliveryProvenance`); + if (value.required === false) return []; + if (value.required !== true) throw new Error(`consumers[${index}].deliveryProvenance.required must be boolean true or false`); + const gitOps = record(value.gitOps, `consumers[${index}].deliveryProvenance.gitOps`); + return [{ + id: required(consumer.id, `consumers[${index}].id`), + node: required(consumer.node, `consumers[${index}].node`), + namespace: required(consumer.namespace, `consumers[${index}].namespace`), + pipeline: required(consumer.pipeline, `consumers[${index}].pipeline`), + pipelineRunPrefix: required(consumer.pipelineRunPrefix, `consumers[${index}].pipelineRunPrefix`), + markerValue: required(value.markerValue, `consumers[${index}].deliveryProvenance.markerValue`), + executionServiceAccountName: required(value.executionServiceAccountName, `consumers[${index}].deliveryProvenance.executionServiceAccountName`), + gitOps: { + repoUrl: required(gitOps.repoUrl, `consumers[${index}].deliveryProvenance.gitOps.repoUrl`), + targetRevision: required(gitOps.targetRevision, `consumers[${index}].deliveryProvenance.gitOps.targetRevision`), + }, + }]; + }); + const markerValues = new Set(consumers.map((consumer) => consumer.markerValue)); + if (markerValues.size !== consumers.length) throw new Error("deliveryProvenance markerValue must be unique per consumer"); + const version = required(provenance.version, "deliveryProvenance.version"); + const versionMatch = version.match(/-v([1-9][0-9]*)$/u); + if (versionMatch === null) throw new Error("deliveryProvenance.version must end with a positive -vN resource epoch"); + const resourceEpoch = `-v${versionMatch[1]}`; + const policyName = required(admission.policyName, "deliveryProvenance.admission.policyName"); + const bindingName = required(admission.bindingName, "deliveryProvenance.admission.bindingName"); + if (!policyName.endsWith(resourceEpoch) || !bindingName.endsWith(resourceEpoch)) throw new Error(`deliveryProvenance admission resource names must end with ${resourceEpoch}`); + return { + 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"), + validationActions: ["Deny"], + child: { + enabled: literal(child.enabled, "deliveryProvenance.child.enabled", false), + parentNameAnnotation: required(child.parentNameAnnotation, "deliveryProvenance.child.parentNameAnnotation"), + parentUidAnnotation: required(child.parentUidAnnotation, "deliveryProvenance.child.parentUidAnnotation"), + }, + consumers, + }; +} + +export function pacAdmissionConsumer(consumerId: string): PacAdmissionConsumerContract | null { + return readPacAdmissionContract().consumers.find((consumer) => consumer.id === consumerId) ?? null; +} + +export function renderPacAdmissionDesiredFragment(targetId: string): string { + const contract = readPacAdmissionContract(); + const consumers = contract.consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase()); + if (consumers.length === 0) return ""; + const annotation = contract.markerAnnotation; + 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"], + ["labels", "pipelinesascode.tekton.dev/repository"], + ["labels", "pipelinesascode.tekton.dev/sha"], + ["labels", "pipelinesascode.tekton.dev/commit"], + ["labels", "pipelinesascode.tekton.dev/branch"], + ["labels", "pipelinesascode.tekton.dev/sender"], + ["annotations", "pipelinesascode.tekton.dev/controller-info"], + ["annotations", "pipelinesascode.tekton.dev/event-type"], + ["annotations", "pipelinesascode.tekton.dev/git-provider"], + ["annotations", "pipelinesascode.tekton.dev/repository"], + ["annotations", "pipelinesascode.tekton.dev/sha"], + ["annotations", "pipelinesascode.tekton.dev/commit"], + ["annotations", "pipelinesascode.tekton.dev/branch"], + ["annotations", "pipelinesascode.tekton.dev/source-branch"], + ["annotations", "pipelinesascode.tekton.dev/sender"], + ["annotations", contract.child.parentNameAnnotation], + ["annotations", contract.child.parentUidAnnotation], + ] as const; + const validations: Record[] = [ + { + expression: `request.operation != 'CREATE' || !(${markerPresent}) || request.userInfo.username == ${cel(contract.controllerUsername)}`, + message: "reserved PaC provenance marker may only be created by the authenticated PaC controller ServiceAccount", + reason: "Forbidden", + }, + { + expression: `request.operation != 'UPDATE' || ((${markerPresent}) == (${oldMarkerPresent}) && (!(${markerPresent}) || object.metadata.annotations[${cel(annotation)}] == oldObject.metadata.annotations[${cel(annotation)}]))`, + message: "reserved PaC provenance marker is immutable", + reason: "Invalid", + }, + { + expression: `request.operation != 'CREATE' || ((!has(object.metadata.annotations) || !(${cel(contract.child.parentNameAnnotation)} in object.metadata.annotations)) && (!has(object.metadata.annotations) || !(${cel(contract.child.parentUidAnnotation)} in object.metadata.annotations)))`, + message: "child PipelineRun provenance is disabled until an admission-verified parent name and UID contract is enabled", + reason: "Forbidden", + }, + { + expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${immutableProofKeys.map(([map, key]) => metadataEntryEqual(map, key)).join(" && ")})`, + message: "PaC repository, branch, revision, event, controller, sender, and parent identity proof fields are immutable", + reason: "Invalid", + }, + { + expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || ((!has(object.spec.params) && !has(oldObject.spec.params)) || (has(object.spec.params) && has(oldObject.spec.params) && object.spec.params == oldObject.spec.params))`, + message: "PaC source revision parameters are immutable", + reason: "Invalid", + }, + { + expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${serviceAccountEqual()})`, + message: "PaC execution ServiceAccount is immutable", + reason: "Invalid", + }, + { + expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${pipelineRefEqual()})`, + message: "PaC referenced Pipeline identity is immutable", + reason: "Invalid", + }, + { + 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) => [{ + 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`, + reason: "Invalid", + }, { + expression: `!(${usesServiceAccount(consumer, "object")}) || ((${candidateExpression(consumer, "object")}) && (${markerPresent}) && object.metadata.annotations[${cel(annotation)}] == ${cel(consumer.markerValue)})`, + message: `execution ServiceAccount for ${consumer.id} is reserved to its admission-proven PipelineRun candidates`, + reason: "Forbidden", + }]), + ]; + const policySpec = { + failurePolicy: contract.failurePolicy, + matchConstraints: { + matchPolicy: "Equivalent", + resourceRules: [{ apiGroups: ["tekton.dev"], apiVersions: ["v1"], operations: ["CREATE", "UPDATE"], resources: ["pipelineruns"] }], + }, + validations, + }; + const bindingSpec = { policyName: contract.policyName, validationActions: contract.validationActions }; + const policyHash = admissionSpecSha256(targetId, contract.policyName, contract.bindingName, policySpec, bindingSpec); + const metadata = { + labels: { "app.kubernetes.io/managed-by": "unidesk", "app.kubernetes.io/part-of": "platform-infra" }, + annotations: { + "argocd.argoproj.io/sync-wave": "0", + "unidesk.ai/pac-provenance-version": contract.version, + "unidesk.ai/pac-controller-identity": contract.controllerUsername, + "unidesk.ai/owning-config-ref": `${configPath}#deliveryProvenance`, + "unidesk.ai/effective-config-sha256": policyHash, + }, + }; + const policy = { + apiVersion: "admissionregistration.k8s.io/v1", + kind: "ValidatingAdmissionPolicy", + metadata: { name: contract.policyName, ...metadata }, + spec: policySpec, + }; + const binding = { + apiVersion: "admissionregistration.k8s.io/v1", + kind: "ValidatingAdmissionPolicyBinding", + metadata: { + name: contract.bindingName, + ...metadata, + annotations: { ...metadata.annotations, "argocd.argoproj.io/sync-wave": "1" }, + }, + spec: bindingSpec, + }; + return [policy, binding].map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n") + "\n"; +} + +export function pacAdmissionDesiredIdentity(targetId: string): PacAdmissionDesiredIdentity { + const contract = readPacAdmissionContract(); + const manifest = renderPacAdmissionDesiredFragment(targetId); + const policy = manifest.length === 0 ? null : record(Bun.YAML.parse(manifest.split(/^---\s*$/mu)[0] ?? ""), "rendered admission policy"); + const policyMetadata = policy === null ? {} : record(policy.metadata, "rendered admission policy metadata"); + const annotations = policy === null ? {} : record(policyMetadata.annotations, "rendered admission policy annotations"); + return { + configSha256: policy === null + ? `sha256:${createHash("sha256").update(stableCanonicalJson({ targetId, disabled: true })).digest("hex")}` + : required(annotations["unidesk.ai/effective-config-sha256"], "rendered admission policy effective config sha256"), + policyName: contract.policyName, + bindingName: contract.bindingName, + version: contract.version, + controllerIdentity: contract.controllerUsername, + }; +} + +function admissionSpecSha256(targetId: string, policyName: string, bindingName: string, policySpec: Record, bindingSpec: Record): string { + const input = { targetId, policyName, bindingName, policySpec, bindingSpec }; + return `sha256:${createHash("sha256").update(stableCanonicalJson(input)).digest("hex")}`; +} + +function stableCanonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableCanonicalJson).join(",")}]`; + if (typeof value === "object" && value !== null) { + return `{${Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${stableCanonicalJson(item)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function candidateExpression(consumer: PacAdmissionConsumerContract, root: "object" | "oldObject"): string { + return `${root}.metadata.namespace == ${cel(consumer.namespace)} && (${root}.metadata.name.startsWith(${cel(consumer.pipelineRunPrefix)}) || (has(${root}.metadata.labels) && ((${cel("tekton.dev/pipeline")} in ${root}.metadata.labels && ${root}.metadata.labels[${cel("tekton.dev/pipeline")}] == ${cel(consumer.pipeline)}) || (${cel("tekton.dev/pipelineName")} in ${root}.metadata.labels && ${root}.metadata.labels[${cel("tekton.dev/pipelineName")}] == ${cel(consumer.pipeline)}))) || (has(${root}.spec.pipelineRef) && has(${root}.spec.pipelineRef.name) && ${root}.spec.pipelineRef.name == ${cel(consumer.pipeline)}))`; +} + +function usesServiceAccount(consumer: PacAdmissionConsumerContract, root: "object" | "oldObject"): string { + return `has(${root}.spec.taskRunTemplate) && has(${root}.spec.taskRunTemplate.serviceAccountName) && ${root}.spec.taskRunTemplate.serviceAccountName == ${cel(consumer.executionServiceAccountName)}`; +} + +function metadataEntryEqual(map: "annotations" | "labels", key: string): string { + const current = `has(object.metadata.${map}) && ${cel(key)} in object.metadata.${map}`; + const previous = `has(oldObject.metadata.${map}) && ${cel(key)} in oldObject.metadata.${map}`; + return `((${current}) == (${previous}) && (!(${current}) || object.metadata.${map}[${cel(key)}] == oldObject.metadata.${map}[${cel(key)}]))`; +} + +function serviceAccountEqual(): string { + const current = "has(object.spec.taskRunTemplate) && has(object.spec.taskRunTemplate.serviceAccountName)"; + const previous = "has(oldObject.spec.taskRunTemplate) && has(oldObject.spec.taskRunTemplate.serviceAccountName)"; + return `((${current}) == (${previous}) && (!(${current}) || object.spec.taskRunTemplate.serviceAccountName == oldObject.spec.taskRunTemplate.serviceAccountName))`; +} + +function pipelineRefEqual(): string { + const current = "has(object.spec.pipelineRef)"; + const previous = "has(oldObject.spec.pipelineRef)"; + return `((${current}) == (${previous}) && (!(${current}) || object.spec.pipelineRef == oldObject.spec.pipelineRef))`; +} + +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)}`; +} + +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); +} + +function record(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} + +function recordArray(value: unknown, path: string): Record[] { + if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + return value.map((item, index) => record(item, `${path}[${index}]`)); +} + +function required(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line 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[]; +} + +function literal(value: unknown, path: string, expected: T): T { + if (value !== expected) throw new Error(`${path} must be ${expected}`); + return expected; +} diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index c20accea..30d88de2 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -163,6 +163,7 @@ EOF } consumer_rbac_manifest() { + pipeline_verbs='["get", "list", "watch", "create", "patch", "update"]' cat </dev/null - consumer_rbac_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null + if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" = "1" ]; then + rbac_tmp=$(mktemp) + printf '%s' "$UNIDESK_PAC_CONSUMER_RBAC_MANIFEST_B64" | base64 -d >"$rbac_tmp" + kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-compatibility-bootstrap" -f "$rbac_tmp" >/dev/null + rm -f "$rbac_tmp" + else + consumer_rbac_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null + fi token=$(ensure_token) test -n "$token" kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" create secret generic "$UNIDESK_PAC_SECRET_NAME" \ @@ -256,6 +264,63 @@ condition_status() { kubectl -n "$1" get "$2" "$3" -o jsonpath='{range .status.conditions[*]}{.type}={.status}:{.reason}{";"}{end}' 2>/dev/null || true } +admission_provenance_state() { + if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ]; then + printf '{"configured":false,"required":false,"ready":null,"reasons":["consumer-admission-provenance-not-required"],"valuesPrinted":false}' + return + fi + policy_file=$(mktemp) + binding_file=$(mktemp) + role_file=$(mktemp) + role_binding_file=$(mktemp) + kubectl get validatingadmissionpolicy "$UNIDESK_PAC_ADMISSION_POLICY_NAME" -o json >"$policy_file" 2>/dev/null || printf '{}' >"$policy_file" + kubectl get validatingadmissionpolicybinding "$UNIDESK_PAC_ADMISSION_BINDING_NAME" -o json >"$binding_file" 2>/dev/null || printf '{}' >"$binding_file" + 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 delete deletecollection; do + for resource in pipelineruns.tekton.dev taskruns.tekton.dev; do + 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) ;; + *) [ "$default_can_mutate" = true ] || default_can_mutate=unknown ;; + esac + done + done + export UNIDESK_PAC_DEFAULT_CAN_MUTATE="$default_can_mutate" + node - "$policy_file" "$binding_file" "$role_file" "$role_binding_file" <<'NODE' +const fs = require('node:fs'); +const { evaluatePacAdmissionState } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); +function read(path) { + try { return JSON.parse(fs.readFileSync(path, 'utf8') || '{}'); } catch { return {}; } +} +process.stdout.write(JSON.stringify(evaluatePacAdmissionState({ + policy: read(process.argv[2]), + binding: read(process.argv[3]), + role: read(process.argv[4]), + roleBinding: read(process.argv[5]), + namespace: process.env.UNIDESK_PAC_TARGET_NAMESPACE || null, + defaultCanMutate: process.env.UNIDESK_PAC_DEFAULT_CAN_MUTATE === 'unknown' ? null : process.env.UNIDESK_PAC_DEFAULT_CAN_MUTATE === 'true', + expected: { + targetId: process.env.UNIDESK_PAC_TARGET_ID || '', + policyName: process.env.UNIDESK_PAC_ADMISSION_POLICY_NAME || '', + bindingName: process.env.UNIDESK_PAC_ADMISSION_BINDING_NAME || '', + version: process.env.UNIDESK_PAC_ADMISSION_VERSION || '', + controllerIdentity: process.env.UNIDESK_PAC_ADMISSION_CONTROLLER_IDENTITY || '', + configSha256: process.env.UNIDESK_PAC_ADMISSION_CONFIG_SHA256 || '', + rbacConfigSha256: process.env.UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256 || '', + }, +}))); +NODE + rm -f "$policy_file" "$binding_file" "$role_file" "$role_binding_file" +} + +prepare_admission_provenance_state() { + UNIDESK_PAC_ADMISSION_STATE_JSON=$(admission_provenance_state) + export UNIDESK_PAC_ADMISSION_STATE_JSON +} + pipeline_rows() { payload_file=$(mktemp) kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file" @@ -310,14 +375,8 @@ function durationSeconds(item) { if (!start) return null; return Math.max(0, Math.round(((done ? Date.parse(done) : Date.now()) - Date.parse(start)) / 1000)); } -const prefix = process.env.UNIDESK_PAC_PIPELINE_RUN_PREFIX; -const pipeline = process.env.UNIDESK_PAC_PIPELINE_NAME; -const consumer = { - id: process.env.UNIDESK_PAC_CONSUMER_ID, - repository: process.env.UNIDESK_PAC_REPOSITORY_NAME, - pipelineRunPrefix: prefix, - pipeline, -}; +const consumer = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_CONSUMER_CONFIG_B64 || 'e30=', 'base64').toString('utf8')); +consumer.admissionState = JSON.parse(process.env.UNIDESK_PAC_ADMISSION_STATE_JSON || '{}'); const rows = (data.items || []) .filter((item) => pipelineRunMatchesConsumer(consumer, item)) .map((item) => ({ item, classification: classifyPacPipelineRun(consumer, item) })) @@ -350,7 +409,7 @@ history_rows() { node <<'NODE' const fs = require('node:fs'); const cp = require('node:child_process'); -const { classifyPacPipelineRun, extractPacArtifactEvidence, parsePacLogRecords, pipelineRunMatchesConsumer, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); +const { classifyPacPipelineRun, evaluatePacAdmissionState, extractPacArtifactEvidence, parsePacLogRecords, pipelineRunMatchesConsumer, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); const limit = Math.max(1, Math.min(50, Number.parseInt(process.env.UNIDESK_PAC_HISTORY_LIMIT || '5', 10) || 5)); const detailId = process.env.UNIDESK_PAC_HISTORY_ID || ''; const consumers = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_HISTORY_CONSUMERS_B64 || 'W10=', 'base64').toString('utf8')); @@ -408,6 +467,65 @@ function kubectlJson(args, fallback, context) { } } +let admissionPolicy = null; +let admissionBinding = null; +const admissionRbac = new Map(); + +function defaultCanMutate(namespace) { + 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: waitTimeoutSeconds * 1000, + }); + const answer = String(result.stdout || '').trim(); + if (answer === 'yes') return true; + if (answer !== 'no' || result.error || (result.status !== 0 && result.status !== 1)) { + kubectlJsonErrors.push({ context: `${namespace}:default-can-${verb}-${resource}`, status: result.status, error: result.error ? String(result.error.message || result.error) : null, stderr: String(result.stderr || '').slice(0, 1000) }); + return null; + } + } + } + return false; +} + +function admissionStateForConsumer(consumer) { + const expected = consumer.deliveryProvenance; + if (!expected || expected.required !== true) return { configured: false, required: false, ready: null, reasons: ['consumer-admission-provenance-not-required'], valuesPrinted: false }; + admissionPolicy ||= kubectlJson(['get', 'validatingadmissionpolicy', expected.policyName, '-o', 'json'], {}, 'admission-policy'); + admissionBinding ||= kubectlJson(['get', 'validatingadmissionpolicybinding', expected.bindingName, '-o', 'json'], {}, 'admission-binding'); + if (!admissionRbac.has(consumer.namespace)) { + admissionRbac.set(consumer.namespace, { + role: kubectlJson(['-n', consumer.namespace, 'get', 'role', 'unidesk-pac-consumer-runner', '-o', 'json'], {}, `${consumer.id}:default-role`), + roleBinding: kubectlJson(['-n', consumer.namespace, 'get', 'rolebinding', 'unidesk-pac-consumer-runner-default', '-o', 'json'], {}, `${consumer.id}:default-rolebinding`), + defaultCanMutate: defaultCanMutate(consumer.namespace), + }); + } + const rbac = admissionRbac.get(consumer.namespace); + return evaluatePacAdmissionState({ + policy: admissionPolicy, + binding: admissionBinding, + role: rbac.role, + roleBinding: rbac.roleBinding, + namespace: consumer.namespace, + defaultCanMutate: rbac.defaultCanMutate, + expected: { + targetId: expected.targetId, + policyName: expected.policyName, + bindingName: expected.bindingName, + version: expected.version, + controllerIdentity: expected.controllerIdentity, + configSha256: expected.configSha256, + rbacConfigSha256: expected.rbacConfigSha256, + }, + }); +} + function condition(item) { const c = (item.status?.conditions || []).find((x) => x.type === 'Succeeded') || {}; return { status: c.status || '', reason: c.reason || '', message: c.message || '' }; @@ -671,6 +789,7 @@ function rowFor(consumer, item, taskRuns) { const rows = []; const consumerSummaries = []; for (const consumer of consumers) { + consumer.admissionState = admissionStateForConsumer(consumer); const pipelineRuns = kubectlJson(['-n', consumer.namespace, 'get', 'pipelinerun', '-o', 'json'], { items: [] }, `${consumer.id}:pipelinerun`); const taskRuns = kubectlJson(['-n', consumer.namespace, 'get', 'taskrun', '-o', 'json'], { items: [] }, `${consumer.id}:taskrun`); let matches = (pipelineRuns.items || []).filter((item) => { @@ -914,25 +1033,35 @@ NODE observed=$(printf '%s\n' "$fields" | sed -n 's/^observed=//p' | base64 -d 2>/dev/null || true) repo_url=$(printf '%s\n' "$fields" | sed -n 's/^repoUrl=//p' | base64 -d 2>/dev/null || true) target_revision=$(printf '%s\n' "$fields" | sed -n 's/^targetRevision=//p' | base64 -d 2>/dev/null || true) + expected_repo_url="${UNIDESK_PAC_GITOPS_EXPECTED_REPO_URL:-}" + expected_target_revision="${UNIDESK_PAC_GITOPS_EXPECTED_TARGET_REVISION:-}" + [ -n "$expected_repo_url" ] || expected_repo_url="$repo_url" + [ -n "$expected_target_revision" ] || expected_target_revision="$target_revision" + normalized_expected_repo=$(printf '%s' "$expected_repo_url" | sed 's#/*$##') + normalized_observed_repo=$(printf '%s' "$repo_url" | sed 's#/*$##') retained=$(printf '%s\n' "$fields" | sed -n 's/^retained=//p' | base64 -d 2>/dev/null || true) relation=unknown proof=unavailable reason=missing-revision-context fetch_ok=false - if [ "$retained" = true ] && printf '%s' "$observed" | grep -Eq '^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$'; then + if [ "$normalized_expected_repo" != "$normalized_observed_repo" ]; then + reason=repository-mismatch + elif [ "$expected_target_revision" != "$target_revision" ]; then + reason=target-revision-mismatch + elif [ "$retained" = true ] && printf '%s' "$observed" | grep -Eq '^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$'; then relation=retained proof=structured-no-runtime-change-plan reason=source-observation-retains-current-revision elif printf '%s\n%s\n' "$expected" "$observed" | grep -Eqv '^[0-9a-fA-F]{7,64}$'; then reason=invalid-or-missing-revision - elif [ -z "$repo_url" ] \ - || ! git check-ref-format "refs/heads/$target_revision" >/dev/null 2>&1; then + elif [ -z "$expected_repo_url" ] \ + || ! git check-ref-format "refs/heads/$expected_target_revision" >/dev/null 2>&1; then reason=invalid-or-missing-git-context else repo_dir=$(mktemp -d) - if resolved_url=$(resolve_git_service_url "$repo_url") \ + if resolved_url=$(resolve_git_service_url "$expected_repo_url") \ && git init --bare -q "$repo_dir/repo.git" \ - && timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" git --git-dir="$repo_dir/repo.git" fetch -q --no-tags "$resolved_url" "+refs/heads/$target_revision:refs/remotes/origin/$target_revision" >/dev/null 2>&1; then + && timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" git --git-dir="$repo_dir/repo.git" fetch -q --no-tags "$resolved_url" "+refs/heads/$expected_target_revision:refs/remotes/origin/$expected_target_revision" >/dev/null 2>&1; then fetch_ok=true if git --git-dir="$repo_dir/repo.git" cat-file -e "$expected^{commit}" 2>/dev/null \ && git --git-dir="$repo_dir/repo.git" cat-file -e "$observed^{commit}" 2>/dev/null; then @@ -964,6 +1093,8 @@ NODE UNIDESK_PAC_GITOPS_OBSERVED="$observed" \ UNIDESK_PAC_GITOPS_REPO_URL="$repo_url" \ UNIDESK_PAC_GITOPS_TARGET_REVISION="$target_revision" \ + UNIDESK_PAC_GITOPS_EXPECTED_REPO_URL_OBSERVED="$expected_repo_url" \ + UNIDESK_PAC_GITOPS_EXPECTED_TARGET_REVISION_OBSERVED="$expected_target_revision" \ UNIDESK_PAC_GITOPS_RELATION="$relation" \ UNIDESK_PAC_GITOPS_PROOF="$proof" \ UNIDESK_PAC_GITOPS_REASON="$reason" \ @@ -974,6 +1105,10 @@ process.stdout.write(JSON.stringify({ observedRevision: process.env.UNIDESK_PAC_GITOPS_OBSERVED || null, repoURL: process.env.UNIDESK_PAC_GITOPS_REPO_URL || null, targetRevision: process.env.UNIDESK_PAC_GITOPS_TARGET_REVISION || null, + expectedRepoURL: process.env.UNIDESK_PAC_GITOPS_EXPECTED_REPO_URL_OBSERVED || null, + expectedTargetRevision: process.env.UNIDESK_PAC_GITOPS_EXPECTED_TARGET_REVISION_OBSERVED || null, + observedRepoURL: process.env.UNIDESK_PAC_GITOPS_REPO_URL || null, + observedTargetRevision: process.env.UNIDESK_PAC_GITOPS_TARGET_REVISION || null, proof: process.env.UNIDESK_PAC_GITOPS_PROOF || 'unavailable', reason: process.env.UNIDESK_PAC_GITOPS_REASON || null, fetchOk: process.env.UNIDESK_PAC_GITOPS_FETCH_OK === 'true', @@ -1156,6 +1291,7 @@ status_action() { crd=$(kubectl get crd repositories.pipelinesascode.tekton.dev -o name 2>/dev/null || true) controller_ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0") repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME") + prepare_admission_provenance_state pipelines=$(pipeline_rows) latest=$(printf '%s' "$pipelines" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(a[0]?.name||"")') export UNIDESK_PAC_TARGET_PIPELINERUN="$latest" @@ -1179,10 +1315,30 @@ NODE ) runtime=$(json_normalize "$(runtime_summary)") diagnostics=$(cicd_diagnostics "$pipelines" "$artifact" "$argo" "$runtime") - printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ - "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \ + admission_ready=$(UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_STATE||"{}"); process.stdout.write(s.ready===true?"true":"false")') + diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node <<'NODE' +const diagnostics = JSON.parse(process.env.UNIDESK_PAC_DIAGNOSTICS || '{}'); +const admissionProvenance = JSON.parse(process.env.UNIDESK_PAC_STATE || '{}'); +if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionProvenance.ready !== true) { + process.stdout.write(JSON.stringify({ + ...diagnostics, + ok: false, + code: 'pac-admission-provenance-not-ready', + phase: 'admission-provenance-not-ready', + hint: 'required PaC admission policy, binding, desired RBAC, or live default-SA mutation gate is not ready', + admissionProvenance, + valuesPrinted: false, + })); +} else { + process.stdout.write(JSON.stringify({ ...diagnostics, admissionProvenance, valuesPrinted: false })); +} +NODE +) + printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ + "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \ "$( [ -n "$crd" ] && echo true || echo false )" \ "$(json_string "$controller_ready")" \ + "$UNIDESK_PAC_ADMISSION_STATE_JSON" \ "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" \ "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \ "$(json_string "$UNIDESK_PAC_GITEA_REPO")" \ @@ -1197,14 +1353,16 @@ history_action() { controller_ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0") repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME") hooks=$(hook_summary) + prepare_admission_provenance_state history=$(history_rows) rows=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.rows||[]));') consumers=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.consumers||[]));') errors=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.errors||[]));') - printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","repositoryCondition":"%s","repository":{"name":"%s","repo":"%s/%s","url":"%s"},"consumer":"%s","display":{"timeZone":"%s"},"consumerRows":%s,"rows":%s,"historyErrors":%s,"webhooks":%s,"source":"gitea-pac-tekton-live","historyStore":"none","valuesPrinted":false}\n' \ + printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"repositoryCondition":"%s","repository":{"name":"%s","repo":"%s/%s","url":"%s"},"consumer":"%s","display":{"timeZone":"%s"},"consumerRows":%s,"rows":%s,"historyErrors":%s,"webhooks":%s,"source":"gitea-pac-tekton-live","historyStore":"none","valuesPrinted":false}\n' \ "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \ "$( [ -n "$crd" ] && echo true || echo false )" \ "$(json_string "$controller_ready")" \ + "$UNIDESK_PAC_ADMISSION_STATE_JSON" \ "$(json_string "$repository_condition")" \ "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" \ "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \ @@ -1227,6 +1385,7 @@ NODE FIXTURES_JSON="$fixtures" node -e 'const result=JSON.parse(process.env.FIXTURES_JSON||"{}"); if(!result.ok) process.exitCode=1' return fi + prepare_admission_provenance_state history=$(history_rows) UNIDESK_PAC_DEBUG_FIXTURES_JSON="$fixtures" UNIDESK_PAC_DEBUG_HISTORY_JSON="$history" node <<'NODE' function record(value) { @@ -1269,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}`); 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 e67af09d..344a1ea9 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -48,6 +48,11 @@ export interface PacSourceArtifactBinding { readonly namespace: string; readonly pipeline: string; readonly pipelineRunPrefix: string; + readonly deliveryProvenance?: { + readonly markerAnnotation: string; + readonly markerValue: string; + readonly executionServiceAccountName: string; + } | null; readonly sourceArtifact: PacSourceArtifactSpec; }; readonly repository: { @@ -635,6 +640,9 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P "pipelinesascode.tekton.dev/on-target-branch": `[${branch}]`, "pipelinesascode.tekton.dev/on-cel-expression": `event == 'push' && target_branch == '${branch}' && node == '${binding.consumer.node}'`, "pipelinesascode.tekton.dev/max-keep-runs": String(binding.consumer.sourceArtifact.maxKeepRuns), + ...(binding.consumer.deliveryProvenance == null ? {} : { + [binding.consumer.deliveryProvenance.markerAnnotation]: binding.consumer.deliveryProvenance.markerValue, + }), ...pipelineProvenanceAnnotations(provenance), }; } @@ -661,7 +669,7 @@ function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" function taskRunTemplate(binding: PacSourceArtifactBinding): Record { const declared = binding.consumer.sourceArtifact.taskRunTemplate; return { - serviceAccountName: `{{ service_account }}`, + serviceAccountName: binding.consumer.deliveryProvenance?.executionServiceAccountName ?? `{{ service_account }}`, podTemplate: { hostNetwork: declared.hostNetwork, dnsPolicy: declared.dnsPolicy, diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 2a0ca51a..eb5b7d99 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -26,6 +26,8 @@ import { type PacSourceArtifactRuntimeObservation, type PacSourceArtifactSpec, } from "./platform-infra-pipelines-as-code-source-artifact"; +import { pacAdmissionDesiredIdentity } from "./platform-infra-pac-provenance"; +import { pacConsumerRbacDesiredIdentity, renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac"; const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml"); const configLabel = "config/platform-infra/pipelines-as-code.yaml"; @@ -76,6 +78,13 @@ interface PacConfig { controllerServicePort: number; waitTimeoutSeconds: number; }; + deliveryProvenance: { + version: string; + markerAnnotation: string; + controllerIdentity: string; + policyName: string; + bindingName: string; + }; capabilities: { sentinelInternalPublish: { enabled: boolean; @@ -146,6 +155,12 @@ interface PacConsumer { destinationNamespace: string; automated: boolean; } | null; + deliveryProvenance: { + required: true; + markerValue: string; + executionServiceAccountName: string; + gitOps: { repoUrl: string; targetRevision: string }; + } | null; repositoryRef: string; closeoutGitOpsMirrorFlush: boolean; closeoutGitOpsMirrorLane: "v02" | "v03" | null; @@ -256,6 +271,11 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf namespace: consumer.namespace, pipeline: consumer.pipeline, pipelineRunPrefix: consumer.pipelineRunPrefix, + deliveryProvenance: consumer.deliveryProvenance === null ? null : { + markerAnnotation: pac.deliveryProvenance.markerAnnotation, + markerValue: consumer.deliveryProvenance.markerValue, + executionServiceAccountName: consumer.deliveryProvenance.executionServiceAccountName, + }, sourceArtifact: consumer.sourceArtifact, }, repository: { @@ -363,6 +383,9 @@ function help(scope: string | null): Record { function readPacConfig(): PacConfig { const root = materializeYamlComposition(readYamlRecord>(configFile, "platform-infra-pipelines-as-code"), { label: configLabel }).value; const release = y.objectField(root, "release", ""); + const deliveryProvenance = y.objectField(root, "deliveryProvenance", ""); + const provenanceController = y.objectField(deliveryProvenance, "controller", "deliveryProvenance"); + const provenanceAdmission = y.objectField(deliveryProvenance, "admission", "deliveryProvenance"); const capabilities = y.objectField(root, "capabilities", ""); const sentinelInternalPublish = y.objectField(capabilities, "sentinelInternalPublish", "capabilities"); const closeout = y.objectField(root, "closeout", ""); @@ -402,6 +425,13 @@ function readPacConfig(): PacConfig { controllerServicePort: y.portField(release, "controllerServicePort", "release"), waitTimeoutSeconds: positiveInteger(release, "waitTimeoutSeconds", "release"), }, + deliveryProvenance: { + version: y.stringField(deliveryProvenance, "version", "deliveryProvenance"), + markerAnnotation: y.stringField(deliveryProvenance, "markerAnnotation", "deliveryProvenance"), + controllerIdentity: `system:serviceaccount:${y.kubernetesNameField(provenanceController, "namespace", "deliveryProvenance.controller")}:${y.kubernetesNameField(provenanceController, "serviceAccountName", "deliveryProvenance.controller")}`, + policyName: y.kubernetesNameField(provenanceAdmission, "policyName", "deliveryProvenance.admission"), + bindingName: y.kubernetesNameField(provenanceAdmission, "bindingName", "deliveryProvenance.admission"), + }, capabilities: { sentinelInternalPublish: { enabled: y.booleanField(sentinelInternalPublish, "enabled", "capabilities.sentinelInternalPublish"), @@ -464,6 +494,7 @@ function parseRepository(repository: Record, path: string): Pac function parseConsumer(consumer: Record, path: string, defaultRepositoryRef: string): PacConsumer { const id = typeof consumer.id === "string" && consumer.id.length > 0 ? consumer.id : `${y.stringField(consumer, "node", path)}-${y.stringField(consumer, "lane", path)}`; const argoBootstrap = consumer.argoBootstrap === undefined ? null : y.objectField(consumer, "argoBootstrap", path); + const deliveryProvenance = consumer.deliveryProvenance === undefined ? null : parseConsumerDeliveryProvenance(y.objectField(consumer, "deliveryProvenance", path), `${path}.deliveryProvenance`); return { id, node: y.stringField(consumer, "node", path), @@ -481,6 +512,7 @@ function parseConsumer(consumer: Record, path: string, defaultR destinationNamespace: y.kubernetesNameField(argoBootstrap, "destinationNamespace", `${path}.argoBootstrap`), automated: y.booleanField(argoBootstrap, "automated", `${path}.argoBootstrap`), }, + deliveryProvenance, repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef, closeoutGitOpsMirrorFlush: y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path), closeoutGitOpsMirrorLane: consumer.closeoutGitOpsMirrorLane === undefined ? null : y.enumField(consumer, "closeoutGitOpsMirrorLane", path, ["v02", "v03"] as const), @@ -488,6 +520,21 @@ function parseConsumer(consumer: Record, path: string, defaultR }; } +function parseConsumerDeliveryProvenance(value: Record, path: string): PacConsumer["deliveryProvenance"] { + const required = y.booleanField(value, "required", path); + if (!required) return null; + const gitOps = y.objectField(value, "gitOps", path); + return { + required: true, + markerValue: y.stringField(value, "markerValue", path), + executionServiceAccountName: y.kubernetesNameField(value, "executionServiceAccountName", path), + gitOps: { + repoUrl: urlField(gitOps, "repoUrl", `${path}.gitOps`), + targetRevision: y.stringField(gitOps, "targetRevision", `${path}.gitOps`), + }, + }; +} + function parseSourceArtifact(value: Record, path: string): PacSourceArtifactSpec { const mode = y.enumField(value, "mode", path, ["embedded-pipeline-spec", "remote-pipeline-annotation"] as const); const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane"] as const); @@ -707,11 +754,25 @@ function validateConfig(config: PacConfig): void { if (new URL(repository.cloneUrl).origin !== new URL(config.gitea.internalBaseUrl).origin) throw new Error(`${configLabel}.repositories.${repository.id}.cloneUrl must use the configured internal Gitea base URL`); } const consumerIds = new Set(); + const markerValues = new Set(); for (const consumer of config.consumers) { if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`); consumerIds.add(consumer.id); const repository = resolveRepository(config, consumer.repositoryRef); if (repository.namespace !== consumer.namespace) throw new Error(`${configLabel}.consumers.${consumer.id}.namespace must match repository namespace`); + if (consumer.deliveryProvenance !== null) { + if (consumer.sourceArtifact === null) throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance requires sourceArtifact ownership`); + if (markerValues.has(consumer.deliveryProvenance.markerValue)) throw new Error(`${configLabel}.consumers deliveryProvenance.markerValue must be unique: ${consumer.deliveryProvenance.markerValue}`); + markerValues.add(consumer.deliveryProvenance.markerValue); + if (repository.params.service_account !== consumer.deliveryProvenance.executionServiceAccountName) { + throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must match repository params.service_account`); + } + if (consumer.argoBootstrap !== null + && (consumer.argoBootstrap.repoUrl !== consumer.deliveryProvenance.gitOps.repoUrl + || consumer.argoBootstrap.targetRevision !== consumer.deliveryProvenance.gitOps.targetRevision)) { + throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.gitOps must match argoBootstrap source identity`); + } + } } if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`); validateTimeZone(config.display.timeZone, `${configLabel}.display.timeZone`); @@ -1029,6 +1090,7 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise< repositoryCondition: parsed.repositoryCondition, repository: record(parsed.repository), consumerRows: arrayRecords(parsed.consumerRows), + admissionProvenance: record(parsed.admissionProvenance), webhookCount: arrayRecords(parsed.webhooks).length, valuesPrinted: false, }, @@ -1083,6 +1145,8 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis const repository = resolveRepository(pac, consumer.repositoryRef); const result = await capture(config, target.route, ["sh"], remoteScript("debug-step", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, emptySecretMaterial(), "")); const parsed = parseJsonOutput(result.stdout); + const fixtureChecks = parsed === null ? [] : arrayRecords(parsed.checks); + const fixtureDisclosure = pacDebugFixtureDisclosure(fixtureChecks, options.detailId); return { ok: result.exitCode === 0 && parsed?.ok === true, action: "platform-infra-pipelines-as-code-debug-step", @@ -1091,7 +1155,8 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis consumer: consumer.id, detailId: options.detailId, evaluator: "scripts/native/cicd/pac-status-evaluator.cjs", - checks: parsed === null ? [] : arrayRecords(parsed.checks), + fixtureGate: { ...fixtureDisclosure.fixtureGate, ok: parsed !== null && fixtureDisclosure.fixtureGate.ok === true }, + checks: fixtureDisclosure.checks, realRun: parsed === null ? null : parsed.realRun ?? null, remote: parsed === null || options.raw ? parsed ?? compactCapture(result, { full: true }) : undefined, next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), @@ -1099,6 +1164,24 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis }; } +export function pacDebugFixtureDisclosure(fixtureChecks: readonly Record[], detailId: string | null): { + readonly fixtureGate: Record; + readonly checks: readonly Record[]; +} { + const failedChecks = fixtureChecks.filter((item) => item.ok !== true); + return { + fixtureGate: { + ok: failedChecks.length === 0, + total: fixtureChecks.length, + passed: fixtureChecks.length - failedChecks.length, + failed: failedChecks.length, + disclosure: detailId === null ? "all-checks" : "failed-checks-only-for-id-specific-debug", + valuesPrinted: false, + }, + checks: detailId === null ? fixtureChecks : failedChecks, + }; +} + async function fetchReleaseManifest(pac: PacConfig): Promise { const response = await fetch(pac.release.manifestUrl); if (!response.ok) throw new Error(`failed to fetch ${pac.release.manifestUrl}: HTTP ${response.status}`); @@ -1109,6 +1192,9 @@ async function fetchReleaseManifest(pac: PacConfig): Promise { function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string { const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`; + const admissionIdentity = pacAdmissionDesiredIdentity(target.id); + const rbacIdentity = pacConsumerRbacDesiredIdentity(target.id); + const consumerConfig = historyConsumerConfig(pac, consumer); const env: Record = { UNIDESK_PAC_ACTION: action, UNIDESK_PAC_EVALUATOR_B64: Buffer.from(readFileSync(evaluatorFile, "utf8"), "utf8").toString("base64"), @@ -1138,6 +1224,17 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_PARAMS_JSON: JSON.stringify(repository.params), UNIDESK_PAC_PIPELINE_NAME: consumer.pipeline, UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix, + UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"), + UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED: consumer.deliveryProvenance?.required === true ? "1" : "0", + UNIDESK_PAC_CONSUMER_RBAC_MANIFEST_B64: Buffer.from(renderPacConsumerRbacDesiredFragment(target.id), "utf8").toString("base64"), + UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256: rbacIdentity.configSha256, + UNIDESK_PAC_ADMISSION_POLICY_NAME: admissionIdentity.policyName, + UNIDESK_PAC_ADMISSION_BINDING_NAME: admissionIdentity.bindingName, + UNIDESK_PAC_ADMISSION_VERSION: admissionIdentity.version, + UNIDESK_PAC_ADMISSION_CONTROLLER_IDENTITY: admissionIdentity.controllerIdentity, + UNIDESK_PAC_ADMISSION_CONFIG_SHA256: admissionIdentity.configSha256, + UNIDESK_PAC_GITOPS_EXPECTED_REPO_URL: consumer.deliveryProvenance?.gitOps.repoUrl ?? "", + UNIDESK_PAC_GITOPS_EXPECTED_TARGET_REVISION: consumer.deliveryProvenance?.gitOps.targetRevision ?? "", UNIDESK_PAC_HISTORY_LIMIT: "limit" in options ? String(options.limit) : "5", UNIDESK_PAC_HISTORY_ID: "detailId" in options && options.detailId !== null ? options.detailId : "", UNIDESK_PAC_HISTORY_CONSUMERS_B64: Buffer.from(JSON.stringify(historyConsumers.map((item) => historyConsumerConfig(pac, item))), "utf8").toString("base64"), @@ -1154,14 +1251,31 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record { const repository = resolveRepository(pac, consumer.repositoryRef); + const admissionIdentity = pacAdmissionDesiredIdentity(consumer.node); + const rbacIdentity = pacConsumerRbacDesiredIdentity(consumer.node); return { id: consumer.id, + node: consumer.node, namespace: consumer.namespace, pipeline: consumer.pipeline, pipelineRunPrefix: consumer.pipelineRunPrefix, repository: repository.name, repo: `${repository.owner}/${repository.repo}`, repoUrl: repository.url, + deliveryProvenance: consumer.deliveryProvenance === null ? null : { + required: true, + targetId: consumer.node, + version: admissionIdentity.version, + controllerIdentity: admissionIdentity.controllerIdentity, + policyName: admissionIdentity.policyName, + bindingName: admissionIdentity.bindingName, + configSha256: admissionIdentity.configSha256, + rbacConfigSha256: rbacIdentity.configSha256, + markerAnnotation: pac.deliveryProvenance.markerAnnotation, + markerValue: consumer.deliveryProvenance.markerValue, + executionServiceAccountName: consumer.deliveryProvenance.executionServiceAccountName, + gitOps: consumer.deliveryProvenance.gitOps, + }, }; } @@ -1296,6 +1410,7 @@ function statusSummary(payload: Record): Record): Record): Record, includeTaskDetails reason: row.reason, commit: row.commit, branch: row.branch, + deliveryClass: row.deliveryClass, + deliveryAuthorityEligible: row.deliveryAuthorityEligible === true, + deliveryOwner: row.deliveryOwner, + parentRelation: row.parentRelation, + classification: row.classification, envReuse: row.envReuse, sourceObservation: row.sourceObservation, artifact: detailRequested ? { @@ -1827,6 +1949,7 @@ function renderStatus(result: Record): RenderedCliResult { const artifact = record(summary.artifact); const argo = record(summary.argo); const diagnostics = record(summary.diagnostics); + const admissionProvenance = record(summary.admissionProvenance); const sourceObservation = record(summary.sourceObservation); const deliveryPlan = record(sourceObservation.plan); const repository = record(summary.repository); @@ -1841,6 +1964,12 @@ function renderStatus(result: Record): RenderedCliResult { ...table(["CONSUMER", "READY", "AUTHORITY", "CRD", "CONTROLLER", "WEBHOOKS", "REPO_URL"], [[stringValue(consumer.id), boolText(summary.ready), stringValue(deliveryAuthority.kind), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), short(stringValue(repository.url), 56)]]), ` repository-condition: ${compactLine(stringValue(summary.repositoryCondition))}`, ` sentinel-internal-publish: enabled=${boolText(internalPublish.enabled)} provenance=${stringValue(internalPublish.admissionProvenance)} tracking=${stringValue(internalPublish.trackingIssue)}`, + ...(admissionProvenance.configured === false + ? [" admission-provenance: not-required"] + : [ + ` admission-provenance: ready=${boolText(admissionProvenance.ready)} policy=${stringValue(admissionProvenance.policyName)} binding=${stringValue(admissionProvenance.bindingName)} active-after=${stringValue(admissionProvenance.activeAfter)} default-can-mutate=${boolText(admissionProvenance.defaultCanMutate)}`, + ` admission-reasons: ${Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons.join(",") || "-" : "-"}`, + ]), "", "GITEA HOOKS", ...(webhooks.length === 0 ? ["-"] : table(["HOOK", "ACTIVE", "EVENTS", "URL"], webhooks.map((item) => [stringValue(item.id), boolText(item.active), Array.isArray(item.events) ? item.events.join(",") : stringValue(item.events), short(stringValue(item.url), 56)]))), 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"))