Merge pull request #2875 from pikasTech/fix/nc01-argo-gitea-desired
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
修复 NC01 PaC consumer 扇出与声明式所有权
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
+13
@@ -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 重启门禁非阻塞
|
||||
|
||||
| 编号 | 短名 | 主责模块 | 关联模块 |
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user