diff --git a/.tekton/platform-infra-gitea-nc01-pac.yaml b/.tekton/platform-infra-gitea-nc01-pac.yaml index aac6f5e0..54c53ef1 100644 --- a/.tekton/platform-infra-gitea-nc01-pac.yaml +++ b/.tekton/platform-infra-gitea-nc01-pac.yaml @@ -54,3 +54,26 @@ spec: volumeMounts: - name: workspace mountPath: /workspace + - name: apply-public-edge + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + imagePullPolicy: IfNotPresent + workingDir: /workspace/source + env: + - name: SOURCE_COMMIT + value: "{{revision}}" + - name: UNIDESK_SSH_CLIENT_TOKEN + valueFrom: + secretKeyRef: + name: public-edge-trans + key: UNIDESK_SSH_CLIENT_TOKEN + script: | + #!/bin/sh + set -eu + test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" + exec bun scripts/native/cicd/deliver-platform-infra-public-edge.mjs \ + --source-root /workspace/source \ + --source-commit "$SOURCE_COMMIT" \ + --confirm + volumeMounts: + - name: workspace + mountPath: /workspace diff --git a/config/platform-infra/public-edge.yaml b/config/platform-infra/public-edge.yaml index 7d055474..d270973f 100644 --- a/config/platform-infra/public-edge.yaml +++ b/config/platform-infra/public-edge.yaml @@ -6,10 +6,31 @@ metadata: owner: platform-infra relatedIssues: - 2181 + - 2205 defaults: targetId: NC01 +delivery: + enabled: true + pipelineId: platform-infra-gitea-nc01 + sourceCommitEnvironment: SOURCE_COMMIT + transport: + hostCwd: /root + secretRef: + configRef: config/secrets-distribution.yaml + targetId: public-edge-delivery-nc01 + secretName: public-edge-trans + targetKey: UNIDESK_SSH_CLIENT_TOKEN + hostHelper: + archiveRoot: /root/unidesk/.state/public-edge-delivery + entrypoint: scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs + sourcePaths: + - config + - scripts + - package.json + - bun.lock + targets: NC01: route: NC01 diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index 37a9a8b6..b412f402 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -10,6 +10,7 @@ metadata: - 313 - 2256 - 1964 + - 2205 sources: root: /root/.unidesk/.state/secrets @@ -142,6 +143,12 @@ sources: - DATABASE_URL createIfMissing: enabled: false + - sourceRef: unidesk-host-k8s.env + type: env + requiredKeys: + - UNIDESK_SSH_CLIENT_TOKEN + createIfMissing: + enabled: false externalFiles: - sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt type: raw-file @@ -250,8 +257,21 @@ targets: namespace: pikaoa-test scope: pikaoa-test enabled: true + - id: public-edge-delivery-nc01 + route: NC01:k3s + namespace: devops-infra + scope: public-edge-delivery + enabled: true kubernetesSecrets: + - name: public-edge-trans + targetId: public-edge-delivery-nc01 + secretName: public-edge-trans + type: Opaque + data: + - sourceRef: unidesk-host-k8s.env + sourceKey: UNIDESK_SSH_CLIENT_TOKEN + targetKey: UNIDESK_SSH_CLIENT_TOKEN - name: pikaoa-runtime targetId: pikaoa-nc01 secretName: pikaoa-runtime diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.5.1.1_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.5.1.1_Task_Report.md new file mode 100644 index 00000000..7e96a3c8 --- /dev/null +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.5.1.1_Task_Report.md @@ -0,0 +1,116 @@ +# R9.5.1.1 任务报告 + +## 任务定位 + +- MDTODO:`R9.5.1.1`。 +- Issue:UniDesk `#2205`。 +- Target:`NC01`。 +- targetWorkspace: + `/root/unidesk/.worktree/2205-public-edge-single-authority`。 +- 分支:`fix/2205-public-edge-single-authority`。 +- 最新基线:`origin/master` 的 `76511eabfb291c95a03d3aa150fa51ab404fa6f9`。 +- 交付边界:只创建 PR,不合并,不触发人工 PipelineRun、Argo sync、 + Secret sync 或运行面终态 apply。 + +## 实现结果 + +- `config/platform-infra/public-edge.yaml` 继续作为 shared public-edge 唯一真相: + - 新增现有 `platform-infra-gitea-nc01` PaC 的自动触发归属; + - 声明 source commit 环境变量、受控 trans SecretRef、NC01 host cwd; + - 声明 commit archive 根目录、archive 内 helper 与 source path 集合; + - 未复制 hostname、upstream、Caddyfile 或 Compose 渲染逻辑。 +- `config/secrets-distribution.yaml` 新增 `public-edge-delivery-nc01` scope: + - sourceRef 为 `unidesk-host-k8s.env`; + - sourceKey 与 targetKey 均为 `UNIDESK_SSH_CLIENT_TOKEN`; + - 目标是 `devops-infra/public-edge-trans`; + - 仅披露 presence、key、fingerprint 与摘要,不披露 Secret 值。 +- `.tekton/platform-infra-gitea-nc01-pac.yaml` 在现有 immutable checkout 后新增步骤: + - 再次校验 checkout `HEAD` 等于 `SOURCE_COMMIT`; + - 通过 Kubernetes SecretRef 注入受控 trans token; + - 调用 checkout 内版本化 archive delivery helper; + - 未增加 PipelineRun、Argo Hook、Job、hostPath、Docker socket 或特权 Pod。 +- `scripts/native/cicd/deliver-platform-infra-public-edge.mjs`: + - 读取 owning YAML 与 secrets-distribution 引用图; + - 校验 source checkout、source commit、sourceRef、sourceKey 与 targetKey; + - 只从该 commit 生成 `config`、`scripts`、`package.json`、`bun.lock` archive; + - 计算 SHA-256,并由 checkout 内 `scripts/trans` 上传和执行; + - 仅在 host-local apply 返回 `mutation=true` 后报告成功。 +- `scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs`: + - 校验 archive source commit marker; + - 从 archive checkout 调用既有 + `platform-infra public-edge apply --target NC01 --confirm`; + - 不包含 Caddyfile 生成、安装、reload、探测或 rollback 的第二实现。 + +## 单一控制链证明 + +终态链固定为: + +1. GitHub `master` merge; +2. 现有 `platform-infra-gitea-nc01` PaC immutable checkout; +3. `config/platform-infra/public-edge.yaml` 的 delivery 与 SecretRef 声明; +4. `config/secrets-distribution.yaml` 的 sourceRef/targetKey 投影; +5. checkout 内版本化 `scripts/trans`; +6. NC01 host-local commit archive helper; +7. 同 archive 的既有 `platform-infra public-edge apply`; +8. 既有候选校验、原子安装、站点探测与失败 rollback。 + +不存在以下第二路径: + +- Argo `PostSync`、`Sync` Hook 或 Kubernetes Job; +- hostPath、Caddy Admin API 直写或直接修改 Caddyfile; +- 第二 renderer、第二 apply、第二 Caddy、fallback listener; +- Docker socket、Docker daemon API、`docker exec` 或特权 Pod; +- PK01 变更或人工终态交付。 + +## Target 原入口证据 + +以下证据全部在指定 Target workspace 通过 `trans` 执行: + +- `node --check`: + - `deliver-platform-infra-public-edge.mjs` 通过; + - `apply-platform-infra-public-edge-archive.mjs` 通过。 +- `bun test scripts/native/cicd/deliver-platform-infra-public-edge.test.ts`: + - `4 pass`; + - 覆盖 YAML delivery plan、PaC 禁止项、SecretRef 绑定; + - fake-trans confirm 实际生成 commit archive 并执行三段受控调用; + - 未连接 Target 运行面。 +- `platform-infra public-edge plan --target NC01`: + - `mutation=false`; + - 七站全部解析; + - `unresolved=[]`; + - UDP `443` 保持; + - Caddyfile fingerprint 为 + `sha256:a998c3d5c3a409883a7dc62e121f7e8210998af686c0b9c34aa456035494ff67`。 +- `platform-infra public-edge apply --target NC01 --dry-run`: + - `mutation=false`; + - 七站与 fingerprint 和 plan 一致。 +- Secret 分发 plan: + - 命令为 + `secrets plan --config config/secrets-distribution.yaml --scope public-edge-delivery`; + - `mutation=false`; + - sourceRef 存在且 owner-only mode 为 `0600`; + - `UNIDESK_SSH_CLIENT_TOKEN` 无缺失; + - desired Secret 为 `devops-infra/public-edge-trans`; + - `valuesPrinted=false`。 +- `git diff --check`:通过。 +- 未运行 Vitest 或重型构建。 + +## Primary Workspace 边界 + +- primary workspace 仅用于加载 resource bundle skills、调用 `trans` wrapper、 + 使用 `unidesk-gh` 读取和评论 Issue。 +- primary workspace 未修改目标源码,未执行目标测试,未冒充 Target 原入口证据。 + +## 未执行事项 + +- 未执行 `secrets sync`。 +- 未触发或创建 PipelineRun。 +- 未执行 Argo refresh、sync 或 runtime patch。 +- 未执行 `platform-infra public-edge apply --confirm`。 +- 未修改 PK01 任意配置、Secret、路由或运行面。 +- PR 合并与 merge 后自动链观察由主代理审核后完成。 + +## 回链 + +- MDTODO:`R9.5.1.1`。 +- Issue:`#2205`。 diff --git a/docs/MDTODO/pr-merge-driven-automatic-delivery.md b/docs/MDTODO/pr-merge-driven-automatic-delivery.md index 10ac9f8f..1d5b6aa6 100644 --- a/docs/MDTODO/pr-merge-driven-automatic-delivery.md +++ b/docs/MDTODO/pr-merge-driven-automatic-delivery.md @@ -150,7 +150,7 @@ #### R9.5.1 [in_progress] 重建 [UniDesk #2205](https://github.com/pikasTech/unidesk/issues/2205):从最新 master 以全新 worktree/session 实现 NC01 shared public-edge 的 merge 驱动自动交付,复用同一 renderer/apply 与 owning YAML/source commit;禁止复用旧 docker.sock 工件,禁止 Kubernetes 挂载 Docker socket/daemon、第二 edge/authority、人工交付、PK01和Vitest,合并后只由正常自动链收敛,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R9.5.1_Task_Report.md)。 -##### R9.5.1.1 [in_progress] +##### R9.5.1.1 [completed] 重做 [UniDesk #2205](https://github.com/pikasTech/unidesk/issues/2205) 的单一 shared public-edge 自动交付链:仅允许 immutable PaC checkout → YAML SecretRef → 受控 trans → NC01 host-local 既有 public-edge renderer/apply;禁止 Argo PostSync、hostPath、Caddy Admin API 直写、Docker socket/daemon、第二 authority/fallback、PK01、Vitest和人工终态交付;必须使用最新 master 的全新 worktree 与全新 Artificer session,合并后只由自动 CI/CD 收敛,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R9.5.1.1_Task_Report.md)。 ### R9.6 [completed] diff --git a/scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs b/scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs new file mode 100644 index 00000000..e418130d --- /dev/null +++ b/scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env bun + +import { spawnSync } from "node:child_process"; +import { readFileSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +const options = parseOptions(process.argv.slice(2)); +const sourceRoot = realpathSync(options.sourceRoot); +const markerPath = join(dirname(sourceRoot), ".unidesk-source-commit"); +const markerCommit = readFileSync(markerPath, "utf8").trim(); +if (markerCommit !== options.sourceCommit) fail("archive source commit marker 不匹配"); +const configPath = resolve(sourceRoot, options.config); +if (!configPath.startsWith(`${sourceRoot}/`)) fail("--config 必须位于 source archive 内"); + +const args = [ + "scripts/cli.ts", + "platform-infra", + "public-edge", + "apply", + "--config", + configPath, + "--target", + options.target, + "--confirm", +]; +const result = spawnSync("bun", args, { + cwd: sourceRoot, + env: { ...process.env, UNIDESK_TRANS_REPO_ROOT: sourceRoot }, + stdio: "inherit", + timeout: 120_000, +}); +if (result.status !== 0) process.exit(result.status ?? 1); + +function parseOptions(args) { + const values = new Map(); + let confirm = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--confirm") { + confirm = true; + continue; + } + if (!["--source-root", "--source-commit", "--config", "--target"].includes(arg)) fail(`不支持的参数:${arg}`); + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) fail(`${arg} 需要参数`); + values.set(arg.slice(2), value); + index += 1; + } + const sourceRoot = values.get("source-root"); + const sourceCommit = values.get("source-commit"); + const config = values.get("config"); + const target = values.get("target"); + if (!confirm) fail("必须显式传入 --confirm"); + if (typeof sourceRoot !== "string" || !isAbsolute(sourceRoot)) fail("--source-root 必须是绝对路径"); + if (typeof sourceCommit !== "string" || !/^[0-9a-f]{40}$/u.test(sourceCommit)) fail("--source-commit 必须是 40 位 commit"); + if (typeof config !== "string" || isAbsolute(config)) fail("--config 必须是 archive 内相对路径"); + if (typeof target !== "string" || target.length === 0) fail("缺少 --target"); + return { sourceRoot, sourceCommit, config, target }; +} + +function fail(message) { + process.stderr.write(`${JSON.stringify({ ok: false, action: "platform-infra-public-edge-archive-apply", error: message })}\n`); + process.exit(1); +} diff --git a/scripts/native/cicd/deliver-platform-infra-public-edge.mjs b/scripts/native/cicd/deliver-platform-infra-public-edge.mjs new file mode 100644 index 00000000..b98a1245 --- /dev/null +++ b/scripts/native/cicd/deliver-platform-infra-public-edge.mjs @@ -0,0 +1,271 @@ +#!/usr/bin/env bun + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; + +const options = parseOptions(process.argv.slice(2)); +const sourceRoot = resolve(options.sourceRoot); +const configPath = resolve(sourceRoot, options.config); +const config = Bun.YAML.parse(readFileSync(configPath, "utf8")); +const plan = readPlan(config, sourceRoot, options.sourceCommit, configPath); +verifySourceCommit(sourceRoot, options.sourceCommit, plan.sourceCommitEnvironment); + +if (options.dryRun) { + process.stdout.write(`${JSON.stringify({ + ok: true, + action: "platform-infra-public-edge-delivery-plan", + mutation: false, + sourceCommit: options.sourceCommit, + ...plan.summary, + valuesPrinted: false, + }, null, 2)}\n`); + process.exit(0); +} + +const token = process.env[plan.secret.targetKey]?.trim() ?? ""; +if (token.length === 0) fail(`缺少 SecretRef 投影环境变量:${plan.secret.targetKey}`); + +const temporaryRoot = mkdtempSync(join(tmpdir(), "unidesk-public-edge-delivery-")); +try { + const archivePath = createArchive(sourceRoot, options.sourceCommit, plan.sourcePaths, temporaryRoot); + const archiveSha256 = sha256File(archivePath); + const route = `${plan.route}:${plan.hostCwd}`; + const remoteArchive = `${plan.archiveRoot}/incoming/public-edge-${options.sourceCommit}.tar.gz`; + const transPath = join(sourceRoot, "scripts/trans"); + const transEnv = { + ...process.env, + UNIDESK_TRANS_REPO_ROOT: sourceRoot, + }; + + runTrans(transPath, [route, "sh", "--", `umask 077; mkdir -p ${shellQuote(`${plan.archiveRoot}/incoming`)} ${shellQuote(`${plan.archiveRoot}/tmp`)}`], transEnv); + const upload = runTrans(transPath, [route, "upload", archivePath, remoteArchive], transEnv); + const remote = runTrans(transPath, [route, "sh"], transEnv, remoteApplyScript({ + archivePath: remoteArchive, + archiveRoot: plan.archiveRoot, + archiveSha256, + config: options.config, + entrypoint: plan.entrypoint, + sourceCommit: options.sourceCommit, + targetId: plan.targetId, + })); + const runtime = compactResult(remote); + if (runtime.ok === false || runtime.mutation !== true) fail("host-local public-edge apply 未返回 mutation=true"); + + process.stdout.write(`${JSON.stringify({ + ok: true, + action: "platform-infra-public-edge-delivery", + mutation: true, + sourceCommit: options.sourceCommit, + archive: { + sha256: `sha256:${archiveSha256}`, + sourcePaths: plan.sourcePaths, + remotePath: remoteArchive, + }, + transport: compactResult(upload), + runtime, + ...plan.summary, + valuesPrinted: false, + }, null, 2)}\n`); +} finally { + rmSync(temporaryRoot, { recursive: true, force: true }); +} + +function parseOptions(args) { + let sourceRoot = null; + let sourceCommit = null; + let config = "config/platform-infra/public-edge.yaml"; + let dryRun = false; + let confirm = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--dry-run") dryRun = true; + else if (arg === "--confirm") confirm = true; + else if (arg === "--source-root" || arg === "--source-commit" || arg === "--config") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) fail(`${arg} 需要参数`); + if (arg === "--source-root") sourceRoot = value; + else if (arg === "--source-commit") sourceCommit = value; + else config = value; + index += 1; + } else fail(`不支持的参数:${arg}`); + } + if (sourceRoot === null) fail("缺少 --source-root"); + if (sourceCommit === null || !/^[0-9a-f]{40}$/u.test(sourceCommit)) fail("--source-commit 必须是 40 位小写十六进制 commit"); + if (dryRun === confirm) fail("必须且只能选择 --dry-run 或 --confirm"); + return { sourceRoot, sourceCommit, config, dryRun, confirm }; +} + +function readPlan(config, sourceRoot, sourceCommit, configPath) { + const delivery = record(config.delivery, "delivery"); + if (delivery.enabled !== true) fail("delivery.enabled 必须为 true"); + const defaults = record(config.defaults, "defaults"); + const targets = record(config.targets, "targets"); + const targetId = string(defaults.targetId, "defaults.targetId"); + const target = record(targets[targetId], `targets.${targetId}`); + const route = string(target.route, `targets.${targetId}.route`); + const transport = record(delivery.transport, "delivery.transport"); + const secret = record(transport.secretRef, "delivery.transport.secretRef"); + const hostHelper = record(delivery.hostHelper, "delivery.hostHelper"); + const sourcePaths = array(hostHelper.sourcePaths, "delivery.hostHelper.sourcePaths").map((value, index) => safeRelativePath(value, `delivery.hostHelper.sourcePaths[${index}]`)); + const entrypoint = safeRelativePath(hostHelper.entrypoint, "delivery.hostHelper.entrypoint"); + if (!sourcePaths.some((value) => entrypoint === value || entrypoint.startsWith(`${value}/`))) fail("hostHelper.entrypoint 必须包含在 sourcePaths 中"); + const configRelative = relativeWithin(sourceRoot, configPath); + if (!sourcePaths.some((value) => configRelative === value || configRelative.startsWith(`${value}/`))) fail("public-edge config 必须包含在 sourcePaths 中"); + const secretSummary = { + configRef: safeRelativePath(secret.configRef, "delivery.transport.secretRef.configRef"), + targetId: string(secret.targetId, "delivery.transport.secretRef.targetId"), + secretName: string(secret.secretName, "delivery.transport.secretRef.secretName"), + targetKey: string(secret.targetKey, "delivery.transport.secretRef.targetKey"), + }; + const secretBinding = readSecretBinding(sourceRoot, secretSummary); + const archiveRoot = absolutePath(hostHelper.archiveRoot, "delivery.hostHelper.archiveRoot"); + return { + sourceCommitEnvironment: string(delivery.sourceCommitEnvironment, "delivery.sourceCommitEnvironment"), + targetId, + route, + hostCwd: absolutePath(transport.hostCwd, "delivery.transport.hostCwd"), + archiveRoot, + entrypoint, + sourcePaths, + secret: secretSummary, + summary: { + pipelineId: string(delivery.pipelineId, "delivery.pipelineId"), + configRef: `${configRelative}#targets.${targetId}`, + target: { id: targetId, route }, + secretRef: { ...secretSummary, sourceRef: secretBinding.sourceRef, sourceKey: secretBinding.sourceKey }, + hostHelper: { archiveRoot, entrypoint, sourcePaths }, + sourceCommit, + }, + }; +} + +function readSecretBinding(sourceRoot, secret) { + const configPath = resolve(sourceRoot, secret.configRef); + const config = Bun.YAML.parse(readFileSync(configPath, "utf8")); + const targets = array(config.targets, `${secret.configRef}#targets`); + const target = targets.find((value) => record(value, "secret target").id === secret.targetId); + if (target === undefined || record(target, "secret target").enabled !== true) fail(`Secret target 未启用:${secret.targetId}`); + const bindings = array(config.kubernetesSecrets, `${secret.configRef}#kubernetesSecrets`); + const binding = bindings.find((value) => record(value, "secret binding").name === secret.secretName); + if (binding === undefined) fail(`Secret binding 不存在:${secret.secretName}`); + const bindingRecord = record(binding, `kubernetesSecrets.${secret.secretName}`); + if (bindingRecord.targetId !== secret.targetId || bindingRecord.secretName !== secret.secretName) fail(`Secret binding target 不匹配:${secret.secretName}`); + const data = array(bindingRecord.data, `kubernetesSecrets.${secret.secretName}.data`); + const item = data.find((value) => record(value, "secret data").targetKey === secret.targetKey); + if (item === undefined) fail(`Secret targetKey 不存在:${secret.targetKey}`); + const itemRecord = record(item, `kubernetesSecrets.${secret.secretName}.data.${secret.targetKey}`); + return { + sourceRef: string(itemRecord.sourceRef, `kubernetesSecrets.${secret.secretName}.data.sourceRef`), + sourceKey: string(itemRecord.sourceKey, `kubernetesSecrets.${secret.secretName}.data.sourceKey`), + }; +} + +function verifySourceCommit(sourceRoot, sourceCommit, environmentName) { + const environmentCommit = process.env[environmentName]?.trim() ?? ""; + if (environmentCommit !== sourceCommit) fail(`${environmentName} 与 --source-commit 不一致`); + const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: sourceRoot, encoding: "utf8" }); + if (result.status !== 0) fail(`无法读取 source checkout commit:${result.stderr || result.stdout}`); + if (result.stdout.trim() !== sourceCommit) fail("source checkout HEAD 与 --source-commit 不一致"); +} + +function createArchive(sourceRoot, sourceCommit, sourcePaths, temporaryRoot) { + const tarPath = join(temporaryRoot, "source.tar"); + const markerPath = join(temporaryRoot, ".unidesk-source-commit"); + writeFileSync(markerPath, `${sourceCommit}\n`, { mode: 0o600 }); + runChecked("git", ["archive", "--format=tar", "--prefix=source/", `--output=${tarPath}`, sourceCommit, "--", ...sourcePaths], sourceRoot); + runChecked("tar", ["--append", "--file", tarPath, "--directory", temporaryRoot, ".unidesk-source-commit"], sourceRoot); + runChecked("gzip", ["-n", tarPath], sourceRoot); + return `${tarPath}.gz`; +} + +function remoteApplyScript(input) { + return `set -eu +archive=${shellQuote(input.archivePath)} +archive_root=${shellQuote(input.archiveRoot)} +expected_sha=${shellQuote(input.archiveSha256)} +source_commit=${shellQuote(input.sourceCommit)} +entrypoint=${shellQuote(input.entrypoint)} +config=${shellQuote(input.config)} +target_id=${shellQuote(input.targetId)} +actual_sha="$(sha256sum "$archive" | awk '{print $1}')" +test "$actual_sha" = "$expected_sha" +tmp="$(mktemp -d "$archive_root/tmp/$source_commit.XXXXXX")" +cleanup() { rm -rf "$tmp"; rm -f "$archive"; } +trap cleanup EXIT HUP INT TERM +tar -xzf "$archive" -C "$tmp" +test "$(cat "$tmp/.unidesk-source-commit")" = "$source_commit" +test -f "$tmp/source/$entrypoint" +cd "$tmp/source" +bun "$entrypoint" --source-root "$tmp/source" --source-commit "$source_commit" --config "$config" --target "$target_id" --confirm +`; +} + +function runTrans(transPath, args, env, input) { + const result = spawnSync(transPath, args, { encoding: "utf8", env, input, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); + if (result.status !== 0) fail(`trans ${args.slice(1, 3).join(" ")} 失败:${result.stderr || result.stdout}`); + return result; +} + +function runChecked(command, args, cwd) { + const result = spawnSync(command, args, { cwd, encoding: "utf8", timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); + if (result.status !== 0) fail(`${command} ${args.join(" ")} 失败:${result.stderr || result.stdout}`); +} + +function compactResult(result) { + const stdout = result.stdout.trim(); + if (stdout.length === 0) return { ok: true }; + try { + return JSON.parse(stdout); + } catch { + return { ok: true, stdout: stdout.slice(-2000) }; + } +} + +function sha256File(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function record(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) fail(`${label} 必须是对象`); + return value; +} + +function array(value, label) { + if (!Array.isArray(value) || value.length === 0) fail(`${label} 必须是非空数组`); + return value; +} + +function string(value, label) { + if (typeof value !== "string" || value.trim().length === 0) fail(`${label} 必须是非空字符串`); + return value.trim(); +} + +function absolutePath(value, label) { + const path = string(value, label); + if (!isAbsolute(path)) fail(`${label} 必须是绝对路径`); + return path.replace(/\/+$/u, "") || "/"; +} + +function safeRelativePath(value, label) { + const path = string(value, label); + if (isAbsolute(path) || !/^[A-Za-z0-9._/-]+$/u.test(path) || path.split("/").some((segment) => segment === ".." || segment === "")) fail(`${label} 必须是仓库内相对路径`); + return path; +} + +function relativeWithin(root, path) { + const prefix = `${root.replace(/\/+$/u, "")}/`; + if (!path.startsWith(prefix)) fail("--config 必须位于 --source-root 内"); + return path.slice(prefix.length); +} + +function shellQuote(value) { + return `'${String(value).replace(/'/gu, `'\\''`)}'`; +} + +function fail(message) { + process.stderr.write(`${JSON.stringify({ ok: false, action: "platform-infra-public-edge-delivery", error: message })}\n`); + process.exit(1); +} diff --git a/scripts/native/cicd/deliver-platform-infra-public-edge.test.ts b/scripts/native/cicd/deliver-platform-infra-public-edge.test.ts new file mode 100644 index 00000000..a09d4218 --- /dev/null +++ b/scripts/native/cicd/deliver-platform-infra-public-edge.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { rootPath } from "../../src/config"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("platform-infra public-edge automatic delivery", () => { + test("renders a commit-pinned single-authority dry-run plan", () => { + const root = rootPath(); + const sourceCommit = gitHead(root); + const result = spawnSync("bun", [ + "scripts/native/cicd/deliver-platform-infra-public-edge.mjs", + "--source-root", root, + "--source-commit", sourceCommit, + "--dry-run", + ], { + cwd: root, + encoding: "utf8", + env: { ...process.env, SOURCE_COMMIT: sourceCommit }, + timeout: 30_000, + }); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + action: "platform-infra-public-edge-delivery-plan", + mutation: false, + sourceCommit, + pipelineId: "platform-infra-gitea-nc01", + target: { id: "NC01", route: "NC01" }, + secretRef: { + configRef: "config/secrets-distribution.yaml", + targetId: "public-edge-delivery-nc01", + secretName: "public-edge-trans", + targetKey: "UNIDESK_SSH_CLIENT_TOKEN", + sourceRef: "unidesk-host-k8s.env", + sourceKey: "UNIDESK_SSH_CLIENT_TOKEN", + }, + hostHelper: { + entrypoint: "scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs", + sourcePaths: ["config", "scripts", "package.json", "bun.lock"], + }, + valuesPrinted: false, + }); + }); + + test("PaC projects only the controlled trans secret and existing apply entrypoint", () => { + const pac = readFileSync(`${rootPath()}/.tekton/platform-infra-gitea-nc01-pac.yaml`, "utf8"); + expect(pac).toContain("name: apply-public-edge"); + expect(pac).toContain("name: public-edge-trans"); + expect(pac).toContain("key: UNIDESK_SSH_CLIENT_TOKEN"); + expect(pac).toContain("deliver-platform-infra-public-edge.mjs"); + expect(pac).toContain("--source-commit \"$SOURCE_COMMIT\""); + expect(pac).toContain("--confirm"); + expect(pac).not.toMatch(/PostSync|hostPath|docker\.sock|\/var\/run\/docker|127\.0\.0\.1:2019|caddy reload|docker exec/u); + }); + + test("Secret distribution owns the sourceRef and targetKey", () => { + const secrets = Bun.YAML.parse(readFileSync(`${rootPath()}/config/secrets-distribution.yaml`, "utf8")); + const binding = secrets.kubernetesSecrets.find((item: Record) => item.name === "public-edge-trans"); + expect(binding).toMatchObject({ + targetId: "public-edge-delivery-nc01", + secretName: "public-edge-trans", + data: [{ + sourceRef: "unidesk-host-k8s.env", + sourceKey: "UNIDESK_SSH_CLIENT_TOKEN", + targetKey: "UNIDESK_SSH_CLIENT_TOKEN", + }], + }); + }); + + test("confirm creates a commit archive and uses only controlled trans operations", () => { + const fixture = mkdtempSync(join(tmpdir(), "public-edge-delivery-fixture-")); + temporaryRoots.push(fixture); + write(fixture, "config/platform-infra/public-edge.yaml", Bun.YAML.stringify({ + defaults: { targetId: "NC01" }, + targets: { NC01: { route: "NC01" } }, + delivery: { + enabled: true, + pipelineId: "platform-infra-gitea-nc01", + sourceCommitEnvironment: "SOURCE_COMMIT", + transport: { + hostCwd: "/root", + secretRef: { + configRef: "config/secrets-distribution.yaml", + targetId: "public-edge-delivery-nc01", + secretName: "public-edge-trans", + targetKey: "UNIDESK_SSH_CLIENT_TOKEN", + }, + }, + hostHelper: { + archiveRoot: "/root/unidesk/.state/public-edge-delivery", + entrypoint: "scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs", + sourcePaths: ["config", "scripts", "package.json", "bun.lock"], + }, + }, + })); + write(fixture, "config/secrets-distribution.yaml", Bun.YAML.stringify({ + targets: [{ id: "public-edge-delivery-nc01", enabled: true }], + kubernetesSecrets: [{ + name: "public-edge-trans", + targetId: "public-edge-delivery-nc01", + secretName: "public-edge-trans", + data: [{ + sourceRef: "unidesk-host-k8s.env", + sourceKey: "UNIDESK_SSH_CLIENT_TOKEN", + targetKey: "UNIDESK_SSH_CLIENT_TOKEN", + }], + }], + })); + write(fixture, "scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs", "process.exit(0);\n"); + write(fixture, "scripts/trans", `#!/bin/sh +set -eu +if [ "$2" = "sh" ] && [ "\${3-}" != "--" ]; then + cat >/dev/null + printf '%s\\n' '{"ok":true,"mutation":true}' +else + printf '%s\\n' '{"ok":true}' +fi +`); + chmodSync(join(fixture, "scripts/trans"), 0o755); + write(fixture, "package.json", "{}\n"); + write(fixture, "bun.lock", ""); + git(["init", "-b", "master"], fixture); + git(["config", "user.name", "UniDesk Test"], fixture); + git(["config", "user.email", "unidesk-test@example.invalid"], fixture); + git(["add", "."], fixture); + git(["commit", "-m", "fixture"], fixture); + const sourceCommit = gitHead(fixture); + const result = spawnSync("bun", [ + "scripts/native/cicd/deliver-platform-infra-public-edge.mjs", + "--source-root", fixture, + "--source-commit", sourceCommit, + "--confirm", + ], { + cwd: rootPath(), + encoding: "utf8", + env: { + ...process.env, + SOURCE_COMMIT: sourceCommit, + UNIDESK_SSH_CLIENT_TOKEN: "fixture-token", + }, + timeout: 30_000, + }); + expect(result.status, result.stderr).toBe(0); + const output = JSON.parse(result.stdout); + expect(output).toMatchObject({ + ok: true, + action: "platform-infra-public-edge-delivery", + mutation: true, + sourceCommit, + runtime: { ok: true, mutation: true }, + }); + expect(output.archive.sha256).toMatch(/^sha256:[0-9a-f]{64}$/u); + }); +}); + +function gitHead(cwd: string): string { + const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout); + return result.stdout.trim(); +} + +function write(root: string, relativePath: string, content: string) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +function git(args: string[], cwd: string) { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout); +}