diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index de6b1dac..0d22ba22 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -1,6 +1,13 @@ --- name: unidesk-cicd -description: UniDesk CI/CD 控制面 — `cicd branch-follower` 退役只读诊断、`hwlab g14`、`hwlab nodes control-plane` 和 `agentrun` 子命令,覆盖 PR 监控自动合并、Tekton/Argo 控制面、git-mirror、Secret、observability、CI tools image、PipelineRun 清理、AgentRun v0.1 部署和 AgentRun YAML-only lane 部署。用户提到 CI/CD、deploy、rollout、PipelineRun、trigger、git-mirror、control-plane、k8s/k3s 部署、branch follower、自动跟随、agentrun 部署、hwlab g14、monitor-prs、trigger-current 时使用。任何需要把代码变更推送部署到 G14 k3s 的操作都必须走本 skill。 +description: >- + UniDesk CI/CD 控制面,覆盖 PaC consumer 首发 bootstrap、Tekton/Argo、GitOps、 + git-mirror、PR 自动交付、Secret、observability、CI tools image、PipelineRun 清理、 + AgentRun 与 HWLAB 部署,以及 branch-follower 退役只读诊断。 + 用户提到 CI/CD、deploy、rollout、PipelineRun、PaC、bootstrap、GitOps、Tekton、 + git-mirror、control-plane、k8s/k3s 部署、branch follower、agentrun、hwlab g14、 + monitor-prs 或 trigger-current 时使用。 + 任何需要把代码变更推送部署到 G14 k3s 的操作都必须走本 skill。 --- # UniDesk CI/CD @@ -19,6 +26,8 @@ bun scripts/cli.ts hwlab g14 git-mirror status --lane v02 bun scripts/cli.ts agentrun control-plane status bun scripts/cli.ts platform-infra gitea mirror status --target JD01 bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01 +bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target NC01 --consumer --dry-run +bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target NC01 --consumer --confirm bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer hwlab-jd01-v03 bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10 @@ -33,6 +42,14 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help 节点级只读状态必须优先用 `cicd status --node `。它从 `config/platform-infra/pipelines-as-code.yaml` 找到该 node 的所有当前 PaC consumer,一次性汇总 PipelineRun、Argo/GitOps、runtime readiness 和诊断;不要再靠阅读源码或手动拼三条 consumer 命令来回答 “NC01 的 CI/CD 流水线情况”。`platform-infra pipelines-as-code status --target --consumer ` 只作为单 consumer drill-down。 +- 新增 PaC consumer 的首次引导: + - 先执行一次 `pipelines-as-code bootstrap --dry-run`; + - 确认后把同一命令改为 `--confirm`; + - 入口从 Gitea 与 PaC owning YAML 精确解析仓库; + - 入口只初始化空 Gitea 仓库和 PaC/Tekton/GitOps 控制面; + - 随后合并 source PR,作为唯一交付触发; + - 详细最短路径见 [references/gitea-pac.md](references/gitea-pac.md)。 + 按职责读取拆分后的 reference: - PR monitor 与自动合并: [references/pr-monitor.md](references/pr-monitor.md)。 @@ -47,6 +64,10 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help ## P0 边界 - PaC migrated consumer 的唯一正式 CI/CD 触发是目标 source branch 的 GitHub PR merge。合并后由 GitHub webhook -> Gitea controlled mirror + immutable snapshot ref -> Gitea webhook -> PaC -> Tekton -> GitOps/Argo -> runtime 自动滚动;不得要求主代理、子代理或操作者再执行 apply、closeout、trigger、sync、flush 或补跑。 +- PaC consumer 首次引导必须满足以下约束: + - 默认使用 `pipelines-as-code bootstrap --dry-run|--confirm`,不再人工串联 Gitea bootstrap 与 PaC apply; + - `apply` 必须在任何 release、RBAC、Secret、Repository CR、Argo 或 webhook 写入前确认 YAML 匹配的 Gitea 仓库已存在; + - source PR 合并后不得再次调用 bootstrap 或 apply;正常验收不串联 mirror/status/history,只有失败归因才逐层下钻。 - 自动链路不通时必须修复自动链自身,并通过修复 PR 合并产生的新正常事件验收。禁止人工 mirror sync、直接 Gitea push、人工 PipelineRun、`trigger-current`、运行面 patch 或其他手段补齐当前交付;只读 status/history/events/logs/debug-step 可用于定位,但不得改变交付状态。`closeout` 只属于显式 compatibility diagnostics,不得进入默认观察或恢复路径。 - CI/CD、GitOps、rollout、PipelineRun、Argo、git-mirror 和 AgentRun 的观察、诊断与 legacy 控制必须走受控 CLI;不要用裸 `kubectl`、`argo`、`tkn`、`curl` 当正式控制入口。PaC migrated consumer 仍只由 GitHub PR merge 触发,不得因“必须走 CLI”而额外调用写命令。 - CI/CD source authority 只能来自 YAML 声明的 Kubernetes 托管 source authority: diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index 4d129c17..5c9488f8 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -44,6 +44,41 @@ GitHub 是唯一上游写入权威。目标 source branch 的 GitHub PR merge - PaC Repository CR `spec.url` 必须与 Gitea webhook payload 中的 URL 一致。`config/platform-infra/pipelines-as-code.yaml#repositories[].url` 保持公网 Gitea repository URL,k8s 内网 service URL 只能写入 `cloneUrl` 或 `params.git_read_url`;否则 PaC 会记录 `cannot find a repository match` 且不会创建 PipelineRun。 - JD01 与 NC01 共用的 repository 必须传入 target-specific `node` Repository param,每个 `.tekton` PipelineRun 必须用 `pipelinesascode.tekton.dev/on-cel-expression` 过滤该参数。一个 target 的 push 不得在同一 target cluster 创建另一 target 的 PipelineRun。 +## PaC consumer 首发最短路径 + +- 在 source PR 合并前完成源码侧准备: + - owning YAML 中声明 Gitea repository、PaC repository 与 consumer; + - consumer 声明 `sourceArtifact` 时,先在 source worktree 执行 `source-artifact check`; + - 不创建人工 PipelineRun,也不推送 Gitea branch 或 snapshot。 +- 只使用同一个组合入口完成控制面首次引导: + +```bash +bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --consumer --dry-run +bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --consumer --confirm +``` + +- 组合入口负责以下职责: + - 组合 `config/platform-infra/gitea.yaml` 与 `config/platform-infra/pipelines-as-code.yaml`; + - 按 target、Gitea owner/name 与 read URL 精确选择唯一 repository; + - 初始化选中的空 Gitea repository; + - 安装或更新 PaC controller、consumer RBAC、Repository CR、Argo bootstrap 与 Gitea webhook; + - 不同步 source branch,不创建 snapshot,不触发业务 PipelineRun。 +- 预检与失败边界: + - PaC `apply` 在任何 Kubernetes 写入前读取 Gitea repository; + - repository 缺失时返回 `gitea-repository-missing`,不留下半套 Tekton/RBAC/Argo 状态; + - YAML 零匹配或多匹配时在本地拒绝,不猜测 repository; + - `gitea mirror bootstrap` 未带 `--confirm` 时输出可读 dry-run 与精确 confirm 命令,不再返回空白拒绝。 +- 完成 `bootstrap --confirm` 后合并 source PR: + - GitHub PR merge 是唯一 delivery 触发; + - 不再调用 bootstrap、apply、mirror sync、PipelineRun、Argo refresh 或 closeout; + - 正常链路不串联 mirror status、webhook status、PaC status 与 history。 +- 只有出现自动链故障或用户明确要求验收时才读取状态: + - 首选一次 `cicd status --node ` 获取 node 摘要; + - 只对失败 consumer 使用一次 `pipelines-as-code status --consumer `; + - 只有归属或 TaskRun 断点不清楚时再用 `history --id` 或 `debug-step --id`; + - 默认不使用 `--full` 或 `--raw`,避免把大对象带回本机; + - consumer 声明 `sourceArtifact` 且需要精确提交验收时,最后执行一次 `source-artifact verify-runtime`。 + ## PaC 源码侧制品同步 当 owning Pipeline 与消费仓 `.tekton` 内联 `pipelineSpec` 或远程 Pipeline 文件存在漂移时,统一使用以下入口: diff --git a/.gitignore b/.gitignore index ebc95e84..c2f2f3f4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ dist/ coverage/ target/ **/target/ +**/__pycache__/ +*.pyc # Local scratch/worktree artifacts /.worktree/ diff --git a/scripts/src/platform-infra-gitea-render.ts b/scripts/src/platform-infra-gitea-render.ts index b7bd9579..73271297 100644 --- a/scripts/src/platform-infra-gitea-render.ts +++ b/scripts/src/platform-infra-gitea-render.ts @@ -27,17 +27,24 @@ export function renderMirrorPlan(result: Record): RenderedCliRe } export function renderMirrorBootstrap(result: Record): RenderedCliResult { - const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.owner), stringValue(repo.name), boolText(record(repo.create).ok)]); + const repos = arrayRecords(result.repositories).map((repo) => [ + stringValue(repo.key), + stringValue(repo.owner, stringValue(repo.giteaOwner)), + stringValue(repo.name, stringValue(repo.giteaRepo)), + result.mode === "dry-run" ? "planned" : boolText(record(repo.create).ok), + ]); + const blockers = Array.isArray(result.blockers) ? result.blockers.map(String) : []; const next = record(result.next); return rendered(result, "platform-infra gitea mirror bootstrap", [ "PLATFORM-INFRA GITEA MIRROR BOOTSTRAP", - ...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]), + ...table(["OK", "MODE", "MUTATION", "TARGET"], [[boolText(result.ok), stringValue(result.mode), boolText(result.mutation), stringValue(record(result.target).id)]]), "", "REPOSITORIES", - ...(repos.length === 0 ? ["-"] : table(["KEY", "OWNER", "REPO", "API_OK"], repos)), + ...(repos.length === 0 ? ["-"] : table(["KEY", "OWNER", "REPO", "STATE"], repos)), + ...(blockers.length === 0 ? [] : ["", `BLOCKERS ${blockers.join(",")}`]), ...remoteErrorLines(result), "", - `NEXT ${stringValue(next.webhookStatus)}`, + `NEXT ${stringValue(result.mode === "dry-run" ? next.confirm : next.webhookStatus)}`, ]); } diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 20b7c759..9402413c 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -71,6 +71,8 @@ interface ApplyOptions extends CommonOptions { interface MirrorOptions extends CommonOptions { confirm: boolean; + dryRun: boolean; + wait: boolean; repoKey: string | null; } @@ -127,6 +129,7 @@ export function giteaHelp(scope?: string): Record { usage: [ "bun scripts/cli.ts platform-infra gitea apply --target --dry-run", "bun scripts/cli.ts platform-infra gitea apply --target --confirm", + "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target --dry-run", "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target --confirm", "bun scripts/cli.ts platform-infra gitea mirror webhook apply --target --confirm", ], @@ -357,8 +360,28 @@ async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { re async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): Promise> { const gitea = readGiteaConfig(); const target = resolveCommandTarget(gitea, options.targetId); - if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-bootstrap", mutation: false, mode: "missing-confirm", error: "mirror bootstrap requires --confirm" }; const repos = selectedRepositories(gitea, target, options.repoKey); + if (!options.confirm) { + const credentials = credentialSummaries(gitea); + const blockers = credentials + .filter((item) => item.id === "github-upstream" && (item.present !== true || item.requiredKeysPresent !== true)) + .map((item) => String(item.id)); + const repoFlag = options.repoKey === null ? "" : ` --repo ${options.repoKey}`; + return { + ok: blockers.length === 0, + action: "platform-infra-gitea-mirror-bootstrap", + mutation: false, + mode: "dry-run", + target: targetSummary(target), + repositories: repos.map((repo) => repositorySummary(gitea, repo)), + credentials, + blockers, + next: { + ...mirrorReadOnlyNextCommands(target.id), + confirm: `bun scripts/cli.ts platform-infra gitea mirror bootstrap --target ${target.id}${repoFlag} --confirm`, + }, + }; + } const secrets = ensureMirrorSecrets(gitea, true, true); const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script); const parsed = parseJsonOutput(result.stdout); @@ -366,6 +389,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P ok: result.exitCode === 0 && parsed?.ok === true, action: "platform-infra-gitea-mirror-bootstrap", mutation: true, + mode: "confirmed", target: targetSummary(target), repositories: arrayRecords(record(parsed).repositories), credentials: credentialSummaries(gitea), @@ -1759,11 +1783,14 @@ function parseMirrorWebhookStatusOptions(args: string[]): MirrorWebhookStatusOpt function parseMirrorOptions(args: string[]): MirrorOptions { const commonArgs: string[] = []; let confirm = false; + let dryRun = false; let repoKey: string | null = null; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--confirm") { confirm = true; + } else if (arg === "--dry-run") { + dryRun = true; } else if (arg === "--repo") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new CliInputError("--repo requires a value", { @@ -1786,7 +1813,15 @@ function parseMirrorOptions(args: string[]): MirrorOptions { } } } - return { ...parseCommonOptions(commonArgs), confirm, repoKey }; + if (confirm && dryRun) throw new CliInputError("Gitea mirror bootstrap accepts only one of --confirm or --dry-run", { + code: "mutually-exclusive-options", + argument: "--confirm --dry-run", + usage: [ + "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target --dry-run", + "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target --confirm", + ], + }); + return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait: false, repoKey }; } function parseCommonOptions(args: string[]): CommonOptions { diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts new file mode 100644 index 00000000..bb8d7bd8 --- /dev/null +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config"; +import { renderMirrorBootstrap } from "./platform-infra-gitea-render"; +import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; +import { runPlatformInfraPipelinesAsCodeCommand } from "./platform-infra-pipelines-as-code"; + +const mirror: GiteaMirrorRepository = { + key: "widgets-nc01", + targetId: "NC01", + upstream: { repository: "acme/widgets", cloneUrl: "https://github.com/acme/widgets.git", branch: "main", visibility: "public" }, + gitea: { owner: "mirrors", name: "acme-widgets", mirrorMode: "controlled-push", publicRead: true, readUrl: "http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/acme-widgets.git" }, + gitops: { branch: "main", flushDisposition: "not-a-gitops-branch" }, + snapshot: { prefix: "refs/unidesk/snapshots/widgets-main-nc01" }, + legacyGitMirror: null, +}; + +function fixtureConfig(repositories: GiteaMirrorRepository[]): GiteaConfig { + return { sourceAuthority: { repositories } } as unknown as GiteaConfig; +} + +test("PaC bootstrap uses one exact YAML-owned Gitea repository", () => { + const selected = resolvePacBootstrapMirrorRepository(fixtureConfig([mirror]), "nc01", { + id: "widgets-nc01", + owner: "mirrors", + repo: "acme-widgets", + cloneUrl: `${mirror.gitea.readUrl}/`, + }); + expect(selected.key).toBe("widgets-nc01"); + expect(() => resolvePacBootstrapMirrorRepository(fixtureConfig([]), "NC01", { + id: "widgets-nc01", + owner: "mirrors", + repo: "acme-widgets", + cloneUrl: mirror.gitea.readUrl, + })).toThrow("no exact match"); + expect(() => resolvePacBootstrapMirrorRepository(fixtureConfig([mirror, { ...mirror }]), "NC01", { + id: "widgets-nc01", + owner: "mirrors", + repo: "acme-widgets", + cloneUrl: mirror.gitea.readUrl, + })).toThrow("2 exact matches"); +}); + +test("Gitea mirror bootstrap defaults to a visible dry-run instead of an empty refusal", () => { + const rendered = renderMirrorBootstrap({ + ok: true, + action: "platform-infra-gitea-mirror-bootstrap", + mode: "dry-run", + mutation: false, + target: { id: "NC01" }, + repositories: [{ key: "widgets-nc01", giteaOwner: "mirrors", giteaRepo: "acme-widgets" }], + blockers: [], + next: { confirm: "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target NC01 --repo widgets-nc01 --confirm" }, + }).renderedText; + expect(rendered).toContain("MODE MUTATION TARGET"); + expect(rendered).toContain("widgets-nc01"); + expect(rendered).toContain("--repo widgets-nc01 --confirm"); + expect(rendered).not.toContain("missing-confirm"); +}); + +test("PaC apply checks the Gitea repository before dry-run return and cluster mutation", () => { + const remote = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-remote.sh"), "utf8"); + const action = remote.indexOf("apply_action() {"); + const preflight = remote.indexOf("if ! repository_preflight", action); + const dryRun = remote.indexOf('if [ "$UNIDESK_PAC_DRY_RUN" = "1" ]', action); + const release = remote.indexOf(" apply_release", action); + expect(action).toBeGreaterThanOrEqual(0); + expect(preflight).toBeGreaterThan(action); + expect(dryRun).toBeGreaterThan(preflight); + expect(release).toBeGreaterThan(dryRun); + expect(remote).toContain('"error":"gitea-repository-missing"'); +}); + +test("platform bootstrap help advertises the composite entry before low-level apply", async () => { + const help = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["help", "platform-bootstrap"]); + const usage = (help as Record).usage as string[]; + expect(usage[0]).toContain("pipelines-as-code bootstrap"); + expect(usage).toContain("bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --consumer --confirm"); +}); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts new file mode 100644 index 00000000..29451758 --- /dev/null +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -0,0 +1,35 @@ +import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config"; + +export interface PacBootstrapRepositoryIdentity { + readonly id: string; + readonly owner: string; + readonly repo: string; + readonly cloneUrl: string; +} + +export function resolvePacBootstrapMirrorRepository( + gitea: GiteaConfig, + targetId: string, + repository: PacBootstrapRepositoryIdentity, +): GiteaMirrorRepository { + const candidates = gitea.sourceAuthority.repositories.filter((item) => ( + item.targetId.toLowerCase() === targetId.toLowerCase() + && item.gitea.owner === repository.owner + && item.gitea.name === repository.repo + && repositoryUrlIdentity(item.gitea.readUrl) === repositoryUrlIdentity(repository.cloneUrl) + )); + if (candidates.length !== 1) { + const disposition = candidates.length === 0 ? "no exact match" : `${candidates.length} exact matches`; + throw new Error( + `cannot resolve the YAML-owned Gitea bootstrap repository for PaC repository ${repository.id} on ${targetId}: ${disposition}; ` + + "match targetId, gitea owner/name, and read URL in config/platform-infra/gitea.yaml", + ); + } + return candidates[0]; +} + +function repositoryUrlIdentity(value: string): string { + const parsed = new URL(value); + const path = parsed.pathname.replace(/\/+$/u, ""); + return `${parsed.protocol}//${parsed.host}${path}`; +} diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index d9818316..6c82d3af 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -100,6 +100,37 @@ gitea_api() { return 22 } +gitea_api_status() { + method="$1" + path="$2" + status=$(curl -sS -o /dev/null -w '%{http_code}' \ + -u "$UNIDESK_PAC_GITEA_ADMIN_USERNAME:$UNIDESK_PAC_GITEA_ADMIN_PASSWORD" \ + -X "$method" \ + "$(api_url "$path")" 2>/dev/null) || status=000 + printf '%s' "${status:-000}" +} + +repository_preflight() { + repository_path="repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO" + repository_status=$(gitea_api_status GET "$repository_path") + case "$repository_status" in + 2*) return 0 ;; + 404) + printf '{"ok":false,"mode":"preflight-blocked","mutation":false,"error":"gitea-repository-missing","preflight":{"repositoryExists":false,"httpStatus":"404","repository":"%s/%s","valuesPrinted":false},"valuesPrinted":false}\n' \ + "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \ + "$(json_string "$UNIDESK_PAC_GITEA_REPO")" + return 22 + ;; + *) + printf '{"ok":false,"mode":"preflight-blocked","mutation":false,"error":"gitea-repository-preflight-failed","preflight":{"repositoryExists":false,"httpStatus":"%s","repository":"%s/%s","valuesPrinted":false},"valuesPrinted":false}\n' \ + "$(json_string "$repository_status")" \ + "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \ + "$(json_string "$UNIDESK_PAC_GITEA_REPO")" + return 22 + ;; + esac +} + ensure_token() { name="unidesk-pac-$(date +%Y%m%d%H%M%S)" body=$(printf '{"name":"%s","scopes":["all"]}' "$name") @@ -268,8 +299,17 @@ wait_ready() { } apply_action() { + preflight_tmp=$(mktemp) + if ! repository_preflight >"$preflight_tmp"; then + cat "$preflight_tmp" + rm -f "$preflight_tmp" + return 22 + fi + rm -f "$preflight_tmp" if [ "$UNIDESK_PAC_DRY_RUN" = "1" ]; then - printf '{"ok":true,"mode":"dry-run","mutation":false,"valuesPrinted":false}\n' + printf '{"ok":true,"mode":"dry-run","mutation":false,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"valuesPrinted":false}\n' \ + "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \ + "$(json_string "$UNIDESK_PAC_GITEA_REPO")" return fi apply_release @@ -311,7 +351,7 @@ apply_action() { hook_id=$(ensure_webhook) ready=false if wait_ready; then ready=true; fi - printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"argoBootstrap":%s,"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$argo_bootstrap" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")" + printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"argoBootstrap":%s,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$argo_bootstrap" "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" "$(json_string "$UNIDESK_PAC_GITEA_REPO")" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")" } condition_status() { diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index b041a07b..be5e7a9d 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -28,6 +28,9 @@ import { } from "./platform-infra-pipelines-as-code-source-artifact"; import { pacAdmissionDesiredIdentity } from "./platform-infra-pac-provenance"; import { pacConsumerRbacDesiredIdentity, renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac"; +import { runPlatformInfraGiteaCommand } from "./platform-infra-gitea"; +import { readGiteaConfig } from "./platform-infra-gitea-config"; +import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml"); const configLabel = "config/platform-infra/pipelines-as-code.yaml"; @@ -240,6 +243,11 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf const result = await apply(config, options); return options.json || options.full || options.raw ? result : renderApply(result); } + if (action === "bootstrap") { + const options = parseApplyOptions(args.slice(1)); + const result = await bootstrap(config, options); + return options.json || options.full || options.raw ? result : renderBootstrap(result); + } if (action === "status") { const options = parseCommonOptions(args.slice(1)); const result = await status(config, options); @@ -353,10 +361,12 @@ function help(scope: string | null): Record { scope: "explicit-initial-platform-bootstrap", configTruth: configLabel, usage: [ + "bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --consumer --dry-run", + "bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --consumer --confirm", "bun scripts/cli.ts platform-infra pipelines-as-code apply --target --dry-run", "bun scripts/cli.ts platform-infra pipelines-as-code apply --target --confirm", ], - boundary: "仅用于首次安装或显式平台 bootstrap;不得在 source PR 合并后用于交付、恢复或补跑。", + boundary: "bootstrap 是首次引导的最短入口;apply 保留为单层维护入口。两者都不得在 source PR 合并后用于交付、恢复或补跑。", mutation: "explicit-confirm-required", }; } @@ -889,7 +899,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { + const pac = readPacConfig(); + const target = resolveTarget(pac, options.targetId); + const consumer = resolveConsumer(pac, options.consumerId); + if (consumer.node.toLowerCase() !== target.id.toLowerCase()) { + throw new Error(`Pipelines-as-Code consumer ${consumer.id} belongs to ${consumer.node}, not target ${target.id}`); + } + const repository = resolveRepository(pac, consumer.repositoryRef); + const mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository); + const giteaArgs = ["mirror", "bootstrap", "--target", target.id, "--repo", mirror.key, ...(options.confirm ? ["--confirm"] : []), "--full"]; + const giteaBootstrap = record(await runPlatformInfraGiteaCommand(config, giteaArgs)); + if (options.confirm && giteaBootstrap.ok !== true) { + return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, { + ok: false, + mutation: false, + mode: "skipped", + error: "gitea-bootstrap-failed", + valuesPrinted: false, + }); + } + const pacApply = await apply(config, options); + return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, pacApply); +} + +function bootstrapResult( + target: PacTarget, + consumer: PacConsumer, + repository: PacRepository, + mirror: ReturnType, + options: ApplyOptions, + giteaBootstrap: Record, + pacApply: Record, +): Record { + const remote = record(pacApply.remote); + const repositoryMissing = remote.error === "gitea-repository-missing"; + const pacReady = pacApply.ok === true || (!options.confirm && repositoryMissing); + const ok = giteaBootstrap.ok === true && pacReady; + const confirm = `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target ${target.id} --consumer ${consumer.id} --confirm`; + return { + ok, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: giteaBootstrap.mutation === true || pacApply.mutation === true, + mode: options.confirm ? "confirmed" : "dry-run", + target: targetSummary(target), + consumer: consumerObservationSummary(consumer), + repository: repositorySummary(repository), + mirrorRepository: { + key: mirror.key, + upstreamRepository: mirror.upstream.repository, + upstreamBranch: mirror.upstream.branch, + owner: mirror.gitea.owner, + repo: mirror.gitea.name, + valuesPrinted: false, + }, + steps: [ + { id: "gitea-repository", ok: giteaBootstrap.ok === true, mutation: giteaBootstrap.mutation === true, mode: giteaBootstrap.mode, valuesPrinted: false }, + { id: "pac-tekton-gitops", ok: pacReady, mutation: pacApply.mutation === true, mode: pacApply.mode, repositoryMissing, valuesPrinted: false }, + ], + giteaBootstrap, + pipelinesAsCodeApply: pacApply, + next: options.confirm + ? { + deliveryTrigger: `Merge the prepared GitHub PR for ${mirror.upstream.repository}@${mirror.upstream.branch}; the automatic chain owns mirror, PaC, Tekton, GitOps, Argo, and runtime convergence.`, + investigate: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${target.id} --consumer ${consumer.id}`, + } + : { + confirm, + boundary: "Run confirm only before the first source PR merge; do not use bootstrap as post-merge recovery.", + }, + valuesPrinted: false, + }; +} + async function status(config: UniDeskConfig, options: CommonOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); @@ -2023,6 +2106,7 @@ function renderPlan(result: Record): RenderedCliResult { function renderApply(result: Record): RenderedCliResult { const target = record(result.target); const remote = record(result.remote); + const preflight = record(remote.preflight); const errorText = compactLine(stringValue(remote.stderrTail, stringValue(remote.error, stringValue(result.error, "")))); const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE APPLY", @@ -2030,7 +2114,13 @@ function renderApply(result: Record): RenderedCliResult { "", "REMOTE", ...table(["CONTROLLER", "WEBHOOK", "REPOSITORY", "EXIT", "VALUES"], [[stringValue(remote.controllerReady), stringValue(record(remote.webhook).present), stringValue(remote.repository), stringValue(remote.exitCode, "0"), "false"]]), + ...(Object.keys(preflight).length === 0 ? [] : [ + "", + "PREFLIGHT", + ...table(["GITEA_REPOSITORY", "HTTP", "EXISTS"], [[stringValue(preflight.repository), stringValue(preflight.httpStatus), boolText(preflight.repositoryExists)]]), + ]), ...(result.ok === false && errorText.length > 0 ? ["", `ERROR ${errorText}`] : []), + ...(remote.error === "gitea-repository-missing" ? ["HELP bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap"] : []), "", "NEXT", ` status: ${stringValue(record(result.next).status)}`, @@ -2038,6 +2128,43 @@ function renderApply(result: Record): RenderedCliResult { return rendered(result, "platform-infra pipelines-as-code apply", lines); } +function renderBootstrap(result: Record): RenderedCliResult { + const target = record(result.target); + const consumer = record(result.consumer); + const mirror = record(result.mirrorRepository); + const steps = arrayRecords(result.steps); + const pacApply = record(result.pipelinesAsCodeApply); + const pacRemote = record(pacApply.remote); + const giteaBootstrap = record(result.giteaBootstrap); + const next = record(result.next); + const errorValue = [pacRemote.error, giteaBootstrap.error, pacApply.error] + .find((value): value is string => typeof value === "string" && value.length > 0) ?? ""; + const error = errorValue.length === 0 ? "" : compactLine(errorValue); + const lines = [ + "PLATFORM-INFRA PAC / TEKTON / GITOPS BOOTSTRAP", + ...table(["TARGET", "CONSUMER", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(consumer.id), stringValue(result.mode), boolText(result.mutation), boolText(result.ok)]]), + "", + "SOURCE AUTHORITY", + ...table(["GITEA_KEY", "GITEA_REPOSITORY", "UPSTREAM", "BRANCH"], [[stringValue(mirror.key), `${stringValue(mirror.owner)}/${stringValue(mirror.repo)}`, stringValue(mirror.upstreamRepository), stringValue(mirror.upstreamBranch)]]), + "", + "STEPS", + ...table(["STEP", "OK", "MODE", "MUTATION", "DETAIL"], steps.map((step) => [ + stringValue(step.id), + boolText(step.ok), + stringValue(step.mode), + boolText(step.mutation), + step.repositoryMissing === true ? "repository-will-be-created-on-confirm" : "ready", + ])), + ...(error.length === 0 ? [] : ["", `ERROR ${error}`]), + "", + "NEXT", + ...(result.mode === "dry-run" + ? [` confirm: ${stringValue(next.confirm)}`, ` boundary: ${stringValue(next.boundary)}`] + : [` delivery: ${stringValue(next.deliveryTrigger)}`, ` investigate: ${stringValue(next.investigate)}`]), + ]; + return rendered(result, "platform-infra pipelines-as-code bootstrap", lines); +} + function renderStatus(result: Record): RenderedCliResult { const summary = record(result.summary); const consumer = record(result.consumer);