diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index e23e686d..a1fe0012 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -11,6 +11,8 @@ metadata: - 1560 - 1649 - 1760 + - 1769 + - 1802 defaults: targetId: NC01 consumerId: hwlab-nc01-v03 @@ -23,6 +25,26 @@ release: controllerServiceName: pipelines-as-code-controller controllerServicePort: 8080 waitTimeoutSeconds: 55 +deliveryProvenance: + version: admission-pac-v1 + 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 + admission: + policyName: unidesk-pac-delivery-provenance-v1 + bindingName: unidesk-pac-delivery-provenance-v1 + failurePolicy: Fail + validationActions: + - Deny + terminalRoles: + - plan-artifacts + - collect-artifacts + - gitops-promote capabilities: sentinelInternalPublish: enabled: false @@ -110,6 +132,13 @@ consumers: variables: NODE: NC01 LANE: v02 + deliveryProvenance: + required: true + markerValue: admission-pac-v1: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..920b84cd 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,146 @@ 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"; + 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 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 +199,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 +213,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 +247,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 +261,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 +348,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"); @@ -227,7 +402,7 @@ function extractPacSourceObservation(recordsInput) { 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, @@ -721,6 +896,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 +989,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.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..1316f856 --- /dev/null +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -0,0 +1,228 @@ +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("versioned admission desired state protects controller proof, candidate identity, params, and execution SA", () => { + const contract = readPacAdmissionContract(); + expect(contract.policyName).toEndWith("-v1"); + expect(contract.bindingName).toEndWith("-v1"); + 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).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"'); +}); + +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, 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"]) }); +}); + +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", + }, + }); + 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).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..2a16667d --- /dev/null +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -0,0 +1,307 @@ +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 PacAdmissionContract { + readonly version: string; + readonly markerAnnotation: string; + readonly controllerUsername: string; + readonly policyName: string; + readonly bindingName: string; + readonly failurePolicy: "Fail"; + readonly validationActions: readonly ["Deny"]; + readonly terminalRoles: readonly string[]; + 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 admission = record(provenance.admission, `${configPath}#deliveryProvenance.admission`); + const controllerNamespace = required(controller.namespace, "deliveryProvenance.controller.namespace"); + const controllerServiceAccount = required(controller.serviceAccountName, "deliveryProvenance.controller.serviceAccountName"); + 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}`, + policyName, + bindingName, + failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Fail"), + validationActions: ["Deny"], + terminalRoles: stringArray(provenance.terminalRoles, "deliveryProvenance.terminalRoles"), + 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 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", + }, + ...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 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 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..8c72bb1d 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; do + for resource in pipelineruns.tekton.dev taskruns.tekton.dev; do + if answer=$(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,61 @@ function kubectlJson(args, fallback, context) { } } +let admissionPolicy = null; +let admissionBinding = null; +const admissionRbac = new Map(); + +function defaultCanMutate(namespace) { + for (const verb of ['create', 'patch', 'update']) { + 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: 12000, + maxBuffer: 64 * 1024, + }); + 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 +785,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 +1029,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 +1089,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 +1101,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 +1287,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 +1311,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 +1349,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 +1381,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) { 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)]))),