diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index 1339e4a6..671836fb 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -64,6 +64,10 @@ bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target -- - 安装或更新 PaC controller、consumer RBAC、Repository CR、Argo bootstrap 与 Gitea webhook; - 不同步 source branch,不创建 snapshot,不触发业务 PipelineRun。 - 预检与失败边界: + - 默认 human 与显式 `--json` 使用同一份紧凑 typed projection,按 `yaml-exact-match`、`github-upstream`、`gitea-repository` 前置条件,以及 `gitea-init`、`pac-controller`、`tekton-consumer`、`gitops-argo` 阶段展示结果;只有显式 `--full` 或 `--raw` 才披露底层组合结果; + - YAML 精确匹配必须显示匹配数量,并区分 `yaml-repository-no-match` 与 `yaml-repository-multiple-matches`; + - GitHub 上游或凭据不可用、Gitea 初始化失败与 PaC apply 失败必须使用不同 blocker code;不得折叠为同一条未知错误; + - 失败输出只给 owning YAML 修正提示或 consumer 只读 `status` 下钻;不得给 mirror sync、人工 PipelineRun、Argo refresh 或其他 mutation 恢复命令; - PaC `apply` 在任何 Kubernetes 写入前读取 Gitea repository; - repository 缺失时返回 `gitea-repository-missing`,不留下半套 Tekton/RBAC/Argo 状态; - YAML 零匹配或多匹配时在本地拒绝,不猜测 repository; diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index d429837f..f91c62f1 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -221,11 +221,45 @@ payload = { }, "valuesPrinted": False, } + print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY } +run_mirror_preflight() { + python3 - "$tmp/repos.json" <<'PY' +import base64, json, os, subprocess, sys +repos = json.load(open(sys.argv[1], encoding="utf-8")) +rows = [] +timeout = int(os.environ.get("UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS", "20")) +for repo in repos: + credential = repo.get("githubCredential") or {} + token_env = credential.get("tokenEnv") or os.environ.get("UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV", "") + token = os.environ.get(token_env, "") + clone_url = repo["upstream"]["cloneUrl"] + branch = repo["upstream"]["branch"] + if not token: + rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": False, "code": "github-credential-unavailable", "valuesPrinted": False}) + continue + basic = base64.b64encode(("x-access-token:" + token).encode()).decode() + try: + completed = subprocess.run( + ["git", "-c", "http.extraHeader=Authorization: Basic " + basic, "ls-remote", "--exit-code", clone_url, "refs/heads/" + branch], + capture_output=True, + text=True, + timeout=timeout, + ) + error = " ".join(completed.stderr.split())[-800:] + rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": completed.returncode == 0, "code": "github-upstream-available" if completed.returncode == 0 else "github-repository-not-found-or-no-access", "exitCode": completed.returncode, "errorTail": error, "valuesPrinted": False}) + except subprocess.TimeoutExpired: + rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": False, "code": "github-upstream-timeout", "valuesPrinted": False}) +payload = {"ok": bool(rows) and all(row["ok"] for row in rows), "mutation": False, "repositories": rows, "valuesPrinted": False} +print(json.dumps(payload, ensure_ascii=False)) +sys.exit(0 if payload["ok"] else 1) +PY +} + run_status() { selector="app.kubernetes.io/name=$UNIDESK_GITEA_APP_NAME,app.kubernetes.io/component=gitea" capture_json namespace kubectl get namespace "$UNIDESK_GITEA_NAMESPACE" @@ -1125,6 +1159,7 @@ case "$UNIDESK_GITEA_ACTION" in apply) run_apply ;; status) run_status ;; validate) run_validate ;; + mirror-preflight) run_mirror_preflight ;; mirror-bootstrap) run_mirror_bootstrap ;; mirror-sync) run_mirror_sync ;; mirror-status) run_mirror_status ;; diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 5cfb4e25..b737b545 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -123,6 +123,39 @@ export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: }; } +export async function runGiteaMirrorBootstrapPreflight( + config: UniDeskConfig, + targetId: string, + repoKey: string, +): Promise> { + const gitea = readGiteaConfig(); + const target = resolveCommandTarget(gitea, targetId); + const repos = selectedRepositories(gitea, target, repoKey); + const credentials = credentialSummaries(gitea, repos); + const blockers = credentialBlockers(credentials); + if (blockers.length > 0) { + return { ok: false, mutation: false, code: "github-credential-unavailable", blockers, repositories: repos.map((repo) => repositorySummary(gitea, repo)), valuesPrinted: false }; + } + const secrets = mirrorPreflightSecrets(gitea, repos); + const result = await capture(config, target.route, ["sh"], remoteScript("mirror-preflight", gitea, target, "", { targetId, repoKey, confirm: false, dryRun: true, wait: false, full: false, raw: false }, { repos, secrets }).script); + const parsed = parseJsonOutput(result.stdout); + return parsed ?? { ok: false, mutation: false, code: "github-upstream-preflight-failed", remote: compactCapture(result, { full: true }), valuesPrinted: false }; +} + +function mirrorPreflightSecrets(gitea: GiteaConfig, repositories: GiteaMirrorRepository[]): MirrorSecrets { + const githubTokens: Record = {}; + for (const repo of repositories) { + const github = githubCredentialForRepo(gitea, repo); + const githubPath = credentialPath(gitea, github.sourceRef); + if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot probe GitHub upstream`); + const githubEnv = parseGithubCredentialFile(githubPath, github); + const githubToken = githubEnv[github.sourceKey]; + if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`); + githubTokens[github.gitFetchCredential.secretRef.key] = githubToken; + } + return { adminUsername: "", adminPassword: "", githubTokens, webhookSecret: "" }; +} + export function giteaHelp(scope?: string): Record { if (scope === "platform-bootstrap") { return { @@ -1116,7 +1149,7 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string { value: ${yamlQuote(value)}`).join("\n"); } -function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record } { +function remoteScript(action: "apply" | "status" | "validate" | "mirror-preflight" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record } { const frpcExposure = targetFrpcExposure(gitea, target); const sync = targetWebhookSync(gitea, target); const env: Record = { diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index bb8d7bd8..6a00d504 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -3,7 +3,7 @@ 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 { pacBootstrapYamlMatchFailure, projectPacBootstrapResult, renderPacBootstrap, resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; import { runPlatformInfraPipelinesAsCodeCommand } from "./platform-infra-pipelines-as-code"; const mirror: GiteaMirrorRepository = { @@ -78,3 +78,53 @@ test("platform bootstrap help advertises the composite entry before low-level ap expect(usage[0]).toContain("pipelines-as-code bootstrap"); expect(usage).toContain("bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --consumer --confirm"); }); + +test("PaC bootstrap projects typed stages and compact JSON", () => { + const result = { + ok: true, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: false, + mode: "dry-run", + target: { id: "NC01" }, + consumer: { id: "widgets", node: "NC01", lane: "v01" }, + repository: { id: "widgets", namespace: "ci" }, + mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name }, + githubPreflight: { ok: true, mutation: false, repositories: [{ key: mirror.key, code: "github-upstream-available", ok: true }] }, + giteaBootstrap: { ok: true, mutation: false, mode: "dry-run", blockers: [] }, + pipelinesAsCodeApply: { ok: true, mutation: false, mode: "dry-run", remote: { ok: true, preflight: { repositoryExists: true } } }, + }; + const projection = projectPacBootstrapResult(result); + expect(projection.ok).toBe(true); + expect(JSON.stringify(projection)).not.toContain("pipelinesAsCodeApply"); + expect((projection.stages as Record[]).map((item) => item.id)).toEqual(["gitea-init", "pac-controller", "tekton-consumer", "gitops-argo"]); + const rendered = renderPacBootstrap(result).renderedText; + expect(rendered).toContain("PRECONDITIONS"); + expect(rendered).toContain("yaml-repository-exact-match"); + expect(rendered).toContain("confirm:"); +}); + +test("PaC bootstrap returns typed YAML match failures without mutation", () => { + const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: 2 exact matches")); + expect(projectPacBootstrapResult(result)).toBe(result); + expect(result.ok).toBe(false); + expect(result.mutation).toBe(false); + expect((result.blockers as Record[])[0]?.code).toBe("yaml-repository-multiple-matches"); +}); + +test("PaC bootstrap distinguishes GitHub access failure from Gitea and PaC stages", () => { + const projection = projectPacBootstrapResult({ + ok: false, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: false, + mode: "dry-run", + target: { id: "NC01" }, + consumer: { id: "widgets", node: "NC01", lane: "v01" }, + repository: { id: "widgets", namespace: "ci" }, + mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name }, + githubPreflight: { ok: false, mutation: false, repositories: [{ key: mirror.key, code: "github-repository-not-found-or-no-access", errorTail: "repository unavailable" }] }, + giteaBootstrap: { ok: false, mutation: false, mode: "skipped", githubPreflight: { ok: false, repositories: [{ code: "github-repository-not-found-or-no-access", errorTail: "repository unavailable" }] } }, + pipelinesAsCodeApply: { ok: false, mutation: false, mode: "skipped" }, + }); + expect((projection.blockers as Record[])).toEqual([{ code: "github-repository-not-found-or-no-access", stage: "github-upstream", reason: "repository unavailable" }]); + expect((projection.next as Record).kind).toBe("read-only-status"); +}); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts index 29451758..053c0b05 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -1,4 +1,5 @@ import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config"; +import type { RenderedCliResult } from "./output"; export interface PacBootstrapRepositoryIdentity { readonly id: string; @@ -28,6 +29,178 @@ export function resolvePacBootstrapMirrorRepository( return candidates[0]; } +export function pacBootstrapYamlMatchFailure( + targetId: string, + consumerId: string, + error: unknown, +): Record { + const reason = error instanceof Error ? error.message : String(error); + const matchCount = reason.includes("no exact match") ? 0 : Number.parseInt(reason.match(/(\d+) exact matches/u)?.[1] ?? "-1", 10); + const status = `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumerId}`; + return { + ok: false, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: false, + mode: "precondition-failed", + target: { id: targetId }, + consumer: { id: consumerId }, + preconditions: [ + { id: "yaml-exact-match", ok: false, code: matchCount === 0 ? "yaml-repository-no-match" : "yaml-repository-multiple-matches", matchCount, detail: reason }, + ], + stages: [], + blockers: [{ code: matchCount === 0 ? "yaml-repository-no-match" : "yaml-repository-multiple-matches", stage: "yaml-exact-match", reason }], + next: { command: status, kind: "read-only-status" }, + valuesPrinted: false, + }; +} + +export function projectPacBootstrapResult(result: Record): Record { + if (Array.isArray(result.preconditions) && Array.isArray(result.stages) && Array.isArray(result.blockers)) return result; + const target = record(result.target); + const consumer = record(result.consumer); + const repository = record(result.repository); + const mirror = record(result.mirrorRepository); + const githubPreflight = record(result.githubPreflight); + const githubRepository = records(githubPreflight.repositories)[0] ?? {}; + const gitea = record(result.giteaBootstrap); + const apply = record(result.pipelinesAsCodeApply); + const remote = record(apply.remote); + const preflight = record(remote.preflight); + const blockers = bootstrapBlockers(githubPreflight, gitea, apply, remote); + const dryRun = result.mode === "dry-run"; + const repositoryMissing = remote.error === "gitea-repository-missing"; + const githubReady = githubPreflight.ok === true; + const giteaReady = gitea.ok === true; + const applyReady = apply.ok === true || (dryRun && repositoryMissing); + const next = bootstrapNext(result, blockers); + return { + ok: blockers.length === 0 && giteaReady && applyReady, + action: result.action, + mutation: result.mutation === true, + mode: result.mode, + target: { id: target.id }, + consumer: { id: consumer.id, node: consumer.node, lane: consumer.lane }, + sourceAuthority: { + upstreamRepository: mirror.upstreamRepository, + upstreamBranch: mirror.upstreamBranch, + github: { ok: githubReady, code: stringValue(githubRepository.code, githubReady ? "github-upstream-available" : "github-upstream-preflight-failed") }, + giteaRepository: `${stringValue(mirror.owner)}/${stringValue(mirror.repo)}`, + giteaKey: mirror.key, + valuesPrinted: false, + }, + preconditions: [ + { id: "yaml-exact-match", ok: true, code: "yaml-repository-exact-match", matchCount: 1 }, + { id: "github-upstream", ok: githubReady, code: stringValue(githubRepository.code, githubReady ? "github-upstream-available" : "github-upstream-preflight-failed") }, + { id: "gitea-repository", ok: giteaReady, code: giteaReady ? (dryRun ? "gitea-bootstrap-planned" : "gitea-bootstrap-ready") : "gitea-bootstrap-failed" }, + ], + stages: [ + { id: "gitea-init", ok: giteaReady, state: giteaReady ? (dryRun ? "planned" : "ready") : "failed", mutation: gitea.mutation === true }, + { id: "pac-controller", ok: applyReady, state: repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + { id: "tekton-consumer", ok: applyReady, state: repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + { id: "gitops-argo", ok: applyReady, state: repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + ], + repository: { id: repository.id, namespace: repository.namespace }, + blockers, + next, + valuesPrinted: false, + }; +} + +export function renderPacBootstrap(result: Record): RenderedCliResult { + const projection = projectPacBootstrapResult(result); + const target = record(projection.target); + const consumer = record(projection.consumer); + const source = record(projection.sourceAuthority); + const preconditions = records(projection.preconditions); + const stages = records(projection.stages); + const blockers = records(projection.blockers); + const next = record(projection.next); + const lines = [ + "PLATFORM-INFRA PAC / TEKTON / GITOPS BOOTSTRAP", + ...table(["TARGET", "CONSUMER", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(consumer.id), stringValue(projection.mode), boolText(projection.mutation), boolText(projection.ok)]]), + "", + "SOURCE AUTHORITY", + ...table(["UPSTREAM", "BRANCH", "GITEA_KEY", "GITEA_REPOSITORY"], [[stringValue(source.upstreamRepository), stringValue(source.upstreamBranch), stringValue(source.giteaKey), stringValue(source.giteaRepository)]]), + "", + "PRECONDITIONS", + ...table(["PRECONDITION", "OK", "CODE"], preconditions.map((item) => [stringValue(item.id), boolText(item.ok), stringValue(item.code)])), + "", + "STAGES", + ...table(["STAGE", "OK", "STATE", "MUTATION"], stages.map((item) => [stringValue(item.id), boolText(item.ok), stringValue(item.state), boolText(item.mutation)])), + ...(blockers.length === 0 ? [] : ["", "BLOCKERS", ...blockers.map((item) => ` ${stringValue(item.code)}: ${compactLine(stringValue(item.reason))}`)]), + "", + "NEXT", + ` ${stringValue(next.kind)}: ${stringValue(next.command)}`, + ]; + return { ok: projection.ok === true, command: "platform-infra pipelines-as-code bootstrap", renderedText: lines.join("\n"), contentType: "text/plain", projection }; +} + +function bootstrapBlockers(githubPreflight: Record, gitea: Record, apply: Record, remote: Record): Record[] { + const blockers: Record[] = []; + const githubRepository = records(githubPreflight.repositories)[0] ?? {}; + if (githubPreflight.ok === false) { + blockers.push({ code: stringValue(githubRepository.code, stringValue(githubPreflight.code, "github-upstream-preflight-failed")), stage: "github-upstream", reason: stringValue(githubRepository.errorTail, errorText(githubPreflight)) }); + return blockers; + } + for (const code of Array.isArray(gitea.blockers) ? gitea.blockers.map(String) : []) { + blockers.push({ code: code.includes("credential") ? "source-credential-unavailable" : "github-upstream-unavailable", stage: "github-upstream", reason: code }); + } + if (gitea.ok !== true && blockers.length === 0) blockers.push({ code: "gitea-bootstrap-failed", stage: "gitea-init", reason: errorText(gitea) }); + if (apply.ok !== true && remote.error !== "gitea-repository-missing") { + blockers.push({ code: remote.error === "gitea-credential-unavailable" ? "gitea-credential-unavailable" : "pac-apply-failed", stage: "pac-controller", reason: errorText(remote, apply) }); + } + return blockers; +} + +function bootstrapNext(result: Record, blockers: Record[]): Record { + const target = record(result.target); + const consumer = record(result.consumer); + const source = record(result.mirrorRepository); + if (blockers.length > 0) { + const yamlFailure = blockers.some((item) => String(item.code).startsWith("yaml-")); + return yamlFailure + ? { kind: "fix-yaml", command: "检查 config/platform-infra/gitea.yaml 与 config/platform-infra/pipelines-as-code.yaml 的 target、owner/name 和 read URL 精确匹配" } + : { kind: "read-only-status", command: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${target.id} --consumer ${consumer.id}` }; + } + if (result.mode === "dry-run") return { kind: "confirm", command: `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target ${target.id} --consumer ${consumer.id} --confirm` }; + return { kind: "source-pr-merge", command: `合并 ${source.upstreamRepository}@${source.upstreamBranch} 的 GitHub PR,由自动链继续交付` }; +} + +function errorText(...values: Record[]): string { + for (const value of values) { + for (const candidate of [value.error, value.stderrTail, record(value.remote).error, record(value.remote).stderrTail]) { + if (typeof candidate === "string" && candidate.length > 0) return candidate; + } + } + return "unknown-bootstrap-failure"; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function records(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record) : []; +} + +function stringValue(value: unknown, fallback = "-"): string { + return typeof value === "string" && value.length > 0 ? value : value === undefined || value === null ? fallback : String(value); +} + +function boolText(value: unknown): string { + return value === true ? "true" : value === false ? "false" : "-"; +} + +function compactLine(value: string): string { + return value.replace(/\s+/gu, " ").trim().slice(0, 240) || "-"; +} + +function table(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length))); + const format = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd(); + return [format(headers), format(widths.map((width) => "-".repeat(width))), ...rows.map(format)]; +} + function repositoryUrlIdentity(value: string): string { const parsed = new URL(value); const path = parsed.pathname.replace(/\/+$/u, ""); diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 6d7b6089..672b985a 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -28,9 +28,14 @@ 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 { runGiteaMirrorBootstrapPreflight, runPlatformInfraGiteaCommand } from "./platform-infra-gitea"; import { readGiteaConfig } from "./platform-infra-gitea-config"; -import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; +import { + pacBootstrapYamlMatchFailure, + projectPacBootstrapResult, + renderPacBootstrap, + 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"; @@ -255,7 +260,7 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf 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); + return options.full || options.raw ? result : options.json ? projectPacBootstrapResult(result) : renderPacBootstrap(result); } if (action === "status") { const options = parseCommonOptions(args.slice(1)); @@ -974,11 +979,20 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise< 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); + let mirror: ReturnType; + try { + mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository); + } catch (error) { + return pacBootstrapYamlMatchFailure(target.id, consumer.id, error); + } + const githubPreflight = record(await runGiteaMirrorBootstrapPreflight(config, target.id, mirror.key)); + if (githubPreflight.ok !== true) { + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, { ok: false, mutation: false, mode: "skipped", githubPreflight, valuesPrinted: false }, { ok: false, mutation: false, mode: "skipped", valuesPrinted: false }); + } 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, { + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, { ok: false, mutation: false, mode: "skipped", @@ -987,7 +1001,7 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise< }); } const pacApply = await apply(config, options); - return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, pacApply); + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, pacApply); } function bootstrapResult( @@ -996,6 +1010,7 @@ function bootstrapResult( repository: PacRepository, mirror: ReturnType, options: ApplyOptions, + githubPreflight: Record, giteaBootstrap: Record, pacApply: Record, ): Record { @@ -1020,6 +1035,7 @@ function bootstrapResult( repo: mirror.gitea.name, valuesPrinted: false, }, + githubPreflight, 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 }, @@ -2207,43 +2223,6 @@ 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); -} - export function renderStatus(result: Record): RenderedCliResult { const summary = record(result.summary); const consumer = record(result.consumer);