diff --git a/.tekton/platform-infra-gitea-nc01-pac.yaml b/.tekton/platform-infra-gitea-nc01-pac.yaml index 34be73c4..d54603d8 100644 --- a/.tekton/platform-infra-gitea-nc01-pac.yaml +++ b/.tekton/platform-infra-gitea-nc01-pac.yaml @@ -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 diff --git a/.tekton/platform-infra-temporal-nc01-pac.yaml b/.tekton/platform-infra-temporal-nc01-pac.yaml index 06d05e0f..4476687d 100644 --- a/.tekton/platform-infra-temporal-nc01-pac.yaml +++ b/.tekton/platform-infra-temporal-nc01-pac.yaml @@ -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" \ diff --git a/.tekton/unidesk-host-pac.yaml b/.tekton/unidesk-host-pac.yaml index d96e2dfe..72d4454d 100644 --- a/.tekton/unidesk-host-pac.yaml +++ b/.tekton/unidesk-host-pac.yaml @@ -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 diff --git a/.tekton/web-probe-sentinel-jd01-pac.yaml b/.tekton/web-probe-sentinel-jd01-pac.yaml index 6222e326..663d8d3c 100644 --- a/.tekton/web-probe-sentinel-jd01-pac.yaml +++ b/.tekton/web-probe-sentinel-jd01-pac.yaml @@ -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 diff --git a/.tekton/web-probe-sentinel-nc01-pac.yaml b/.tekton/web-probe-sentinel-nc01-pac.yaml index 6beec467..45bdf3a0 100644 --- a/.tekton/web-probe-sentinel-nc01-pac.yaml +++ b/.tekton/web-probe-sentinel-nc01-pac.yaml @@ -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 \ diff --git a/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md b/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md index 04ec7cf8..fc21ccd2 100644 --- a/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md +++ b/project-management/PJ2026-01/specs/PJ2026-01060308-cicd-yaml-first-target-governance.md @@ -277,6 +277,19 @@ Pipeline 必须在镜像构建或 GitOps 写入前校验 reviewed plan。实际 受控手动 `trigger` 必须同时接收 target、consumer 或 lane、精确 source commit 和 pipeline intent,并向 PaC 发送 webhook;CLI 不得直接创建 PipelineRun。输入、YAML、env identity 或构建/rollout 范围变化后必须重新 plan。PR merge 后不自动 trigger,L0/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 重启门禁非阻塞 | 编号 | 短名 | 主责模块 | 关联模块 | diff --git a/scripts/native/cicd/publish-platform-infra-gitea-gitops.mjs b/scripts/native/cicd/publish-platform-infra-gitea-gitops.mjs index 7a05e005..818af700 100644 --- a/scripts/native/cicd/publish-platform-infra-gitea-gitops.mjs +++ b/scripts/native/cicd/publish-platform-infra-gitea-gitops.mjs @@ -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`); diff --git a/scripts/native/cicd/verify-reviewed-release-plan.mjs b/scripts/native/cicd/verify-reviewed-release-plan.mjs index 11b87cbd..3e843af5 100644 --- a/scripts/native/cicd/verify-reviewed-release-plan.mjs +++ b/scripts/native/cicd/verify-reviewed-release-plan.mjs @@ -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 { diff --git a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts index bace6b9d..a994237d 100644 --- a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts +++ b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts @@ -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", diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index a136f0b0..13d42605 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -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 () => { diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 317fab74..7f8a2dd0 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -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 })); 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 bb498fc3..85f8bc6b 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -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, diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index c949d526..810aef4e 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -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 { diff --git a/scripts/src/platform-infra-pipelines-as-code/config.ts b/scripts/src/platform-infra-pipelines-as-code/config.ts index 2339ede9..23c2361c 100644 --- a/scripts/src/platform-infra-pipelines-as-code/config.ts +++ b/scripts/src/platform-infra-pipelines-as-code/config.ts @@ -1307,6 +1307,135 @@ export function renderPacRepositoryDesiredFragment(targetId: string): string { }).trim()).join("\n---\n") + (repositories.length > 0 ? "\n" : ""); } +function pacRunnerDesiredResources(consumer: PacConsumer): Array> { + 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; 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> { + 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 => item !== null); +} + +function pacDesiredResourceKey(resource: Record): string { + return `${resource.kind}/${resource.metadata.namespace}/${resource.metadata.name}`; +} + +export function renderPacArgoDesiredFragment(targetId: string, pac: PacConfig = readPacConfig()): string { + const resources = new Map; 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, key: string, path: string): number { const value = y.integerField(obj, key, path); if (value < 1) throw new Error(`${configLabel}.${path}.${key} must be positive`); diff --git a/scripts/src/platform-infra-reviewed-release-plan.test.ts b/scripts/src/platform-infra-reviewed-release-plan.test.ts index 69702bdc..0154c94f 100644 --- a/scripts/src/platform-infra-reviewed-release-plan.test.ts +++ b/scripts/src/platform-infra-reviewed-release-plan.test.ts @@ -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"); });