From f99aefafbf101161f5ab4d471a33beb923654788 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 08:12:47 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=9A=94=E7=A6=BB=20Sub2Rank=20PaC=20?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/pipelines-as-code.yaml | 5 ++- config/platform-infra/sub2rank.yaml | 2 +- scripts/native/cicd/pac-status-evaluator.cjs | 28 +++++++++++++ ...trun-managed-repository-reconciler.test.ts | 3 ++ ...atform-infra-gitea-gitops-delivery.test.ts | 1 + .../src/platform-infra-pac-consumer-rbac.ts | 39 ++++++++++++++++++- .../src/platform-infra-pac-provenance.test.ts | 37 +++++++++++++++--- scripts/src/platform-infra-pac-provenance.ts | 13 +++++++ ...platform-infra-pipelines-as-code-remote.sh | 35 +++++++++++++++++ ...infra-pipelines-as-code-source-artifact.ts | 10 +++++ .../src/platform-infra-pipelines-as-code.ts | 18 +++++++++ .../src/platform-infra-sub2rank-pipeline.ts | 21 ++++++++++ 12 files changed, 201 insertions(+), 11 deletions(-) diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 81a90780..c9ef5b00 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -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 diff --git a/config/platform-infra/sub2rank.yaml b/config/platform-infra/sub2rank.yaml index 7594352a..2c6b02e4 100644 --- a/config/platform-infra/sub2rank.yaml +++ b/config/platform-infra/sub2rank.yaml @@ -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 diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index f14ffb46..123a4811 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -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, diff --git a/scripts/src/agentrun-managed-repository-reconciler.test.ts b/scripts/src/agentrun-managed-repository-reconciler.test.ts index 51c406f8..f5ecf449 100644 --- a/scripts/src/agentrun-managed-repository-reconciler.test.ts +++ b/scripts/src/agentrun-managed-repository-reconciler.test.ts @@ -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", diff --git a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts index 3c8fbdff..c0bc2342 100644 --- a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts +++ b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts @@ -80,6 +80,7 @@ test("owning YAML renders one child Application and the complete durable bridge "RoleBinding", "Role", "RoleBinding", + "ServiceAccount", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", diff --git a/scripts/src/platform-infra-pac-consumer-rbac.ts b/scripts/src/platform-infra-pac-consumer-rbac.ts index 58ee9a87..12963abc 100644 --- a/scripts/src/platform-infra-pac-consumer-rbac.ts +++ b/scripts/src/platform-infra-pac-consumer-rbac.ts @@ -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[] = 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, }; } diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index 872d49f0..26c3f28f 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -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: { diff --git a/scripts/src/platform-infra-pac-provenance.ts b/scripts/src/platform-infra-pac-provenance.ts index 434bbb02..951cc337 100644 --- a/scripts/src/platform-infra-pac-provenance.ts +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -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): 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): 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(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; +} diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 30d88de2..61b9a669 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -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, }, }); } diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index f5550b76..bc1da2af 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -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; diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 21eff00c..c8c595d4 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -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, 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(); const markerValues = new Set(); + const reservedServiceAccounts = new Set(); for (const consumer of config.consumers) { if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`); consumerIds.add(consumer.id); @@ -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> = { 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 {