feat: 收敛 PaC 首发 bootstrap

This commit is contained in:
Codex
2026-07-13 09:27:42 +02:00
parent 72e2f13587
commit 8c216a02df
9 changed files with 393 additions and 11 deletions
+22 -1
View File
@@ -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 <consumer> --dry-run
bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target NC01 --consumer <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 <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 <NODE> --consumer <id>` 只作为单 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
@@ -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 URLk8s 内网 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 <NODE> --consumer <consumer> --dry-run
bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <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>` 获取 node 摘要;
- 只对失败 consumer 使用一次 `pipelines-as-code status --consumer <id>`
- 只有归属或 TaskRun 断点不清楚时再用 `history --id``debug-step --id`
- 默认不使用 `--full``--raw`,避免把大对象带回本机;
- consumer 声明 `sourceArtifact` 且需要精确提交验收时,最后执行一次 `source-artifact verify-runtime`
## PaC 源码侧制品同步
当 owning Pipeline 与消费仓 `.tekton` 内联 `pipelineSpec` 或远程 Pipeline 文件存在漂移时,统一使用以下入口:
+2
View File
@@ -13,6 +13,8 @@ dist/
coverage/
target/
**/target/
**/__pycache__/
*.pyc
# Local scratch/worktree artifacts
/.worktree/
+11 -4
View File
@@ -27,17 +27,24 @@ export function renderMirrorPlan(result: Record<string, unknown>): RenderedCliRe
}
export function renderMirrorBootstrap(result: Record<string, unknown>): 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)}`,
]);
}
+37 -2
View File
@@ -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<string, unknown> {
usage: [
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra gitea apply --target <NODE> --confirm",
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <NODE> --confirm",
"bun scripts/cli.ts platform-infra gitea mirror webhook apply --target <NODE> --confirm",
],
@@ -357,8 +360,28 @@ async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { re
async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
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 <node> --dry-run",
"bun scripts/cli.ts platform-infra gitea mirror bootstrap --target <node> --confirm",
],
});
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait: false, repoKey };
}
function parseCommonOptions(args: string[]): CommonOptions {
@@ -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<string, unknown>).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 <NODE> --consumer <consumer> --confirm");
});
@@ -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}`;
}
@@ -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() {
+129 -2
View File
@@ -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<string, unknown> {
scope: "explicit-initial-platform-bootstrap",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <consumer> --dry-run",
"bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <consumer> --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --dry-run",
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --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<Reco
return {
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-pipelines-as-code-apply",
mutation: !options.dryRun,
mutation: options.dryRun ? false : parsed?.mutation !== false,
mode: options.dryRun ? "dry-run" : "confirmed",
target: targetSummary(target),
config: compactConfigSummary(pac, consumer, repository),
@@ -902,6 +912,79 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
};
}
async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
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<typeof resolvePacBootstrapMirrorRepository>,
options: ApplyOptions,
giteaBootstrap: Record<string, unknown>,
pacApply: Record<string, unknown>,
): Record<string, unknown> {
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<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId);
@@ -2023,6 +2106,7 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
function renderApply(result: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): RenderedCliResult {
return rendered(result, "platform-infra pipelines-as-code apply", lines);
}
function renderBootstrap(result: Record<string, unknown>): 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<string, unknown>): RenderedCliResult {
const summary = record(result.summary);
const consumer = record(result.consumer);