Merge pull request #1808 from pikasTech/fix/1802-pac-delivery-provenance
feat(cicd): 固化 PaC admission 交付溯源
This commit is contained in:
@@ -11,6 +11,8 @@ metadata:
|
||||
- 1560
|
||||
- 1649
|
||||
- 1760
|
||||
- 1769
|
||||
- 1802
|
||||
defaults:
|
||||
targetId: NC01
|
||||
consumerId: hwlab-nc01-v03
|
||||
@@ -23,6 +25,22 @@ 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
|
||||
capabilities:
|
||||
sentinelInternalPublish:
|
||||
enabled: false
|
||||
@@ -110,6 +128,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
|
||||
|
||||
@@ -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");
|
||||
@@ -225,9 +400,14 @@ function extractPacSourceObservation(recordsInput) {
|
||||
reason = "delivery-terminal-evidence-incomplete";
|
||||
}
|
||||
|
||||
const planMarkerSource = plan?.event === "pac-delivery-plan"
|
||||
? "pac-delivery-plan-structured-log"
|
||||
: plan?.event === "g14-ci-plan"
|
||||
? "g14-ci-plan-structured-log"
|
||||
: "delivery-plan-structured-log";
|
||||
return {
|
||||
schemaVersion: "v1",
|
||||
contract: "hwlab-g14-ci-plan",
|
||||
contract: plan?.event === "pac-delivery-plan" ? "pac-delivery-plan-v1" : "hwlab-g14-ci-plan",
|
||||
mode,
|
||||
valid,
|
||||
reason,
|
||||
@@ -244,7 +424,7 @@ function extractPacSourceObservation(recordsInput) {
|
||||
gitopsPromote: promoteMarker !== null ? "no-build-no-rollout-plan" : promoteTask?.status || "missing",
|
||||
},
|
||||
terminalSources: {
|
||||
planArtifacts: terminalSource(planTask, plan, "observed", "g14-ci-plan-structured-log"),
|
||||
planArtifacts: terminalSource(planTask, plan, "observed", planMarkerSource),
|
||||
collectArtifacts: terminalSource(collectTask, collectMarker),
|
||||
gitopsPromote: terminalSource(promoteTask, promoteMarker),
|
||||
},
|
||||
@@ -721,6 +901,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 +994,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,
|
||||
|
||||
@@ -218,83 +218,13 @@ export function renderAgentRunPipelineManifest(spec: AgentRunLaneSpec): Record<s
|
||||
{ name: "artifact-catalog", type: "string", default: spec.deployment.artifactCatalogPath },
|
||||
],
|
||||
workspaces: [{ name: "source" }],
|
||||
tasks: [
|
||||
agentRunBuildPublishTask(spec),
|
||||
],
|
||||
tasks: agentRunBuildPublishTasks(spec),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function agentRunBuildPublishTask(spec: AgentRunLaneSpec): Record<string, unknown> {
|
||||
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<string, unknown>[] {
|
||||
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<string, unknow
|
||||
{ name: "env-identity-files-json", value: "$(params.env-identity-files-json)" },
|
||||
{ name: "gitops-root", value: "$(params.gitops-root)" },
|
||||
{ name: "artifact-catalog", value: "$(params.artifact-catalog)" },
|
||||
],
|
||||
];
|
||||
const taskParams = params.map(({ name }) => ({ name }));
|
||||
const task = (name: string, steps: readonly Record<string, unknown>[], runAfter: readonly string[] = []): Record<string, unknown> => ({
|
||||
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");
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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<string, unknown>) => Record<string, any>;
|
||||
evaluatePacStatus: (input: Record<string, unknown>) => Record<string, any>;
|
||||
extractPacArtifactEvidence: (records: unknown[], logText: string) => Record<string, any>;
|
||||
taskTerminalRecord: (taskRun: Record<string, unknown>) => Record<string, any> | 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();
|
||||
const owningYaml = Bun.YAML.parse(readFileSync(resolve(root, "config/platform-infra/pipelines-as-code.yaml"), "utf8")) as any;
|
||||
expect(owningYaml.deliveryProvenance.terminalRoles).toBeUndefined();
|
||||
expect(contract.policyName).toEndWith("-v1");
|
||||
expect(contract.bindingName).toEndWith("-v1");
|
||||
expect(contract.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("object.spec == oldObject.spec");
|
||||
expect(expressions).toContain("taskRunTemplate.serviceAccountName == oldObject.spec.taskRunTemplate.serviceAccountName");
|
||||
expect(expressions).toContain("object.metadata.name.startsWith");
|
||||
expect(expressions).toContain('object.metadata.labels["tekton.dev/pipeline"]');
|
||||
expect(expressions).toContain("agentrun-nc01-v02-tekton-runner");
|
||||
expect(messages).toContain("execution ServiceAccount");
|
||||
expect(expressions).toContain(contract.child.parentUidAnnotation);
|
||||
expect(expressions).toContain("oldObject");
|
||||
expect(JSON.stringify(items)).not.toContain('"kind":"ServiceAccount"');
|
||||
const specGuard = policy.spec.validations.find((item: any) => item.message.includes("PipelineRun spec is immutable"));
|
||||
const oldRun = { spec: { pipelineSpec: { tasks: [{ name: "plan-artifacts" }] }, workspaces: [], timeouts: { pipeline: "1h" } } };
|
||||
const mutatedRun = structuredClone(oldRun);
|
||||
mutatedRun.spec.pipelineSpec.tasks[0].name = "forged-task";
|
||||
expect(specGuard.expression).toMatch(/object\.spec == oldObject\.spec/u);
|
||||
expect(mutatedRun.spec).not.toEqual(oldRun.spec);
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
terminalSources: {
|
||||
planArtifacts: { markerSource: "pac-delivery-plan-structured-log" },
|
||||
},
|
||||
});
|
||||
expect(evaluator.evaluatePacStatus({
|
||||
sourceCommit,
|
||||
artifact,
|
||||
registryPresent: true,
|
||||
argo: {
|
||||
sync: "Synced",
|
||||
health: "Healthy",
|
||||
revision: gitopsCommit,
|
||||
revisionRelation: { relation: "exact", expectedRevision: gitopsCommit, observedRevision: gitopsCommit },
|
||||
},
|
||||
runtime: { image: `registry/agentrun-mgr@${digest}`, digest, replicas: 1, readyReplicas: 1 },
|
||||
})).toMatchObject({ ok: true, code: "pac-ready-gitops-exact" });
|
||||
});
|
||||
|
||||
test("provenance fixtures fail closed for pre-bootstrap, forged marker, wrong SA, and policy mismatch", () => {
|
||||
const result = evaluator.runPacStatusFixtureChecks();
|
||||
expect(result.ok).toBe(true);
|
||||
for (const id of ["admission-provenance-verified", "pre-bootstrap-run-fails-closed", "forged-name-candidate-fails-closed", "forged-label-candidate-fails-closed", "forged-pipeline-ref-candidate-fails-closed", "wrong-service-account-fails-closed", "policy-identity-mismatch-fails-closed"]) {
|
||||
expect(result.checks.find((item) => item.id === id)?.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("history evaluates admission and default RBAC in each consumer namespace", () => {
|
||||
const remote = readFileSync(resolve(root, "scripts/src/platform-infra-pipelines-as-code-remote.sh"), "utf8");
|
||||
expect(remote).toContain("consumer.admissionState = admissionStateForConsumer(consumer)");
|
||||
expect(remote).toContain("kubectlJson(['-n', consumer.namespace, 'get', 'role', 'unidesk-pac-consumer-runner'");
|
||||
expect(remote).toContain("kubectlJson(['-n', consumer.namespace, 'get', 'rolebinding', 'unidesk-pac-consumer-runner-default'");
|
||||
expect(remote).toContain("defaultCanMutate(consumer.namespace)");
|
||||
expect(remote).toContain("for verb in create patch update delete deletecollection; do");
|
||||
expect(remote).toContain('timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" kubectl auth can-i');
|
||||
expect(remote).toContain("for (const verb of ['create', 'patch', 'update', 'delete', 'deletecollection'])");
|
||||
const defaultMutationProbe = remote.slice(remote.indexOf("function defaultCanMutate(namespace)"), remote.indexOf("function admissionStateForConsumer(consumer)"));
|
||||
expect(defaultMutationProbe).toContain("timeout: waitTimeoutSeconds * 1000");
|
||||
expect(defaultMutationProbe).not.toContain("timeout: 12000");
|
||||
expect(remote).toContain("'delivery plan structured evidence is missing'");
|
||||
expect(remote).toContain("'delivery plan source commit does not match the selected PipelineRun'");
|
||||
expect(remote).not.toContain("'g14-ci-plan structured evidence is missing'");
|
||||
expect(remote).not.toContain("for (const consumer of consumers) consumer.admissionState = admissionState");
|
||||
});
|
||||
|
||||
test("id-specific PaC debug keeps fixture failures but does not dump every passing check", () => {
|
||||
const passing = Array.from({ length: 23 }, (_, index) => ({ id: `pass-${index}`, ok: true }));
|
||||
expect(pacDebugFixtureDisclosure(passing, "agentrun-run")).toMatchObject({
|
||||
fixtureGate: { ok: true, total: 23, passed: 23, failed: 0, disclosure: "failed-checks-only-for-id-specific-debug" },
|
||||
checks: [],
|
||||
});
|
||||
const withFailure = [...passing, { id: "failed", ok: false }];
|
||||
expect(pacDebugFixtureDisclosure(withFailure, "agentrun-run").checks).toEqual([{ id: "failed", ok: false }]);
|
||||
expect(pacDebugFixtureDisclosure(passing, null).checks).toHaveLength(23);
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
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 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<Record<string, unknown>>(rootPath("config", "platform-infra", "pipelines-as-code.yaml"), "platform-infra-pipelines-as-code");
|
||||
return parsePacAdmissionContract(source);
|
||||
}
|
||||
|
||||
export function parsePacAdmissionContract(source: Record<string, unknown>): 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"],
|
||||
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<string, unknown>[] = [
|
||||
{
|
||||
expression: `request.operation != 'CREATE' || !(${markerPresent}) || request.userInfo.username == ${cel(contract.controllerUsername)}`,
|
||||
message: "reserved PaC provenance marker may only be created by the authenticated PaC controller ServiceAccount",
|
||||
reason: "Forbidden",
|
||||
},
|
||||
{
|
||||
expression: `request.operation != 'UPDATE' || ((${markerPresent}) == (${oldMarkerPresent}) && (!(${markerPresent}) || object.metadata.annotations[${cel(annotation)}] == oldObject.metadata.annotations[${cel(annotation)}]))`,
|
||||
message: "reserved PaC provenance marker is immutable",
|
||||
reason: "Invalid",
|
||||
},
|
||||
{
|
||||
expression: `request.operation != 'CREATE' || ((!has(object.metadata.annotations) || !(${cel(contract.child.parentNameAnnotation)} in object.metadata.annotations)) && (!has(object.metadata.annotations) || !(${cel(contract.child.parentUidAnnotation)} in object.metadata.annotations)))`,
|
||||
message: "child PipelineRun provenance is disabled until an admission-verified parent name and UID contract is enabled",
|
||||
reason: "Forbidden",
|
||||
},
|
||||
{
|
||||
expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${immutableProofKeys.map(([map, key]) => metadataEntryEqual(map, key)).join(" && ")})`,
|
||||
message: "PaC repository, branch, revision, event, controller, sender, and parent identity proof fields are immutable",
|
||||
reason: "Invalid",
|
||||
},
|
||||
{
|
||||
expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || ((!has(object.spec.params) && !has(oldObject.spec.params)) || (has(object.spec.params) && has(oldObject.spec.params) && object.spec.params == oldObject.spec.params))`,
|
||||
message: "PaC source revision parameters are immutable",
|
||||
reason: "Invalid",
|
||||
},
|
||||
{
|
||||
expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${serviceAccountEqual()})`,
|
||||
message: "PaC execution ServiceAccount is immutable",
|
||||
reason: "Invalid",
|
||||
},
|
||||
{
|
||||
expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${pipelineRefEqual()})`,
|
||||
message: "PaC referenced Pipeline identity is immutable",
|
||||
reason: "Invalid",
|
||||
},
|
||||
{
|
||||
expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || object.spec == oldObject.spec`,
|
||||
message: "admission-proven PaC PipelineRun spec is immutable, including embedded pipelineSpec, workspaces, and timeouts",
|
||||
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<string, unknown>, bindingSpec: Record<string, unknown>): 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<string, unknown>).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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function recordArray(value: unknown, path: string): Record<string, unknown>[] {
|
||||
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<T extends string | boolean>(value: unknown, path: string, expected: T): T {
|
||||
if (value !== expected) throw new Error(`${path} must be ${expected}`);
|
||||
return expected;
|
||||
}
|
||||
@@ -163,6 +163,7 @@ EOF
|
||||
}
|
||||
|
||||
consumer_rbac_manifest() {
|
||||
pipeline_verbs='["get", "list", "watch", "create", "patch", "update"]'
|
||||
cat <<EOF
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
@@ -176,7 +177,7 @@ metadata:
|
||||
rules:
|
||||
- apiGroups: ["tekton.dev"]
|
||||
resources: ["pipelineruns", "taskruns"]
|
||||
verbs: ["get", "list", "watch", "create", "patch", "update"]
|
||||
verbs: $pipeline_verbs
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "pods/log"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
@@ -229,7 +230,14 @@ apply_action() {
|
||||
fi
|
||||
apply_release
|
||||
kubectl create ns "$UNIDESK_PAC_TARGET_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||
consumer_rbac_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null
|
||||
if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" = "1" ]; then
|
||||
rbac_tmp=$(mktemp)
|
||||
printf '%s' "$UNIDESK_PAC_CONSUMER_RBAC_MANIFEST_B64" | base64 -d >"$rbac_tmp"
|
||||
kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-compatibility-bootstrap" -f "$rbac_tmp" >/dev/null
|
||||
rm -f "$rbac_tmp"
|
||||
else
|
||||
consumer_rbac_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null
|
||||
fi
|
||||
token=$(ensure_token)
|
||||
test -n "$token"
|
||||
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" create secret generic "$UNIDESK_PAC_SECRET_NAME" \
|
||||
@@ -256,6 +264,63 @@ condition_status() {
|
||||
kubectl -n "$1" get "$2" "$3" -o jsonpath='{range .status.conditions[*]}{.type}={.status}:{.reason}{";"}{end}' 2>/dev/null || true
|
||||
}
|
||||
|
||||
admission_provenance_state() {
|
||||
if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ]; then
|
||||
printf '{"configured":false,"required":false,"ready":null,"reasons":["consumer-admission-provenance-not-required"],"valuesPrinted":false}'
|
||||
return
|
||||
fi
|
||||
policy_file=$(mktemp)
|
||||
binding_file=$(mktemp)
|
||||
role_file=$(mktemp)
|
||||
role_binding_file=$(mktemp)
|
||||
kubectl get validatingadmissionpolicy "$UNIDESK_PAC_ADMISSION_POLICY_NAME" -o json >"$policy_file" 2>/dev/null || printf '{}' >"$policy_file"
|
||||
kubectl get validatingadmissionpolicybinding "$UNIDESK_PAC_ADMISSION_BINDING_NAME" -o json >"$binding_file" 2>/dev/null || printf '{}' >"$binding_file"
|
||||
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get role unidesk-pac-consumer-runner -o json >"$role_file" 2>/dev/null || printf '{}' >"$role_file"
|
||||
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get rolebinding unidesk-pac-consumer-runner-default -o json >"$role_binding_file" 2>/dev/null || printf '{}' >"$role_binding_file"
|
||||
default_can_mutate=false
|
||||
for verb in create patch update delete deletecollection; do
|
||||
for resource in pipelineruns.tekton.dev taskruns.tekton.dev; do
|
||||
if answer=$(timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" kubectl auth can-i --as="system:serviceaccount:$UNIDESK_PAC_TARGET_NAMESPACE:default" "$verb" "$resource" -n "$UNIDESK_PAC_TARGET_NAMESPACE" 2>/dev/null); then :; else :; fi
|
||||
case "$answer" in
|
||||
yes) default_can_mutate=true ;;
|
||||
no) ;;
|
||||
*) [ "$default_can_mutate" = true ] || default_can_mutate=unknown ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
export UNIDESK_PAC_DEFAULT_CAN_MUTATE="$default_can_mutate"
|
||||
node - "$policy_file" "$binding_file" "$role_file" "$role_binding_file" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const { evaluatePacAdmissionState } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
|
||||
function read(path) {
|
||||
try { return JSON.parse(fs.readFileSync(path, 'utf8') || '{}'); } catch { return {}; }
|
||||
}
|
||||
process.stdout.write(JSON.stringify(evaluatePacAdmissionState({
|
||||
policy: read(process.argv[2]),
|
||||
binding: read(process.argv[3]),
|
||||
role: read(process.argv[4]),
|
||||
roleBinding: read(process.argv[5]),
|
||||
namespace: process.env.UNIDESK_PAC_TARGET_NAMESPACE || null,
|
||||
defaultCanMutate: process.env.UNIDESK_PAC_DEFAULT_CAN_MUTATE === 'unknown' ? null : process.env.UNIDESK_PAC_DEFAULT_CAN_MUTATE === 'true',
|
||||
expected: {
|
||||
targetId: process.env.UNIDESK_PAC_TARGET_ID || '',
|
||||
policyName: process.env.UNIDESK_PAC_ADMISSION_POLICY_NAME || '',
|
||||
bindingName: process.env.UNIDESK_PAC_ADMISSION_BINDING_NAME || '',
|
||||
version: process.env.UNIDESK_PAC_ADMISSION_VERSION || '',
|
||||
controllerIdentity: process.env.UNIDESK_PAC_ADMISSION_CONTROLLER_IDENTITY || '',
|
||||
configSha256: process.env.UNIDESK_PAC_ADMISSION_CONFIG_SHA256 || '',
|
||||
rbacConfigSha256: process.env.UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256 || '',
|
||||
},
|
||||
})));
|
||||
NODE
|
||||
rm -f "$policy_file" "$binding_file" "$role_file" "$role_binding_file"
|
||||
}
|
||||
|
||||
prepare_admission_provenance_state() {
|
||||
UNIDESK_PAC_ADMISSION_STATE_JSON=$(admission_provenance_state)
|
||||
export UNIDESK_PAC_ADMISSION_STATE_JSON
|
||||
}
|
||||
|
||||
pipeline_rows() {
|
||||
payload_file=$(mktemp)
|
||||
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file"
|
||||
@@ -310,14 +375,8 @@ function durationSeconds(item) {
|
||||
if (!start) return null;
|
||||
return Math.max(0, Math.round(((done ? Date.parse(done) : Date.now()) - Date.parse(start)) / 1000));
|
||||
}
|
||||
const prefix = process.env.UNIDESK_PAC_PIPELINE_RUN_PREFIX;
|
||||
const pipeline = process.env.UNIDESK_PAC_PIPELINE_NAME;
|
||||
const consumer = {
|
||||
id: process.env.UNIDESK_PAC_CONSUMER_ID,
|
||||
repository: process.env.UNIDESK_PAC_REPOSITORY_NAME,
|
||||
pipelineRunPrefix: prefix,
|
||||
pipeline,
|
||||
};
|
||||
const consumer = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_CONSUMER_CONFIG_B64 || 'e30=', 'base64').toString('utf8'));
|
||||
consumer.admissionState = JSON.parse(process.env.UNIDESK_PAC_ADMISSION_STATE_JSON || '{}');
|
||||
const rows = (data.items || [])
|
||||
.filter((item) => pipelineRunMatchesConsumer(consumer, item))
|
||||
.map((item) => ({ item, classification: classifyPacPipelineRun(consumer, item) }))
|
||||
@@ -350,7 +409,7 @@ history_rows() {
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const cp = require('node:child_process');
|
||||
const { classifyPacPipelineRun, extractPacArtifactEvidence, parsePacLogRecords, pipelineRunMatchesConsumer, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
|
||||
const { classifyPacPipelineRun, evaluatePacAdmissionState, extractPacArtifactEvidence, parsePacLogRecords, pipelineRunMatchesConsumer, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
|
||||
const limit = Math.max(1, Math.min(50, Number.parseInt(process.env.UNIDESK_PAC_HISTORY_LIMIT || '5', 10) || 5));
|
||||
const detailId = process.env.UNIDESK_PAC_HISTORY_ID || '';
|
||||
const consumers = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_HISTORY_CONSUMERS_B64 || 'W10=', 'base64').toString('utf8'));
|
||||
@@ -408,6 +467,65 @@ function kubectlJson(args, fallback, context) {
|
||||
}
|
||||
}
|
||||
|
||||
let admissionPolicy = null;
|
||||
let admissionBinding = null;
|
||||
const admissionRbac = new Map();
|
||||
|
||||
function defaultCanMutate(namespace) {
|
||||
const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10);
|
||||
if (!Number.isInteger(waitTimeoutSeconds) || waitTimeoutSeconds <= 0) {
|
||||
kubectlJsonErrors.push({ context: `${namespace}:default-can-mutation`, status: null, error: 'invalid-yaml-wait-timeout', stderr: '' });
|
||||
return null;
|
||||
}
|
||||
for (const verb of ['create', 'patch', 'update', 'delete', 'deletecollection']) {
|
||||
for (const resource of ['pipelineruns.tekton.dev', 'taskruns.tekton.dev']) {
|
||||
const result = cp.spawnSync('kubectl', ['auth', 'can-i', `--as=system:serviceaccount:${namespace}:default`, verb, resource, '-n', namespace], {
|
||||
encoding: 'utf8',
|
||||
timeout: waitTimeoutSeconds * 1000,
|
||||
});
|
||||
const answer = String(result.stdout || '').trim();
|
||||
if (answer === 'yes') return true;
|
||||
if (answer !== 'no' || result.error || (result.status !== 0 && result.status !== 1)) {
|
||||
kubectlJsonErrors.push({ context: `${namespace}:default-can-${verb}-${resource}`, status: result.status, error: result.error ? String(result.error.message || result.error) : null, stderr: String(result.stderr || '').slice(0, 1000) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function admissionStateForConsumer(consumer) {
|
||||
const expected = consumer.deliveryProvenance;
|
||||
if (!expected || expected.required !== true) return { configured: false, required: false, ready: null, reasons: ['consumer-admission-provenance-not-required'], valuesPrinted: false };
|
||||
admissionPolicy ||= kubectlJson(['get', 'validatingadmissionpolicy', expected.policyName, '-o', 'json'], {}, 'admission-policy');
|
||||
admissionBinding ||= kubectlJson(['get', 'validatingadmissionpolicybinding', expected.bindingName, '-o', 'json'], {}, 'admission-binding');
|
||||
if (!admissionRbac.has(consumer.namespace)) {
|
||||
admissionRbac.set(consumer.namespace, {
|
||||
role: kubectlJson(['-n', consumer.namespace, 'get', 'role', 'unidesk-pac-consumer-runner', '-o', 'json'], {}, `${consumer.id}:default-role`),
|
||||
roleBinding: kubectlJson(['-n', consumer.namespace, 'get', 'rolebinding', 'unidesk-pac-consumer-runner-default', '-o', 'json'], {}, `${consumer.id}:default-rolebinding`),
|
||||
defaultCanMutate: defaultCanMutate(consumer.namespace),
|
||||
});
|
||||
}
|
||||
const rbac = admissionRbac.get(consumer.namespace);
|
||||
return evaluatePacAdmissionState({
|
||||
policy: admissionPolicy,
|
||||
binding: admissionBinding,
|
||||
role: rbac.role,
|
||||
roleBinding: rbac.roleBinding,
|
||||
namespace: consumer.namespace,
|
||||
defaultCanMutate: rbac.defaultCanMutate,
|
||||
expected: {
|
||||
targetId: expected.targetId,
|
||||
policyName: expected.policyName,
|
||||
bindingName: expected.bindingName,
|
||||
version: expected.version,
|
||||
controllerIdentity: expected.controllerIdentity,
|
||||
configSha256: expected.configSha256,
|
||||
rbacConfigSha256: expected.rbacConfigSha256,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function condition(item) {
|
||||
const c = (item.status?.conditions || []).find((x) => x.type === 'Succeeded') || {};
|
||||
return { status: c.status || '', reason: c.reason || '', message: c.message || '' };
|
||||
@@ -671,6 +789,7 @@ function rowFor(consumer, item, taskRuns) {
|
||||
const rows = [];
|
||||
const consumerSummaries = [];
|
||||
for (const consumer of consumers) {
|
||||
consumer.admissionState = admissionStateForConsumer(consumer);
|
||||
const pipelineRuns = kubectlJson(['-n', consumer.namespace, 'get', 'pipelinerun', '-o', 'json'], { items: [] }, `${consumer.id}:pipelinerun`);
|
||||
const taskRuns = kubectlJson(['-n', consumer.namespace, 'get', 'taskrun', '-o', 'json'], { items: [] }, `${consumer.id}:taskrun`);
|
||||
let matches = (pipelineRuns.items || []).filter((item) => {
|
||||
@@ -914,25 +1033,35 @@ NODE
|
||||
observed=$(printf '%s\n' "$fields" | sed -n 's/^observed=//p' | base64 -d 2>/dev/null || true)
|
||||
repo_url=$(printf '%s\n' "$fields" | sed -n 's/^repoUrl=//p' | base64 -d 2>/dev/null || true)
|
||||
target_revision=$(printf '%s\n' "$fields" | sed -n 's/^targetRevision=//p' | base64 -d 2>/dev/null || true)
|
||||
expected_repo_url="${UNIDESK_PAC_GITOPS_EXPECTED_REPO_URL:-}"
|
||||
expected_target_revision="${UNIDESK_PAC_GITOPS_EXPECTED_TARGET_REVISION:-}"
|
||||
[ -n "$expected_repo_url" ] || expected_repo_url="$repo_url"
|
||||
[ -n "$expected_target_revision" ] || expected_target_revision="$target_revision"
|
||||
normalized_expected_repo=$(printf '%s' "$expected_repo_url" | sed 's#/*$##')
|
||||
normalized_observed_repo=$(printf '%s' "$repo_url" | sed 's#/*$##')
|
||||
retained=$(printf '%s\n' "$fields" | sed -n 's/^retained=//p' | base64 -d 2>/dev/null || true)
|
||||
relation=unknown
|
||||
proof=unavailable
|
||||
reason=missing-revision-context
|
||||
fetch_ok=false
|
||||
if [ "$retained" = true ] && printf '%s' "$observed" | grep -Eq '^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$'; then
|
||||
if [ "$normalized_expected_repo" != "$normalized_observed_repo" ]; then
|
||||
reason=repository-mismatch
|
||||
elif [ "$expected_target_revision" != "$target_revision" ]; then
|
||||
reason=target-revision-mismatch
|
||||
elif [ "$retained" = true ] && printf '%s' "$observed" | grep -Eq '^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$'; then
|
||||
relation=retained
|
||||
proof=structured-no-runtime-change-plan
|
||||
reason=source-observation-retains-current-revision
|
||||
elif printf '%s\n%s\n' "$expected" "$observed" | grep -Eqv '^[0-9a-fA-F]{7,64}$'; then
|
||||
reason=invalid-or-missing-revision
|
||||
elif [ -z "$repo_url" ] \
|
||||
|| ! git check-ref-format "refs/heads/$target_revision" >/dev/null 2>&1; then
|
||||
elif [ -z "$expected_repo_url" ] \
|
||||
|| ! git check-ref-format "refs/heads/$expected_target_revision" >/dev/null 2>&1; then
|
||||
reason=invalid-or-missing-git-context
|
||||
else
|
||||
repo_dir=$(mktemp -d)
|
||||
if resolved_url=$(resolve_git_service_url "$repo_url") \
|
||||
if resolved_url=$(resolve_git_service_url "$expected_repo_url") \
|
||||
&& git init --bare -q "$repo_dir/repo.git" \
|
||||
&& timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" git --git-dir="$repo_dir/repo.git" fetch -q --no-tags "$resolved_url" "+refs/heads/$target_revision:refs/remotes/origin/$target_revision" >/dev/null 2>&1; then
|
||||
&& timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" git --git-dir="$repo_dir/repo.git" fetch -q --no-tags "$resolved_url" "+refs/heads/$expected_target_revision:refs/remotes/origin/$expected_target_revision" >/dev/null 2>&1; then
|
||||
fetch_ok=true
|
||||
if git --git-dir="$repo_dir/repo.git" cat-file -e "$expected^{commit}" 2>/dev/null \
|
||||
&& git --git-dir="$repo_dir/repo.git" cat-file -e "$observed^{commit}" 2>/dev/null; then
|
||||
@@ -964,6 +1093,8 @@ NODE
|
||||
UNIDESK_PAC_GITOPS_OBSERVED="$observed" \
|
||||
UNIDESK_PAC_GITOPS_REPO_URL="$repo_url" \
|
||||
UNIDESK_PAC_GITOPS_TARGET_REVISION="$target_revision" \
|
||||
UNIDESK_PAC_GITOPS_EXPECTED_REPO_URL_OBSERVED="$expected_repo_url" \
|
||||
UNIDESK_PAC_GITOPS_EXPECTED_TARGET_REVISION_OBSERVED="$expected_target_revision" \
|
||||
UNIDESK_PAC_GITOPS_RELATION="$relation" \
|
||||
UNIDESK_PAC_GITOPS_PROOF="$proof" \
|
||||
UNIDESK_PAC_GITOPS_REASON="$reason" \
|
||||
@@ -974,6 +1105,10 @@ process.stdout.write(JSON.stringify({
|
||||
observedRevision: process.env.UNIDESK_PAC_GITOPS_OBSERVED || null,
|
||||
repoURL: process.env.UNIDESK_PAC_GITOPS_REPO_URL || null,
|
||||
targetRevision: process.env.UNIDESK_PAC_GITOPS_TARGET_REVISION || null,
|
||||
expectedRepoURL: process.env.UNIDESK_PAC_GITOPS_EXPECTED_REPO_URL_OBSERVED || null,
|
||||
expectedTargetRevision: process.env.UNIDESK_PAC_GITOPS_EXPECTED_TARGET_REVISION_OBSERVED || null,
|
||||
observedRepoURL: process.env.UNIDESK_PAC_GITOPS_REPO_URL || null,
|
||||
observedTargetRevision: process.env.UNIDESK_PAC_GITOPS_TARGET_REVISION || null,
|
||||
proof: process.env.UNIDESK_PAC_GITOPS_PROOF || 'unavailable',
|
||||
reason: process.env.UNIDESK_PAC_GITOPS_REASON || null,
|
||||
fetchOk: process.env.UNIDESK_PAC_GITOPS_FETCH_OK === 'true',
|
||||
@@ -1156,6 +1291,7 @@ status_action() {
|
||||
crd=$(kubectl get crd repositories.pipelinesascode.tekton.dev -o name 2>/dev/null || true)
|
||||
controller_ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0")
|
||||
repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME")
|
||||
prepare_admission_provenance_state
|
||||
pipelines=$(pipeline_rows)
|
||||
latest=$(printf '%s' "$pipelines" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(a[0]?.name||"")')
|
||||
export UNIDESK_PAC_TARGET_PIPELINERUN="$latest"
|
||||
@@ -1179,10 +1315,30 @@ NODE
|
||||
)
|
||||
runtime=$(json_normalize "$(runtime_summary)")
|
||||
diagnostics=$(cicd_diagnostics "$pipelines" "$artifact" "$argo" "$runtime")
|
||||
printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \
|
||||
"$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \
|
||||
admission_ready=$(UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_STATE||"{}"); process.stdout.write(s.ready===true?"true":"false")')
|
||||
diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node <<'NODE'
|
||||
const diagnostics = JSON.parse(process.env.UNIDESK_PAC_DIAGNOSTICS || '{}');
|
||||
const admissionProvenance = JSON.parse(process.env.UNIDESK_PAC_STATE || '{}');
|
||||
if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionProvenance.ready !== true) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
...diagnostics,
|
||||
ok: false,
|
||||
code: 'pac-admission-provenance-not-ready',
|
||||
phase: 'admission-provenance-not-ready',
|
||||
hint: 'required PaC admission policy, binding, desired RBAC, or live default-SA mutation gate is not ready',
|
||||
admissionProvenance,
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify({ ...diagnostics, admissionProvenance, valuesPrinted: false }));
|
||||
}
|
||||
NODE
|
||||
)
|
||||
printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \
|
||||
"$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \
|
||||
"$( [ -n "$crd" ] && echo true || echo false )" \
|
||||
"$(json_string "$controller_ready")" \
|
||||
"$UNIDESK_PAC_ADMISSION_STATE_JSON" \
|
||||
"$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" \
|
||||
"$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \
|
||||
"$(json_string "$UNIDESK_PAC_GITEA_REPO")" \
|
||||
@@ -1197,14 +1353,16 @@ history_action() {
|
||||
controller_ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0")
|
||||
repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME")
|
||||
hooks=$(hook_summary)
|
||||
prepare_admission_provenance_state
|
||||
history=$(history_rows)
|
||||
rows=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.rows||[]));')
|
||||
consumers=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.consumers||[]));')
|
||||
errors=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.errors||[]));')
|
||||
printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","repositoryCondition":"%s","repository":{"name":"%s","repo":"%s/%s","url":"%s"},"consumer":"%s","display":{"timeZone":"%s"},"consumerRows":%s,"rows":%s,"historyErrors":%s,"webhooks":%s,"source":"gitea-pac-tekton-live","historyStore":"none","valuesPrinted":false}\n' \
|
||||
printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"repositoryCondition":"%s","repository":{"name":"%s","repo":"%s/%s","url":"%s"},"consumer":"%s","display":{"timeZone":"%s"},"consumerRows":%s,"rows":%s,"historyErrors":%s,"webhooks":%s,"source":"gitea-pac-tekton-live","historyStore":"none","valuesPrinted":false}\n' \
|
||||
"$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \
|
||||
"$( [ -n "$crd" ] && echo true || echo false )" \
|
||||
"$(json_string "$controller_ready")" \
|
||||
"$UNIDESK_PAC_ADMISSION_STATE_JSON" \
|
||||
"$(json_string "$repository_condition")" \
|
||||
"$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" \
|
||||
"$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \
|
||||
@@ -1227,6 +1385,7 @@ NODE
|
||||
FIXTURES_JSON="$fixtures" node -e 'const result=JSON.parse(process.env.FIXTURES_JSON||"{}"); if(!result.ok) process.exitCode=1'
|
||||
return
|
||||
fi
|
||||
prepare_admission_provenance_state
|
||||
history=$(history_rows)
|
||||
UNIDESK_PAC_DEBUG_FIXTURES_JSON="$fixtures" UNIDESK_PAC_DEBUG_HISTORY_JSON="$history" node <<'NODE'
|
||||
function record(value) {
|
||||
@@ -1269,8 +1428,8 @@ if (historyErrors.length > 0) breakAt('target-read-failed', 'target-read', histo
|
||||
if (Object.keys(row).length === 0) breakAt('pipeline-run-not-found', 'pipeline-run', `PipelineRun ${process.env.UNIDESK_PAC_HISTORY_ID || '-'} was not found for the selected consumer`);
|
||||
if (Object.keys(row).length > 0 && row.deliveryAuthorityEligible !== true) breakAt('pipeline-run-not-delivery-authority', 'pipeline-run', `PipelineRun is classified as ${row.deliveryClass || 'unknown'}; only an outer PaC event can drive delivery closeout`);
|
||||
if (Object.keys(row).length > 0 && row.status !== 'True') breakAt('pipeline-run-not-succeeded', 'pipeline-run', `PipelineRun terminal status is ${row.status || row.reason || 'unknown'}`);
|
||||
if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'g14-ci-plan structured evidence is missing');
|
||||
if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'g14-ci-plan source commit does not match the selected PipelineRun');
|
||||
if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'delivery plan structured evidence is missing');
|
||||
if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'delivery plan source commit does not match the selected PipelineRun');
|
||||
if (sourceObservation.valid !== true) breakAt('source-observation-invalid', 'plan-artifacts', sourceObservation.reason || 'delivery plan is incomplete or contradictory');
|
||||
for (const item of terminal) {
|
||||
if (item.present !== true || item.status !== 'succeeded') breakAt('terminal-evidence-incomplete', item.step, `${item.step} TaskRun terminal status is ${item.status}`);
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
const declared = binding.consumer.sourceArtifact.taskRunTemplate;
|
||||
return {
|
||||
serviceAccountName: `{{ service_account }}`,
|
||||
serviceAccountName: binding.consumer.deliveryProvenance?.executionServiceAccountName ?? `{{ service_account }}`,
|
||||
podTemplate: {
|
||||
hostNetwork: declared.hostNetwork,
|
||||
dnsPolicy: declared.dnsPolicy,
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
function readPacConfig(): PacConfig {
|
||||
const root = materializeYamlComposition(readYamlRecord<Record<string, unknown>>(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<string, unknown>, path: string): Pac
|
||||
function parseConsumer(consumer: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, path: string, defaultR
|
||||
};
|
||||
}
|
||||
|
||||
function parseConsumerDeliveryProvenance(value: Record<string, unknown>, 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<string, unknown>, 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<string>();
|
||||
const markerValues = new Set<string>();
|
||||
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<string, unknown>[], detailId: string | null): {
|
||||
readonly fixtureGate: Record<string, unknown>;
|
||||
readonly checks: readonly Record<string, unknown>[];
|
||||
} {
|
||||
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<string> {
|
||||
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<string> {
|
||||
|
||||
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<string, string> = {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown
|
||||
const argo = record(payload.argo);
|
||||
const runtime = record(payload.runtime);
|
||||
const diagnostics = record(payload.diagnostics);
|
||||
const admissionProvenance = record(payload.admissionProvenance);
|
||||
const webhooks = arrayRecords(payload.webhooks);
|
||||
const rawArtifact = record(payload.artifact);
|
||||
const sourceObservation = record(rawArtifact.sourceObservation);
|
||||
@@ -1328,6 +1443,7 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
|
||||
argo,
|
||||
runtime,
|
||||
diagnostics,
|
||||
admissionProvenance,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -1612,6 +1728,7 @@ function compactStatusSummary(summary: Record<string, unknown>): Record<string,
|
||||
},
|
||||
},
|
||||
pipelineRunGate: summary.pipelineRunGate,
|
||||
admissionProvenance: summary.admissionProvenance,
|
||||
sourceObservation: summary.sourceObservation,
|
||||
artifact: {
|
||||
imageStatus: artifact.imageStatus,
|
||||
@@ -1709,6 +1826,11 @@ function compactHistoryJson(result: Record<string, unknown>, 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<string, unknown>): 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<string, unknown>): 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)]))),
|
||||
|
||||
Reference in New Issue
Block a user