fix: 隔离 Sub2Rank PaC 执行身份
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user