Merge pull request #2875 from pikasTech/fix/nc01-argo-gitea-desired
Pipelines as Code CI / platform-infra-gitea-nc01- Success

修复 NC01 PaC consumer 扇出与声明式所有权
This commit is contained in:
Lyon
2026-07-22 22:21:16 +08:00
committed by GitHub
15 changed files with 283 additions and 35 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ metadata:
annotations:
pipelinesascode.tekton.dev/on-event: "[push]"
pipelinesascode.tekton.dev/on-target-branch: "[master]"
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01'"
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01' && body.head_commit.message.startsWith('unidesk-release-plan-v1.platform-infra-gitea-nc01.')"
labels:
app.kubernetes.io/name: platform-infra-gitea-nc01-pac
app.kubernetes.io/part-of: platform-infra
+16 -1
View File
@@ -5,7 +5,7 @@ metadata:
annotations:
pipelinesascode.tekton.dev/on-event: "[push]"
pipelinesascode.tekton.dev/on-target-branch: "[master]"
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01'"
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01' && body.head_commit.message.startsWith('unidesk-release-plan-v1.platform-infra-temporal-nc01.')"
labels:
app.kubernetes.io/name: platform-infra-temporal-nc01-pac
app.kubernetes.io/part-of: temporal
@@ -42,6 +42,10 @@ spec:
env:
- name: SOURCE_COMMIT
value: "{{revision}}"
- name: BASE_COMMIT
value: "{{ body.before }}"
- name: REVIEWED_PLAN
value: "{{ body.head_commit.message }}"
- name: SOURCE_SNAPSHOT_PREFIX
value: "{{source_snapshot_prefix}}"
script: |
@@ -64,6 +68,17 @@ spec:
git fetch --depth=1 --filter=blob:none origin "+$SOURCE_SNAPSHOT_PREFIX/$SOURCE_COMMIT:refs/remotes/origin/unidesk-source-snapshot"
git checkout --detach "$SOURCE_COMMIT"
test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT"
printf '%s' "$REVIEWED_PLAN" >/workspace/reviewed-plan-envelope
cat >/workspace/actual-plan.json <<'JSON'
{"buildServices":[],"rolloutServices":["platform-infra-temporal-nc01"],"affectedServices":["platform-infra-temporal-nc01"],"reusedServices":[]}
JSON
bun scripts/native/cicd/verify-reviewed-release-plan.mjs \
--reviewed-plan-file /workspace/reviewed-plan-envelope \
--actual-plan-file /workspace/actual-plan.json \
--source-commit "$SOURCE_COMMIT" \
--base-commit "$BASE_COMMIT" \
--target NC01 \
--consumer platform-infra-temporal-nc01
bun scripts/cli.ts platform-infra temporal plan --target NC01
exec bun scripts/native/cicd/publish-platform-infra-temporal-gitops.ts \
--source-commit "$SOURCE_COMMIT" \
+1 -1
View File
@@ -5,7 +5,7 @@ metadata:
annotations:
pipelinesascode.tekton.dev/on-event: "[push]"
pipelinesascode.tekton.dev/on-target-branch: "[master]"
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01'"
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01' && body.head_commit.message.startsWith('unidesk-release-plan-v1.unidesk-host.')"
labels:
app.kubernetes.io/name: unidesk-host-pac
app.kubernetes.io/part-of: unidesk-host
+1 -1
View File
@@ -5,7 +5,7 @@ metadata:
annotations:
pipelinesascode.tekton.dev/on-event: "[push]"
pipelinesascode.tekton.dev/on-target-branch: "[master]"
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'JD01'"
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'JD01' && body.head_commit.message.startsWith('unidesk-release-plan-v1.sentinel-jd01-v03.')"
labels:
app.kubernetes.io/name: hwlab-web-probe-sentinel-jd01-pac
app.kubernetes.io/part-of: hwlab-web-probe-sentinel
+18 -1
View File
@@ -5,7 +5,7 @@ metadata:
annotations:
pipelinesascode.tekton.dev/on-event: "[push]"
pipelinesascode.tekton.dev/on-target-branch: "[master]"
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01'"
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01' && body.head_commit.message.startsWith('unidesk-release-plan-v1.sentinel-nc01-v03.')"
labels:
app.kubernetes.io/name: hwlab-web-probe-sentinel-nc01-pac
app.kubernetes.io/part-of: hwlab-web-probe-sentinel
@@ -46,6 +46,12 @@ spec:
image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1
imagePullPolicy: IfNotPresent
env:
- name: SOURCE_COMMIT
value: "{{revision}}"
- name: BASE_COMMIT
value: "{{ body.before }}"
- name: REVIEWED_PLAN
value: "{{ body.head_commit.message }}"
- name: GITEA_SOURCE_READ_URL
value: "{{git_read_url}}"
- name: GITEA_USERNAME
@@ -80,6 +86,17 @@ spec:
cd /workspace/bootstrap
git fetch --depth=1 --filter=blob:none origin "+refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01/{{revision}}:refs/remotes/origin/unidesk-source-snapshot"
git checkout --detach "{{revision}}"
printf '%s' "$REVIEWED_PLAN" >/workspace/reviewed-plan-envelope
cat >/workspace/actual-plan.json <<'JSON'
{"buildServices":["sentinel-nc01-v03"],"rolloutServices":["sentinel-nc01-v03"],"affectedServices":["sentinel-nc01-v03"],"reusedServices":[]}
JSON
bun scripts/native/cicd/verify-reviewed-release-plan.mjs \
--reviewed-plan-file /workspace/reviewed-plan-envelope \
--actual-plan-file /workspace/actual-plan.json \
--source-commit "$SOURCE_COMMIT" \
--base-commit "$BASE_COMMIT" \
--target NC01 \
--consumer sentinel-nc01-v03
bun scripts/native/cicd/render-sentinel-publish-task.ts \
--node NC01 \
--lane v03 \
@@ -277,6 +277,19 @@ Pipeline 必须在镜像构建或 GitOps 写入前校验 reviewed plan。实际
受控手动 `trigger` 必须同时接收 target、consumer 或 lane、精确 source commit 和 pipeline intent,并向 PaC 发送 webhookCLI 不得直接创建 PipelineRun。输入、YAML、env identity 或构建/rollout 范围变化后必须重新 plan。PR merge 后不自动 triggerL0/L1 操作也不得调用该 mutation。
手动 webhook payload 必须携带已审阅的精确 consumer intent;每个 PaC
PipelineRun 的 CEL selector 必须同时匹配 source branch、target 和 consumer。
同一 Repository 内其他 consumer 必须返回不匹配,单次 trigger 只能创建
一个与 reviewed plan 对应的 PipelineRun,禁止仅按 node 或 branch 扇出。
PaC consumer 的 runner `ServiceAccount`/`RoleBinding`、Argo `Application`
和非默认 `AppProject` 必须从 owning YAML 渲染,并由目标节点的
Gitea-backed GitOps publisher 持有长期 desired state。一次性 bootstrap、
live apply、人工 sync/refresh 和 legacy mirror 不得成为资源所有者。同一
target 内同名资源必须先按 namespace、kind 和 name 去重;只有完整声明
一致时才可复用,任一字段冲突必须在发布写入前 fail-closed。渲染结果不得
包含 Secret 值或从运行面反解凭据。
### 6.8 OPS-TARGET-REQ-008 k3s server 重启门禁非阻塞
| 编号 | 短名 | 主责模块 | 关联模块 |
@@ -124,7 +124,7 @@ export function renderPlatformInfraGiteaApplication(deliveryValue) {
}
export function verifyMaterializationReviewedPlan({ envelope, sourceCommit, baseCommit, target, consumer }) {
const prefix = "unidesk-release-plan-v1.";
const prefix = `unidesk-release-plan-v1.${consumer}.`;
if (typeof envelope !== "string" || !envelope.startsWith(prefix)) throw new Error("reviewed plan envelope is missing or invalid");
let reviewed;
try {
@@ -310,6 +310,8 @@ async function main() {
const desiredManifest = [
module.renderGiteaWebhookSyncDesiredManifest(delivery.targetId).trim(),
pacModule.renderPacRepositoryDesiredFragment(delivery.targetId).trim(),
pacModule.renderPacRunnerDesiredFragment(delivery.targetId).trim(),
pacModule.renderPacArgoDesiredFragment(delivery.targetId).trim(),
].filter((item) => item.length > 0).join("\n---\n") + "\n";
const result = publishPlatformInfraGiteaGitOps({ delivery, desiredManifest, sourceRoot, sourceCommit, worktree });
process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -30,7 +30,7 @@ function sameStrings(left, right) {
}
export function verifyReviewedReleasePlan({ envelope, actualPlan, sourceCommit, baseCommit, target, consumer }) {
const prefix = "unidesk-release-plan-v1.";
const prefix = `unidesk-release-plan-v1.${consumer}.`;
if (typeof envelope !== "string" || !envelope.startsWith(prefix)) throw new Error("reviewed plan envelope is missing or invalid");
let reviewed;
try {
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { readGiteaWebhookGitOpsDelivery, renderGiteaWebhookSyncDesiredManifest } from "./platform-infra-gitea";
import { readPacConfig, renderPacArgoDesiredFragment, renderPacRunnerDesiredFragment, validPacConsumers } from "./platform-infra-pipelines-as-code/config";
import { renderPlatformInfraGiteaApplication, verifyMaterializationReviewedPlan } from "../native/cicd/publish-platform-infra-gitea-gitops.mjs";
const root = resolve(import.meta.dir, "../..");
@@ -60,6 +61,68 @@ test("existing NC01 Repository can bootstrap the independent desired publisher w
username: "unidesk-admin",
},
});
const publisher = readFileSync(resolve(root, "scripts/native/cicd/publish-platform-infra-gitea-gitops.mjs"), "utf8");
expect(publisher).toContain("renderPacRepositoryDesiredFragment(delivery.targetId)");
expect(publisher).toContain("renderPacRunnerDesiredFragment(delivery.targetId)");
expect(publisher).toContain("renderPacArgoDesiredFragment(delivery.targetId)");
});
test("NC01 custom PaC runners have one durable ServiceAccount and RoleBinding", () => {
const pac = readPacConfig();
const consumers = validPacConsumers(pac)
.filter((consumer) => consumer.node === "NC01" && consumer.runnerServiceAccount !== null);
const manifest = renderPacRunnerDesiredFragment("NC01", pac);
const documents = manifest.split(/^---\s*$/mu).map((item) => item.trim()).filter(Boolean)
.map((item) => Bun.YAML.parse(item) as any);
const expectedNames = new Set(consumers.map((consumer) => `${consumer.namespace}/${consumer.runnerServiceAccount?.name}`));
expect(documents.filter((item) => item.kind === "ServiceAccount")).toHaveLength(expectedNames.size);
expect(documents.filter((item) => item.kind === "RoleBinding")).toHaveLength(expectedNames.size);
expect(documents.find((item) => item.kind === "ServiceAccount" && item.metadata.name === "unidesk-host-tekton-runner")).toMatchObject({
metadata: { namespace: "devops-infra" },
automountServiceAccountToken: false,
});
expect(documents.find((item) => item.kind === "RoleBinding" && item.metadata.name === "unidesk-host-tekton-runner")).toMatchObject({
metadata: { namespace: "devops-infra" },
roleRef: { kind: "Role", name: "unidesk-pac-consumer-runner" },
});
expect(manifest).not.toContain("secret");
});
test("NC01 Argo desired state is complete, Gitea-backed, deduplicated and conflict closed", () => {
const pac = readPacConfig();
const consumers = validPacConsumers(pac)
.filter((consumer) => consumer.node === "NC01" && consumer.argoBootstrap !== null);
const manifest = renderPacArgoDesiredFragment("NC01", pac);
const documents = manifest.split(/^---\s*$/mu).map((item) => item.trim()).filter(Boolean)
.map((item) => Bun.YAML.parse(item) as any);
const applications = documents.filter((item) => item.kind === "Application");
const projects = documents.filter((item) => item.kind === "AppProject");
expect(applications).toHaveLength(new Set(consumers.map((consumer) => `${consumer.argoNamespace}/${consumer.argoApplication}`)).size);
expect(projects).toHaveLength(new Set(consumers
.filter((consumer) => consumer.argoBootstrap?.project !== "default")
.map((consumer) => `${consumer.argoNamespace}/${consumer.argoBootstrap?.project}`)).size);
expect(applications.find((item) => item.metadata.name === "unidesk-host")?.spec.source).toEqual({
repoURL: "http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git",
targetRevision: "unidesk-host-gitops",
path: "deploy/gitops/unidesk-host",
});
expect(manifest).not.toContain("git-mirror-http");
expect(manifest).not.toContain("repositoryCredential");
expect(manifest).not.toContain("secretName");
const duplicated = structuredClone(pac);
const source = duplicated.consumers.find((consumer) => consumer.id === "agentrun-nc01-v02")!;
duplicated.consumers.push({ ...source, id: "agentrun-nc01-v02-duplicate" });
expect(renderPacArgoDesiredFragment("NC01", duplicated)).toBe(manifest);
const conflicting = structuredClone(pac);
const conflictSource = conflicting.consumers.find((consumer) => consumer.id === "agentrun-nc01-v02")!;
conflicting.consumers.push({
...conflictSource,
id: "agentrun-nc01-v02-conflict",
argoBootstrap: { ...conflictSource.argoBootstrap!, path: "deploy/gitops/conflicting-path" },
});
expect(() => renderPacArgoDesiredFragment("NC01", conflicting)).toThrow("conflicting PaC Argo desired resource");
});
test("materialization release verifies the reviewed plan before publishing", () => {
@@ -76,7 +139,7 @@ test("materialization release verifies the reviewed plan before publishing", ()
rolloutServices: ["platform-infra-gitea-nc01"],
affectedServices: ["platform-infra-gitea-nc01"],
};
const envelope = `unidesk-release-plan-v1.${Buffer.from(JSON.stringify(reviewed)).toString("base64url")}`;
const envelope = `unidesk-release-plan-v1.${reviewed.consumer}.${Buffer.from(JSON.stringify(reviewed)).toString("base64url")}`;
expect(verifyMaterializationReviewedPlan({ envelope, sourceCommit, baseCommit, target: "NC01", consumer: "platform-infra-gitea-nc01" })).toMatchObject({
event: "pac-delivery-plan",
sourceCommitId: sourceCommit,
@@ -85,7 +148,7 @@ test("materialization release verifies the reviewed plan before publishing", ()
affectedServices: ["platform-infra-gitea-nc01"],
});
expect(() => verifyMaterializationReviewedPlan({
envelope: `unidesk-release-plan-v1.${Buffer.from(JSON.stringify({ ...reviewed, buildServices: ["unexpected"] })).toString("base64url")}`,
envelope: `unidesk-release-plan-v1.${reviewed.consumer}.${Buffer.from(JSON.stringify({ ...reviewed, buildServices: ["unexpected"] })).toString("base64url")}`,
sourceCommit,
baseCommit,
target: "NC01",
@@ -11,6 +11,8 @@ import { runHwlabNodeControlPlaneInfra } from "./hwlab-node-control-plane";
import { detachedJobLaunchScript } from "./hwlab-node-control-plane-executor";
import { materializeYamlComposition } from "./yaml-composition";
const root = resolve(import.meta.dir, "../..");
const mirror: GiteaMirrorRepository = {
key: "widgets-nc01",
targetId: "NC01",
@@ -429,6 +431,7 @@ test("L2/L3 release exposes plan first and trigger only sends the PaC webhook",
expect(triggerAction).toContain("release-plan-empty-scope");
expect(triggerAction).toContain("release-plan-identity-changed");
expect(triggerAction).toContain("unidesk-release-plan-v1.");
expect(triggerAction).toContain("reviewedPlan.consumer");
expect(triggerAction).not.toContain("kubectl create");
expect(triggerAction).not.toContain("PipelineRun");
const source = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code.ts"), "utf8");
@@ -442,6 +445,25 @@ test("L2/L3 release exposes plan first and trigger only sends the PaC webhook",
const sourceArtifactRenderer = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-source-artifact.ts"), "utf8");
expect(sourceArtifactRenderer).toContain('if (name === "release-base-commit") return { name, value: "{{ body.before }}" }');
expect(sourceArtifactRenderer).toContain('if (name === "reviewed-plan") return { name, value: "{{ body.head_commit.message }}" }');
expect(sourceArtifactRenderer).toContain("body.head_commit.message.startsWith('unidesk-release-plan-v1.${binding.consumer.id}.')");
const staticConsumers = new Map([
[".tekton/platform-infra-gitea-nc01-pac.yaml", "platform-infra-gitea-nc01"],
[".tekton/platform-infra-temporal-nc01-pac.yaml", "platform-infra-temporal-nc01"],
[".tekton/unidesk-host-pac.yaml", "unidesk-host"],
[".tekton/web-probe-sentinel-nc01-pac.yaml", "sentinel-nc01-v03"],
]);
for (const [relativePath, consumerId] of staticConsumers) {
const manifest = Bun.YAML.parse(readFileSync(resolve(root, relativePath), "utf8")) as any;
expect(manifest.metadata.annotations["pipelinesascode.tekton.dev/on-cel-expression"])
.toContain(`body.head_commit.message.startsWith('unidesk-release-plan-v1.${consumerId}.')`);
}
for (const relativePath of [".tekton/platform-infra-temporal-nc01-pac.yaml", ".tekton/web-probe-sentinel-nc01-pac.yaml"]) {
const source = readFileSync(resolve(root, relativePath), "utf8");
expect(source).toContain("verify-reviewed-release-plan.mjs");
expect(source.indexOf("verify-reviewed-release-plan.mjs")).toBeLessThan(source.indexOf("publish-platform-infra-temporal-gitops.ts") === -1
? source.indexOf("render-sentinel-publish-task.ts")
: source.indexOf("publish-platform-infra-temporal-gitops.ts"));
}
});
test("PaC release scoped help stays on the manual plan-before-trigger path", async () => {
@@ -737,7 +737,7 @@ const reviewedPlan = {
rolloutServices: plan.rolloutServices,
affectedServices: plan.affectedServices,
};
const reviewedPlanEnvelope = `unidesk-release-plan-v1.${Buffer.from(JSON.stringify(reviewedPlan)).toString('base64url')}`;
const reviewedPlanEnvelope = `unidesk-release-plan-v1.${reviewedPlan.consumer}.${Buffer.from(JSON.stringify(reviewedPlan)).toString('base64url')}`;
const commit = { id: plan.sourceCommit, message: reviewedPlanEnvelope, url: `${url}/commit/${plan.sourceCommit}`, added: byKind.added, removed: byKind.removed, modified: byKind.modified, author: { name: 'unidesk-cli', email: '', username: 'unidesk-cli' }, committer: { name: 'unidesk-cli', email: '', username: 'unidesk-cli' } };
const user = { id: 0, login: 'unidesk-cli', full_name: 'UniDesk CLI', email: '', avatar_url: '', username: 'unidesk-cli' };
process.stdout.write(JSON.stringify({ ref: `refs/heads/${plan.sourceBranch}`, before: plan.baseCommit, after: plan.sourceCommit, compare_url: `${url}/compare/${plan.baseCommit}...${plan.sourceCommit}`, commits: [commit], total_commits: 1, head_commit: commit, repository: { id: 0, owner: { ...user, login: owner, username: owner }, name: repo, full_name: `${owner}/${repo}`, private: true, html_url: url, clone_url: `${url}.git`, default_branch: plan.sourceBranch }, pusher: user, sender: user }));
@@ -960,7 +960,7 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P
return {
"pipelinesascode.tekton.dev/on-event": "[push]",
"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/on-cel-expression": `event == 'push' && target_branch == '${branch}' && node == '${binding.consumer.node}' && body.head_commit.message.startsWith('unidesk-release-plan-v1.${binding.consumer.id}.')`,
"pipelinesascode.tekton.dev/max-keep-runs": String(binding.consumer.sourceArtifact.maxKeepRuns),
...(binding.consumer.deliveryProvenance == null ? {} : {
[binding.consumer.deliveryProvenance.markerAnnotation]: binding.consumer.deliveryProvenance.markerValue,
@@ -70,6 +70,7 @@ import {
record,
remoteScriptFile,
rendered,
renderPacRunnerServiceAccountManifest,
resolveConsumer,
resolveRepository,
resolveTarget,
@@ -1728,29 +1729,7 @@ function pacPartOf(consumerId: string): string {
}
function runnerServiceAccountManifest(consumer: PacConsumer): string {
const runner = consumer.runnerServiceAccount;
if (runner === null) return "";
const labels = {
"app.kubernetes.io/managed-by": "unidesk",
"app.kubernetes.io/part-of": pacPartOf(consumer.id),
"unidesk.ai/pac-runner": consumer.id,
};
const objects = [
{
apiVersion: "v1",
kind: "ServiceAccount",
metadata: { name: runner.name, namespace: consumer.namespace, labels },
automountServiceAccountToken: runner.automountServiceAccountToken,
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "RoleBinding",
metadata: { name: runner.roleBindingName, namespace: consumer.namespace, labels },
subjects: [{ kind: "ServiceAccount", name: runner.name, namespace: consumer.namespace }],
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "unidesk-pac-consumer-runner" },
},
];
return `${objects.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
return renderPacRunnerServiceAccountManifest(consumer);
}
function argoBootstrapManifest(consumer: PacConsumer): string {
@@ -1307,6 +1307,135 @@ export function renderPacRepositoryDesiredFragment(targetId: string): string {
}).trim()).join("\n---\n") + (repositories.length > 0 ? "\n" : "");
}
function pacRunnerDesiredResources(consumer: PacConsumer): Array<Record<string, unknown>> {
const runner = consumer.runnerServiceAccount;
if (runner === null) return [];
const labels = {
"app.kubernetes.io/managed-by": "unidesk",
"app.kubernetes.io/part-of": "pipelines-as-code",
"unidesk.ai/pac-runner": "yaml-owned",
};
return [
{
apiVersion: "v1",
kind: "ServiceAccount",
metadata: { name: runner.name, namespace: consumer.namespace, labels },
automountServiceAccountToken: runner.automountServiceAccountToken,
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "RoleBinding",
metadata: { name: runner.roleBindingName, namespace: consumer.namespace, labels },
subjects: [{ kind: "ServiceAccount", name: runner.name, namespace: consumer.namespace }],
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "unidesk-pac-consumer-runner" },
},
];
}
export function renderPacRunnerServiceAccountManifest(consumer: PacConsumer): string {
const desired = pacRunnerDesiredResources(consumer).map((item) => Bun.YAML.stringify(item).trim());
return desired.join("\n---\n") + (desired.length > 0 ? "\n" : "");
}
export function renderPacRunnerDesiredFragment(targetId: string, pac: PacConfig = readPacConfig()): string {
const resources = new Map<string, { owner: string; resource: Record<string, unknown>; canonical: string }>();
const targetConsumers = validPacConsumers(pac)
.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase() && consumer.runnerServiceAccount !== null);
for (const consumer of targetConsumers) {
for (const resource of pacRunnerDesiredResources(consumer)) {
const key = pacDesiredResourceKey(resource);
const canonical = JSON.stringify(resource);
const existing = resources.get(key);
if (existing !== undefined && existing.canonical !== canonical) {
throw new Error(`conflicting PaC runner desired resource ${key} from consumers ${existing.owner} and ${consumer.id}`);
}
if (existing === undefined) resources.set(key, { owner: consumer.id, resource, canonical });
}
}
const desired = [...resources.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([, value]) => Bun.YAML.stringify(value.resource).trim());
return desired.join("\n---\n") + (desired.length > 0 ? "\n" : "");
}
function pacArgoDesiredResources(consumer: PacConsumer): Array<Record<string, unknown>> {
const bootstrap = consumer.argoBootstrap;
if (bootstrap === null) return [];
const labels = {
"app.kubernetes.io/managed-by": "unidesk",
"app.kubernetes.io/part-of": "pipelines-as-code",
};
const project = bootstrap.project === "default" ? null : {
apiVersion: "argoproj.io/v1alpha1",
kind: "AppProject",
metadata: {
name: bootstrap.project,
namespace: consumer.argoNamespace,
labels,
},
spec: {
sourceRepos: [bootstrap.repoUrl],
destinations: [{
server: "https://kubernetes.default.svc",
namespace: bootstrap.destinationNamespace,
}],
clusterResourceWhitelist: [{ group: "*", kind: "*" }],
namespaceResourceWhitelist: [{ group: "*", kind: "*" }],
},
};
const application = {
apiVersion: "argoproj.io/v1alpha1",
kind: "Application",
metadata: {
name: consumer.argoApplication,
namespace: consumer.argoNamespace,
labels,
},
spec: {
project: bootstrap.project,
source: {
repoURL: bootstrap.repoUrl,
targetRevision: bootstrap.targetRevision,
path: bootstrap.path,
},
destination: {
server: "https://kubernetes.default.svc",
namespace: bootstrap.destinationNamespace,
},
syncPolicy: bootstrap.automated ? {
automated: { prune: true, selfHeal: true },
syncOptions: ["CreateNamespace=true"],
} : undefined,
},
};
return [project, application].filter((item): item is NonNullable<typeof item> => item !== null);
}
function pacDesiredResourceKey(resource: Record<string, any>): string {
return `${resource.kind}/${resource.metadata.namespace}/${resource.metadata.name}`;
}
export function renderPacArgoDesiredFragment(targetId: string, pac: PacConfig = readPacConfig()): string {
const resources = new Map<string, { owner: string; resource: Record<string, unknown>; canonical: string }>();
const targetConsumers = validPacConsumers(pac)
.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase() && consumer.argoBootstrap !== null);
for (const consumer of targetConsumers) {
for (const resource of pacArgoDesiredResources(consumer)) {
const key = pacDesiredResourceKey(resource);
const canonical = JSON.stringify(resource);
const existing = resources.get(key);
if (existing !== undefined && existing.canonical !== canonical) {
throw new Error(`conflicting PaC Argo desired resource ${key} from consumers ${existing.owner} and ${consumer.id}`);
}
if (existing === undefined) resources.set(key, { owner: consumer.id, resource, canonical });
}
}
const desired = [...resources.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([, value]) => Bun.YAML.stringify(value.resource).trim());
return desired.join("\n---\n") + (desired.length > 0 ? "\n" : "");
}
export function positiveInteger(obj: Record<string, unknown>, key: string, path: string): number {
const value = y.integerField(obj, key, path);
if (value < 1) throw new Error(`${configLabel}.${path}.${key} must be positive`);
@@ -15,7 +15,7 @@ function envelope(scope: { buildServices: string[]; rolloutServices: string[]; a
baseCommit,
...scope,
};
return `unidesk-release-plan-v1.${Buffer.from(JSON.stringify(reviewed)).toString("base64url")}`;
return `unidesk-release-plan-v1.${reviewed.consumer}.${Buffer.from(JSON.stringify(reviewed)).toString("base64url")}`;
}
test("reviewed release plan accepts only exact execution scope", () => {
@@ -49,4 +49,12 @@ test("reviewed release plan accepts only exact execution scope", () => {
target: "NC01",
consumer: "unidesk-host",
})).toThrow("reviewed plan does not match execution scope");
expect(() => verifyReviewedReleasePlan({
envelope: envelope(scope).replace("unidesk-host", "other-consumer"),
actualPlan: scope,
sourceCommit,
baseCommit,
target: "NC01",
consumer: "unidesk-host",
})).toThrow("reviewed plan envelope is missing or invalid");
});