fix: 隔离 Sub2Rank PaC 执行身份

This commit is contained in:
Codex
2026-07-13 08:12:47 +02:00
parent 18ac5d33ba
commit f99aefafbf
12 changed files with 201 additions and 11 deletions
+3 -2
View File
@@ -144,7 +144,7 @@ repositories:
gitops_manifest_path: deploy/gitops/platform-infra/sub2rank-nc01/resources.yaml
pipeline_name: platform-infra-sub2rank-nc01-pac
pipeline_run_prefix: sub2rank-nc01
service_account: default
service_account: sub2rank-nc01-tekton-runner
pipeline_timeout: 600s
workspace_pvc_size: 4Gi
runtime_namespace: platform-infra
@@ -245,7 +245,8 @@ consumers:
deliveryProvenance:
required: true
markerValue: admission-pac-v2:platform-infra-sub2rank-nc01
executionServiceAccountName: default
executionServiceAccountName: sub2rank-nc01-tekton-runner
manageExecutionServiceAccount: true
gitOps:
repoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git
targetRevision: unidesk-host-gitops
+1 -1
View File
@@ -29,7 +29,7 @@ delivery:
namespace: devops-infra
name: platform-infra-sub2rank-nc01-pac
runPrefix: sub2rank-nc01
serviceAccountName: default
serviceAccountName: sub2rank-nc01-tekton-runner
timeout: 600s
workspaceSize: 4Gi
toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1
@@ -112,6 +112,8 @@ function evaluatePacAdmissionState(inputValue) {
const role = record(input.role);
const roleBinding = record(input.roleBinding);
const expected = record(input.expected);
const executionServiceAccounts = Array.isArray(input.executionServiceAccounts) ? input.executionServiceAccounts.map(record) : [];
const expectedExecutionServiceAccounts = Array.isArray(expected.managedExecutionServiceAccounts) ? expected.managedExecutionServiceAccounts.map(record) : [];
const namespace = stringOrNull(input.namespace);
const policyMetadata = record(policy.metadata);
const bindingMetadata = record(binding.metadata);
@@ -167,6 +169,31 @@ function evaluatePacAdmissionState(inputValue) {
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 executionServiceAccountState = expectedExecutionServiceAccounts.map((expectedServiceAccount) => {
const expectedNamespace = stringOrNull(expectedServiceAccount.namespace);
const expectedName = stringOrNull(expectedServiceAccount.name);
const expectedConsumerId = stringOrNull(expectedServiceAccount.consumerId);
const live = executionServiceAccounts.find((item) => {
const metadata = record(item.metadata);
return metadata.namespace === expectedNamespace && metadata.name === expectedName;
});
const metadata = record(live?.metadata);
const annotations = record(metadata.annotations);
const labels = record(metadata.labels);
const identity = expectedConsumerId || expectedName || "unknown";
if (live === undefined) reasons.push(`execution-serviceaccount-missing:${identity}`);
if (live !== undefined && live.automountServiceAccountToken !== false) reasons.push(`execution-serviceaccount-automount-not-false:${identity}`);
if (live !== undefined && annotations["unidesk.ai/effective-config-sha256"] !== expected.rbacConfigSha256) reasons.push(`execution-serviceaccount-config-sha256-mismatch:${identity}`);
if (live !== undefined && labels["unidesk.ai/pac-consumer"] !== expectedConsumerId) reasons.push(`execution-serviceaccount-consumer-mismatch:${identity}`);
return {
consumerId: expectedConsumerId,
namespace: expectedNamespace,
name: expectedName,
present: live !== undefined,
automountServiceAccountToken: live === undefined ? null : live.automountServiceAccountToken === false ? false : true,
configSha256: stringOrNull(annotations["unidesk.ai/effective-config-sha256"]),
};
});
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));
@@ -189,6 +216,7 @@ function evaluatePacAdmissionState(inputValue) {
observedGeneration: Number.isInteger(policyStatus.observedGeneration) ? policyStatus.observedGeneration : null,
rbacConfigSha256: stringOrNull(roleAnnotations["unidesk.ai/effective-config-sha256"]),
defaultCanMutate: typeof input.defaultCanMutate === "boolean" ? input.defaultCanMutate : null,
executionServiceAccounts: executionServiceAccountState,
activeAfter,
reasons,
valuesPrinted: false,
@@ -89,6 +89,9 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
expect(documents.map((item) => item.kind)).toEqual([
"Role",
"RoleBinding",
"Role",
"RoleBinding",
"ServiceAccount",
"ValidatingAdmissionPolicy",
"ValidatingAdmissionPolicyBinding",
"ServiceAccount",
@@ -80,6 +80,7 @@ test("owning YAML renders one child Application and the complete durable bridge
"RoleBinding",
"Role",
"RoleBinding",
"ServiceAccount",
"ValidatingAdmissionPolicy",
"ValidatingAdmissionPolicyBinding",
"ServiceAccount",
@@ -5,6 +5,12 @@ import { readPacAdmissionContract } from "./platform-infra-pac-provenance";
export interface PacConsumerRbacDesiredIdentity {
readonly configSha256: string;
readonly namespaces: readonly string[];
readonly executionServiceAccounts: readonly {
readonly consumerId: string;
readonly namespace: string;
readonly name: string;
readonly automountServiceAccountToken: false;
}[];
}
const pacConsumerReadOnlyRules = [
@@ -19,7 +25,7 @@ export function renderPacConsumerRbacDesiredFragment(targetId: string): string {
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 objects: Record<string, unknown>[] = namespaces.flatMap((namespace) => {
const labels = {
"app.kubernetes.io/managed-by": "unidesk",
"app.kubernetes.io/part-of": "platform-infra",
@@ -45,6 +51,25 @@ export function renderPacConsumerRbacDesiredFragment(targetId: string): string {
},
];
});
objects.push(...consumers.filter((consumer) => consumer.manageExecutionServiceAccount).map((consumer) => ({
apiVersion: "v1",
kind: "ServiceAccount",
metadata: {
name: consumer.executionServiceAccountName,
namespace: consumer.namespace,
labels: {
"app.kubernetes.io/managed-by": "unidesk",
"app.kubernetes.io/part-of": "platform-infra",
"unidesk.ai/pac-rbac-contract": "admission-provenance-required",
"unidesk.ai/pac-consumer": consumer.id,
},
annotations: {
"argocd.argoproj.io/sync-wave": "-2",
"unidesk.ai/effective-config-sha256": configSha256,
},
},
automountServiceAccountToken: false,
})));
return `${objects.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
}
@@ -60,8 +85,18 @@ export function pacConsumerRbacDesiredIdentity(targetId: string): PacConsumerRba
roleRef: pacConsumerDefaultRoleRef,
},
}));
const executionServiceAccounts = consumers
.filter((consumer) => consumer.manageExecutionServiceAccount)
.map((consumer) => ({
consumerId: consumer.id,
namespace: consumer.namespace,
name: consumer.executionServiceAccountName,
automountServiceAccountToken: false as const,
}))
.sort((left, right) => `${left.namespace}/${left.name}`.localeCompare(`${right.namespace}/${right.name}`));
return {
configSha256: `sha256:${createHash("sha256").update(JSON.stringify({ targetId, desiredSpec, contract: "admission-provenance-required-v1" })).digest("hex")}`,
configSha256: `sha256:${createHash("sha256").update(JSON.stringify({ targetId, desiredSpec, executionServiceAccounts, contract: "admission-provenance-required-v2" })).digest("hex")}`,
namespaces,
executionServiceAccounts,
};
}
@@ -25,9 +25,18 @@ function documents(value: string): any[] {
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");
const malformedRequired = structuredClone(source);
malformedRequired.consumers.find((item: any) => item.deliveryProvenance !== undefined).deliveryProvenance.required = "true";
expect(() => parsePacAdmissionContract(malformedRequired)).toThrow("deliveryProvenance.required must be boolean true or false");
const sharedDefault = structuredClone(source);
sharedDefault.consumers.find((item: any) => item.deliveryProvenance !== undefined).deliveryProvenance.executionServiceAccountName = "default";
expect(() => parsePacAdmissionContract(sharedDefault)).toThrow("must not reserve the shared default ServiceAccount");
const duplicatedServiceAccount = structuredClone(source);
const duplicatedRequiredConsumers = duplicatedServiceAccount.consumers.filter((item: any) => item.deliveryProvenance !== undefined);
duplicatedRequiredConsumers[1].deliveryProvenance.executionServiceAccountName = duplicatedRequiredConsumers[0].deliveryProvenance.executionServiceAccountName;
expect(() => parsePacAdmissionContract(duplicatedServiceAccount)).toThrow("executionServiceAccountName must be unique per target");
});
test("admission queue transition contract rejects absent sources, unknown Tekton states, and duplicate transitions", () => {
@@ -79,6 +88,8 @@ test("versioned admission desired state protects controller proof, candidate ide
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(expressions).toContain("sub2rank-nc01-tekton-runner");
expect(expressions).not.toContain('serviceAccountName == "default"');
expect(messages).toContain("execution ServiceAccount");
expect(expressions).toContain(contract.child.parentUidAnnotation);
expect(expressions).toContain("oldObject");
@@ -113,26 +124,34 @@ test("versioned admission desired state protects controller proof, candidate ide
expect(queueGuard.expression.match(/admission-pac-v2:agentrun-nc01-v02/gu)).toHaveLength(2);
expect(queueGuard.expression.match(/pipelines-as-code-watcher/gu)).toHaveLength(1);
expect(queueGuard.expression.match(/agentrun-nc01-v02-tekton-runner/gu)).toHaveLength(2);
expect(queueGuard.expression.match(/sub2rank-nc01-tekton-runner/gu)).toHaveLength(2);
expect(queueGuard.expression).not.toContain("admission-pac-v1:");
expect(queueGuard.expression).not.toContain('== "Cancelled"');
});
test("required consumers have one automatic desired owner per namespace and no Tekton mutation verbs", () => {
const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01"));
expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding"]);
expect(rbac.map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "agentrun-ci", "devops-infra", "devops-infra"]);
expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "ServiceAccount"]);
expect(rbac.map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "agentrun-ci", "devops-infra", "devops-infra", "devops-infra"]);
for (const role of rbac.filter((item) => item.kind === "Role")) {
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 executionServiceAccount = rbac.find((item) => item.kind === "ServiceAccount");
expect(executionServiceAccount).toMatchObject({
metadata: { name: "sub2rank-nc01-tekton-runner", namespace: "devops-infra" },
automountServiceAccountToken: false,
});
expect(executionServiceAccount.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-2");
const desiredKinds = documents(renderPlatformInfraGiteaDesiredFragments("NC01")).map((item) => item.kind);
expect(desiredKinds).toEqual([
"Role",
"RoleBinding",
"Role",
"RoleBinding",
"ServiceAccount",
"ValidatingAdmissionPolicy",
"ValidatingAdmissionPolicyBinding",
"ServiceAccount",
@@ -145,6 +164,7 @@ test("live admission evaluator requires exact desired hashes, observed generatio
const contract = readPacAdmissionContract();
const admission = documents(renderPacAdmissionDesiredFragment("NC01"));
const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01"));
const executionServiceAccount = rbac.find((item) => item.kind === "ServiceAccount");
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 = {
@@ -155,9 +175,14 @@ test("live admission evaluator requires exact desired hashes, observed generatio
controllerIdentity: contract.controllerUsername,
configSha256: policy.metadata.annotations["unidesk.ai/effective-config-sha256"],
rbacConfigSha256: rbac[0].metadata.annotations["unidesk.ai/effective-config-sha256"],
managedExecutionServiceAccounts: pacConsumerRbacDesiredIdentity("NC01").executionServiceAccounts,
};
const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, expected };
const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, executionServiceAccounts: [executionServiceAccount], expected };
expect(evaluator.evaluatePacAdmissionState(input)).toMatchObject({ ready: true, activeAfter: "2026-01-01T00:00:01Z", defaultCanMutate: false });
expect(evaluator.evaluatePacAdmissionState({ ...input, executionServiceAccounts: [] })).toMatchObject({
ready: false,
reasons: expect.arrayContaining(["execution-serviceaccount-missing:platform-infra-sub2rank-nc01"]),
});
expect(evaluator.evaluatePacAdmissionState({
...input,
policy: {
@@ -14,6 +14,7 @@ export interface PacAdmissionConsumerContract {
readonly pipelineRunPrefix: string;
readonly markerValue: string;
readonly executionServiceAccountName: string;
readonly manageExecutionServiceAccount: boolean;
readonly gitOps: { readonly repoUrl: string; readonly targetRevision: string };
}
@@ -98,6 +99,7 @@ export function parsePacAdmissionContract(source: Record<string, unknown>): PacA
pipelineRunPrefix: required(consumer.pipelineRunPrefix, `consumers[${index}].pipelineRunPrefix`),
markerValue: required(value.markerValue, `consumers[${index}].deliveryProvenance.markerValue`),
executionServiceAccountName: required(value.executionServiceAccountName, `consumers[${index}].deliveryProvenance.executionServiceAccountName`),
manageExecutionServiceAccount: optionalBoolean(value.manageExecutionServiceAccount, `consumers[${index}].deliveryProvenance.manageExecutionServiceAccount`, false),
gitOps: {
repoUrl: required(gitOps.repoUrl, `consumers[${index}].deliveryProvenance.gitOps.repoUrl`),
targetRevision: required(gitOps.targetRevision, `consumers[${index}].deliveryProvenance.gitOps.targetRevision`),
@@ -106,6 +108,11 @@ export function parsePacAdmissionContract(source: Record<string, unknown>): PacA
});
const markerValues = new Set(consumers.map((consumer) => consumer.markerValue));
if (markerValues.size !== consumers.length) throw new Error("deliveryProvenance markerValue must be unique per consumer");
for (const consumer of consumers) {
if (consumer.executionServiceAccountName === "default") throw new Error(`deliveryProvenance executionServiceAccountName for ${consumer.id} must not reserve the shared default ServiceAccount`);
}
const serviceAccountKeys = consumers.map((consumer) => `${consumer.node.toLowerCase()}\u0000${consumer.executionServiceAccountName}`);
if (new Set(serviceAccountKeys).size !== serviceAccountKeys.length) throw new Error("deliveryProvenance executionServiceAccountName must be unique per target");
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");
@@ -393,3 +400,9 @@ function literal<T extends string | boolean>(value: unknown, path: string, expec
if (value !== expected) throw new Error(`${path} must be ${expected}`);
return expected;
}
function optionalBoolean(value: unknown, path: string, fallback: boolean): boolean {
if (value === undefined) return fallback;
if (typeof value !== "boolean") throw new Error(`${path} must be boolean`);
return value;
}
@@ -291,10 +291,28 @@ admission_provenance_state() {
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 cp = require('node:child_process');
const { evaluatePacAdmissionState } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
function read(path) {
try { return JSON.parse(fs.readFileSync(path, 'utf8') || '{}'); } catch { return {}; }
}
let managedExecutionServiceAccounts = [];
try {
const parsed = JSON.parse(process.env.UNIDESK_PAC_MANAGED_EXECUTION_SERVICE_ACCOUNTS_JSON || '[]');
if (Array.isArray(parsed)) managedExecutionServiceAccounts = parsed;
} catch {}
const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10);
const kubectlTimeoutMs = Number.isInteger(waitTimeoutSeconds) && waitTimeoutSeconds > 0 ? waitTimeoutSeconds * 1000 : null;
const executionServiceAccounts = managedExecutionServiceAccounts.map((item) => {
if (kubectlTimeoutMs === null || !item || typeof item.namespace !== 'string' || typeof item.name !== 'string') return {};
const result = cp.spawnSync('kubectl', ['-n', item.namespace, 'get', 'serviceaccount', item.name, '-o', 'json'], {
encoding: 'utf8',
timeout: kubectlTimeoutMs,
maxBuffer: 1024 * 1024,
});
if (result.error || result.status !== 0) return {};
try { return JSON.parse(result.stdout || '{}'); } catch { return {}; }
});
process.stdout.write(JSON.stringify(evaluatePacAdmissionState({
policy: read(process.argv[2]),
binding: read(process.argv[3]),
@@ -302,6 +320,7 @@ process.stdout.write(JSON.stringify(evaluatePacAdmissionState({
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',
executionServiceAccounts,
expected: {
targetId: process.env.UNIDESK_PAC_TARGET_ID || '',
policyName: process.env.UNIDESK_PAC_ADMISSION_POLICY_NAME || '',
@@ -310,6 +329,7 @@ process.stdout.write(JSON.stringify(evaluatePacAdmissionState({
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 || '',
managedExecutionServiceAccounts,
},
})));
NODE
@@ -470,6 +490,7 @@ function kubectlJson(args, fallback, context) {
let admissionPolicy = null;
let admissionBinding = null;
const admissionRbac = new Map();
const admissionExecutionServiceAccounts = new Map();
function defaultCanMutate(namespace) {
const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10);
@@ -507,6 +528,18 @@ function admissionStateForConsumer(consumer) {
});
}
const rbac = admissionRbac.get(consumer.namespace);
const managedExecutionServiceAccounts = Array.isArray(expected.managedExecutionServiceAccounts) ? expected.managedExecutionServiceAccounts : [];
const executionServiceAccounts = managedExecutionServiceAccounts.map((item) => {
const key = `${item.namespace || ''}/${item.name || ''}`;
if (!admissionExecutionServiceAccounts.has(key)) {
admissionExecutionServiceAccounts.set(key, kubectlJson(
['-n', item.namespace, 'get', 'serviceaccount', item.name, '-o', 'json'],
{},
`${consumer.id}:execution-serviceaccount:${key}`,
));
}
return admissionExecutionServiceAccounts.get(key);
});
return evaluatePacAdmissionState({
policy: admissionPolicy,
binding: admissionBinding,
@@ -514,6 +547,7 @@ function admissionStateForConsumer(consumer) {
roleBinding: rbac.roleBinding,
namespace: consumer.namespace,
defaultCanMutate: rbac.defaultCanMutate,
executionServiceAccounts,
expected: {
targetId: expected.targetId,
policyName: expected.policyName,
@@ -522,6 +556,7 @@ function admissionStateForConsumer(consumer) {
controllerIdentity: expected.controllerIdentity,
configSha256: expected.configSha256,
rbacConfigSha256: expected.rbacConfigSha256,
managedExecutionServiceAccounts,
},
});
}
@@ -49,6 +49,16 @@ export interface PacSourceArtifactBinding {
readonly namespace: string;
readonly pipeline: string;
readonly pipelineRunPrefix: string;
readonly argoNamespace: string;
readonly argoApplication: string;
readonly argoBootstrap: {
readonly project: string;
readonly repoUrl: string;
readonly targetRevision: string;
readonly path: string;
readonly destinationNamespace: string;
readonly automated: boolean;
} | null;
readonly deliveryProvenance?: {
readonly markerAnnotation: string;
readonly markerValue: string;
@@ -159,6 +159,7 @@ interface PacConsumer {
required: true;
markerValue: string;
executionServiceAccountName: string;
manageExecutionServiceAccount: boolean;
gitOps: { repoUrl: string; targetRevision: string };
} | null;
repositoryRef: string;
@@ -271,6 +272,9 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
namespace: consumer.namespace,
pipeline: consumer.pipeline,
pipelineRunPrefix: consumer.pipelineRunPrefix,
argoNamespace: consumer.argoNamespace,
argoApplication: consumer.argoApplication,
argoBootstrap: consumer.argoBootstrap,
deliveryProvenance: consumer.deliveryProvenance === null ? null : {
markerAnnotation: pac.deliveryProvenance.markerAnnotation,
markerValue: consumer.deliveryProvenance.markerValue,
@@ -528,6 +532,9 @@ function parseConsumerDeliveryProvenance(value: Record<string, unknown>, path: s
required: true,
markerValue: y.stringField(value, "markerValue", path),
executionServiceAccountName: y.kubernetesNameField(value, "executionServiceAccountName", path),
manageExecutionServiceAccount: value.manageExecutionServiceAccount === undefined
? false
: y.booleanField(value, "manageExecutionServiceAccount", path),
gitOps: {
repoUrl: urlField(gitOps, "repoUrl", `${path}.gitOps`),
targetRevision: y.stringField(gitOps, "targetRevision", `${path}.gitOps`),
@@ -756,6 +763,7 @@ function validateConfig(config: PacConfig): void {
}
const consumerIds = new Set<string>();
const markerValues = new Set<string>();
const reservedServiceAccounts = 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);
@@ -765,6 +773,14 @@ function validateConfig(config: PacConfig): void {
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 (consumer.deliveryProvenance.executionServiceAccountName === "default") {
throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must not reserve the shared default ServiceAccount`);
}
const serviceAccountKey = `${consumer.node.toLowerCase()}\u0000${consumer.deliveryProvenance.executionServiceAccountName}`;
if (reservedServiceAccounts.has(serviceAccountKey)) {
throw new Error(`${configLabel}.consumers deliveryProvenance.executionServiceAccountName must be unique per target: ${consumer.deliveryProvenance.executionServiceAccountName}`);
}
reservedServiceAccounts.add(serviceAccountKey);
if (repository.params.service_account !== consumer.deliveryProvenance.executionServiceAccountName) {
throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must match repository params.service_account`);
}
@@ -1229,6 +1245,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac
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_MANAGED_EXECUTION_SERVICE_ACCOUNTS_JSON: JSON.stringify(rbacIdentity.executionServiceAccounts),
UNIDESK_PAC_ADMISSION_POLICY_NAME: admissionIdentity.policyName,
UNIDESK_PAC_ADMISSION_BINDING_NAME: admissionIdentity.bindingName,
UNIDESK_PAC_ADMISSION_VERSION: admissionIdentity.version,
@@ -1272,6 +1289,7 @@ function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record<st
bindingName: admissionIdentity.bindingName,
configSha256: admissionIdentity.configSha256,
rbacConfigSha256: rbacIdentity.configSha256,
managedExecutionServiceAccounts: rbacIdentity.executionServiceAccounts,
markerAnnotation: pac.deliveryProvenance.markerAnnotation,
markerValue: consumer.deliveryProvenance.markerValue,
executionServiceAccountName: consumer.deliveryProvenance.executionServiceAccountName,
@@ -224,6 +224,8 @@ function assertBinding(config: Sub2RankConfig, target: Sub2RankTarget, binding:
gitops_manifest_path: delivery.gitops.manifestPath,
pipeline_name: delivery.pipeline.name,
pipeline_run_prefix: delivery.pipeline.runPrefix,
pipeline_timeout: delivery.pipeline.timeout,
workspace_pvc_size: delivery.pipeline.workspaceSize,
};
const observed: Readonly<Record<string, string | undefined>> = {
node: binding.consumer.node,
@@ -236,6 +238,25 @@ function assertBinding(config: Sub2RankConfig, target: Sub2RankTarget, binding:
for (const [key, value] of Object.entries(expected)) {
if (observed[key] !== value) throw new Error(`Sub2Rank PaC binding ${key}=${String(observed[key])} does not match owning YAML ${value}`);
}
if (binding.consumer.deliveryProvenance?.executionServiceAccountName !== delivery.pipeline.serviceAccountName) {
throw new Error(`Sub2Rank PaC deliveryProvenance execution ServiceAccount does not match owning YAML ${delivery.pipeline.serviceAccountName}`);
}
const application = delivery.gitops.application;
if (binding.consumer.argoNamespace !== application.namespace) throw new Error(`Sub2Rank PaC argoNamespace does not match owning YAML ${application.namespace}`);
if (binding.consumer.argoApplication !== application.name) throw new Error(`Sub2Rank PaC argoApplication does not match owning YAML ${application.name}`);
const bootstrap = binding.consumer.argoBootstrap;
if (bootstrap === null) throw new Error("Sub2Rank PaC argoBootstrap must be declared");
const expectedBootstrap = {
project: application.project,
repoUrl: delivery.gitops.readUrl,
targetRevision: delivery.gitops.branch,
path: application.path,
destinationNamespace: application.destinationNamespace,
automated: application.automated,
} as const;
for (const [key, value] of Object.entries(expectedBootstrap)) {
if (bootstrap[key as keyof typeof expectedBootstrap] !== value) throw new Error(`Sub2Rank PaC argoBootstrap.${key} does not match owning YAML ${String(value)}`);
}
}
function pipelineOwner(config: Sub2RankConfig, target: Sub2RankTarget): Record<string, unknown> {