From 8e68f8a08f6b5790d30570938a8d175bae67f2da Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 14:21:33 +0200 Subject: [PATCH] fix: materialize Gitea bootstrap credentials --- .../R1.1_Task_Report.md | 11 ++ .../src/platform-infra-gitea-payload.test.ts | 73 +++++++++++ scripts/src/platform-infra-gitea-remote.sh | 123 +++++++++++------- scripts/src/platform-infra-gitea.ts | 6 +- 4 files changed, 168 insertions(+), 45 deletions(-) diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md index 98de4f55..06819b60 100644 --- a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md @@ -45,6 +45,17 @@ - 最终吸收 `origin/master@3aaa2616`。 - R1.2 保持 `[completed]`,`R1.2_Task_Report.md` 保留。 +## 本轮 bootstrap Secret 物化修复 + +- PR #1926 合并后的只读运行面证据显示,candidate Pod 因 `devops-infra/gitea-github-sync-secrets` 缺少 `github-token-pikainc-selfmedia` 而失败;live Secret 脱敏 key 列表只有全局 `github-token`、Gitea 管理员与 webhook secret。 +- 旧 Deployment/reconciler 仅引用全局 `github-token`;candidate desired manifest 已引用 repository key,因此故障发生在 YAML credential 选择之后、Secret 物化之前。 +- 根因确认:`run_mirror_bootstrap()` 读取 repository token 并创建 Gitea repo,但从未执行 `run_apply()` 内的 webhook Secret upsert。此前“bootstrap 已写入 key”的现场假设不成立。 +- 修复将 Secret upsert 抽为 `upsert_webhook_sync_secret()`,由 `run_apply()` 与 `run_mirror_bootstrap()` 复用。bootstrap 在任何 Gitea admin/repository mutation 前物化完整 Target/owner YAML credential key 集合;失败时显式 `return 1`,`apiBootstrap.skipped=true`、`valuesPrinted=false`。 +- bootstrap 的 Secret key map 由本次实际加载的 `githubTokens` keys 生成,selfmedia override 不回落或合并到全局 token,也不会删除同 Target/owner 的其他 YAML repository keys。Secret 值未进入 GitOps、Git、日志或报告。 +- 行为测试使用 fixture token 模拟全部 key 与 Secret apply 失败,不读取宿主 sourceRef;断言两个 GitHub key 均物化、失败时不发生后续 Gitea mutation。 +- 主代理按 `bun.lock` 执行 `bun install --frozen-lockfile`,未修改 package/lock;`sh -n`、`git diff --check` 与 payload/config/PaC bootstrap 三个测试文件共 14/14 通过。 +- 受控 webhook status 已可读取 `pikainc/selfmedia` master HEAD `6e6a95297c3c153ab269410a079a997adc345774`,先前 `github-api-get-404` 已消失;生产 Hook 尚未创建,R1.1 继续保持 `in_progress`。 + ## 未执行事项 - 未创建假 token,未复制 SSH 私钥,未修改仓库可见性。 diff --git a/scripts/src/platform-infra-gitea-payload.test.ts b/scripts/src/platform-infra-gitea-payload.test.ts index ebdd93ff..361f6309 100644 --- a/scripts/src/platform-infra-gitea-payload.test.ts +++ b/scripts/src/platform-infra-gitea-payload.test.ts @@ -7,6 +7,14 @@ import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } fr import { renderGiteaWebhookSyncDesiredManifest } from "./platform-infra-gitea"; const remoteScriptPath = resolve(import.meta.dir, "platform-infra-gitea-remote.sh"); +const orchestrationPath = resolve(import.meta.dir, "platform-infra-gitea.ts"); + +function functionSource(source: string, name: string, nextName: string): string { + const start = source.indexOf(`${name}() {`); + const end = source.indexOf(`\n${nextName}() {`, start); + if (start < 0 || end < 0) throw new Error(`missing shell function ${name}`); + return source.slice(start, end); +} test("stdin file envelope materializes a payload larger than the current Gitea manifest without argv or env", () => { const currentManifest = renderGiteaWebhookSyncDesiredManifest("NC01"); @@ -68,3 +76,68 @@ test("Gitea remote runner consumes materialized files instead of base64 environm source.lastIndexOf('timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts'), ); }); + +test("mirror bootstrap materializes every selected GitHub credential key before repository mutation", () => { + const source = readFileSync(remoteScriptPath, "utf8"); + const orchestration = readFileSync(orchestrationPath, "utf8"); + const upsert = functionSource(source, "upsert_webhook_sync_secret", "run_apply"); + const bootstrap = functionSource(source, "run_mirror_bootstrap", "run_mirror_sync"); + expect(bootstrap.indexOf("upsert_webhook_sync_secret")).toBeLessThan(bootstrap.indexOf('pod="$(kubectl')); + expect(bootstrap).toContain('"apiBootstrap": {"exitCode": None, "skipped": True}'); + expect(bootstrap).toContain("return 1"); + expect(orchestration).toContain("ensureMirrorSecrets(gitea, target, repositoriesForTarget(gitea, target), true, true, true)"); + expect(orchestration).toContain("Object.keys(params.secrets.githubTokens)"); + expect(orchestration).toContain("env.UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP"); + + const result = spawnSync("sh", ["-s"], { + encoding: "utf8", + input: [ + "set -u", + 'tmp="$(mktemp -d)"', + 'trap \'rm -rf "$tmp"\' EXIT', + 'export tmp UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED=1 UNIDESK_GITEA_DRY_RUN=0 UNIDESK_GITEA_NAMESPACE=devops-infra UNIDESK_GITEA_WEBHOOK_SECRET_NAME=gitea-github-sync-secrets UNIDESK_GITEA_FIELD_MANAGER=test-manager', + 'export UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP="github-token=GLOBAL_TOKEN,github-token-pikainc-selfmedia=SELFMEDIA_TOKEN"', + 'export GLOBAL_TOKEN=fixture-global SELFMEDIA_TOKEN=fixture-selfmedia UNIDESK_GITEA_ADMIN_USERNAME=fixture-admin UNIDESK_GITEA_ADMIN_PASSWORD=fixture-password UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET=fixture-webhook', + 'kubectl() { if [ "$1" = "-n" ] && [ "$3" = "create" ]; then printf "%s\\n" "apiVersion: v1" "kind: Secret"; printf "%s\\n" "$*" >>"$tmp/kubectl.log"; return 0; fi; if [ "$1" = "apply" ]; then cat >/dev/null; printf "%s\\n" "$*" >>"$tmp/kubectl.log"; return 0; fi; return 1; }', + upsert, + "upsert_webhook_sync_secret", + 'cat "$tmp/kubectl.log"', + ].join("\n"), + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("--from-file=github-token="); + expect(result.stdout).toContain("--from-file=github-token-pikainc-selfmedia="); + expect(result.stdout).not.toContain("fixture-global"); + expect(result.stdout).not.toContain("fixture-selfmedia"); + + const failed = spawnSync("sh", ["-s"], { + encoding: "utf8", + input: [ + "set -u", + 'tmp="$(mktemp -d)"', + 'trap \'rm -rf "$tmp"\' EXIT', + 'export tmp UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED=1 UNIDESK_GITEA_DRY_RUN=0 UNIDESK_GITEA_NAMESPACE=devops-infra UNIDESK_GITEA_WEBHOOK_SECRET_NAME=gitea-github-sync-secrets UNIDESK_GITEA_FIELD_MANAGER=test-manager UNIDESK_GITEA_TARGET_ID=NC01', + 'export UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP="github-token=GLOBAL_TOKEN,github-token-pikainc-selfmedia=SELFMEDIA_TOKEN"', + 'export GLOBAL_TOKEN=fixture-global SELFMEDIA_TOKEN=fixture-selfmedia UNIDESK_GITEA_ADMIN_USERNAME=fixture-admin UNIDESK_GITEA_ADMIN_PASSWORD=fixture-password UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET=fixture-webhook', + 'mirror_repos_json() { printf "%s\\n" "[]" >"$tmp/repos.json"; }', + 'kubectl() { if [ "$1" = "-n" ] && [ "$3" = "create" ]; then printf "%s\\n" "apiVersion: v1" "kind: Secret"; return 0; fi; if [ "$1" = "apply" ]; then cat >/dev/null; return 1; fi; touch "$tmp/unexpected-mutation"; return 1; }', + upsert, + bootstrap, + "run_mirror_bootstrap >\"$tmp/result.json\"", + 'rc=$?', + 'python3 - "$tmp/result.json" "$tmp/unexpected-mutation" "$rc" <<\'PY\'', + "import json, pathlib, sys", + "payload=json.load(open(sys.argv[1], encoding='utf-8'))", + "assert int(sys.argv[3]) == 1", + "assert payload['steps']['apiBootstrap']['skipped'] is True", + "assert payload['valuesPrinted'] is False", + "assert not pathlib.Path(sys.argv[2]).exists()", + "print('bootstrap-secret-fail-closed')", + "PY", + ].join("\n"), + }); + expect(failed.status).toBe(0); + expect(failed.stdout).toContain("bootstrap-secret-fail-closed"); + expect(failed.stdout).not.toContain("fixture-global"); + expect(failed.stdout).not.toContain("fixture-selfmedia"); +}); diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index 0d6c4685..d429837f 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -32,6 +32,49 @@ capture_json() { printf '%s' "$rc" >"$tmp/$name.rc" } +upsert_webhook_sync_secret() { + webhook_secret_rc=0 + : >"$tmp/webhook-secret.out" + : >"$tmp/webhook-secret.err" + if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" != "1" ]; then + printf '%s\n' "webhook sync disabled" >"$tmp/webhook-secret.err" + return 0 + fi + if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then + printf '%s\n' "dry-run: webhook sync secret apply skipped" >"$tmp/webhook-secret.err" + return 0 + fi + github_secret_args="" + old_ifs="$IFS" + IFS=',' + for mapping in $UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP; do + secret_key=${mapping%%=*} + env_name=${mapping#*=} + eval "secret_value=\${$env_name-}" + if [ -z "$secret_value" ]; then + printf '%s\n' "missing GitHub credential environment for Secret key $secret_key" >"$tmp/webhook-secret.err" + webhook_secret_rc=1 + break + fi + secret_file="$tmp/github-secret-$secret_key" + printf '%s' "$secret_value" >"$secret_file" + chmod 600 "$secret_file" + github_secret_args="$github_secret_args --from-file=$secret_key=$secret_file" + done + IFS="$old_ifs" + if [ "$webhook_secret_rc" -ne 0 ]; then + return "$webhook_secret_rc" + fi + kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \ + $github_secret_args \ + --from-literal=gitea-username="$UNIDESK_GITEA_ADMIN_USERNAME" \ + --from-literal=gitea-password="$UNIDESK_GITEA_ADMIN_PASSWORD" \ + --from-literal=github-webhook-secret="$UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET" \ + --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/webhook-secret.out" 2>"$tmp/webhook-secret.err" + webhook_secret_rc=$? + return "$webhook_secret_rc" +} + run_apply() { manifest="$tmp/gitea.k8s.yaml" if [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then @@ -51,43 +94,8 @@ run_apply() { printf '%s\n' "frpc disabled" >"$tmp/frpc-secret.err" fi fi - if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then - github_secret_args="" - old_ifs="$IFS" - IFS=',' - for mapping in $UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP; do - secret_key=${mapping%%=*} - env_name=${mapping#*=} - eval "secret_value=\${$env_name-}" - if [ -z "$secret_value" ]; then - printf '%s\n' "missing GitHub credential environment for Secret key $secret_key" >"$tmp/webhook-secret.err" - webhook_secret_rc=1 - break - fi - secret_file="$tmp/github-secret-$secret_key" - printf '%s' "$secret_value" >"$secret_file" - chmod 600 "$secret_file" - github_secret_args="$github_secret_args --from-file=$secret_key=$secret_file" - done - IFS="$old_ifs" - if [ "${webhook_secret_rc:-0}" -eq 0 ]; then - kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \ - $github_secret_args \ - --from-literal=gitea-username="$UNIDESK_GITEA_ADMIN_USERNAME" \ - --from-literal=gitea-password="$UNIDESK_GITEA_ADMIN_PASSWORD" \ - --from-literal=github-webhook-secret="$UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET" \ - --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/webhook-secret.out" 2>"$tmp/webhook-secret.err" - webhook_secret_rc=$? - fi - else - webhook_secret_rc=0 - : >"$tmp/webhook-secret.out" - if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ]; then - printf '%s\n' "dry-run: webhook sync secret apply skipped" >"$tmp/webhook-secret.err" - else - printf '%s\n' "webhook sync disabled" >"$tmp/webhook-secret.err" - fi - fi + upsert_webhook_sync_secret + webhook_secret_rc=$? timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts --dry-run=server \ --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/server-dry-run.out" 2>"$tmp/server-dry-run.err" server_dry_run_rc=$? @@ -409,6 +417,32 @@ PY run_mirror_bootstrap() { mirror_repos_json + upsert_webhook_sync_secret + webhook_secret_rc=$? + if [ "$webhook_secret_rc" -ne 0 ]; then + python3 - "$webhook_secret_rc" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" <<'PY' +import json, os, sys +def text(path, limit=1600): + try: + return open(path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" +payload = { + "ok": False, + "target": os.environ.get("UNIDESK_GITEA_TARGET_ID"), + "namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"), + "steps": { + "webhookSyncSecret": {"exitCode": int(sys.argv[1]), "stdoutTail": text(sys.argv[2]), "stderrTail": text(sys.argv[3])}, + "apiBootstrap": {"exitCode": None, "skipped": True}, + }, + "repositories": [], + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(1) +PY + return 1 + fi pod="$(kubectl -n "$UNIDESK_GITEA_NAMESPACE" get pod -l "app.kubernetes.io/name=$UNIDESK_GITEA_APP_NAME,app.kubernetes.io/component=gitea" -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/pod.err")" create_rc=1 change_rc=1 @@ -490,26 +524,27 @@ json.dump(payload, open(out_path, "w", encoding="utf-8"), ensure_ascii=False, in sys.exit(0 if ok else 1) PY api_rc=$? - python3 - "$create_rc" "$change_rc" "$unset_rc" "$api_rc" "$tmp/admin-create.out" "$tmp/admin-create.err" "$tmp/admin-pass.out" "$tmp/admin-pass.err" "$tmp/admin-unset.out" "$tmp/admin-unset.err" "$tmp/api.json" <<'PY' + python3 - "$webhook_secret_rc" "$create_rc" "$change_rc" "$unset_rc" "$api_rc" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/admin-create.out" "$tmp/admin-create.err" "$tmp/admin-pass.out" "$tmp/admin-pass.err" "$tmp/admin-unset.out" "$tmp/admin-unset.err" "$tmp/api.json" <<'PY' import json, sys def text(path, limit=1600): try: return open(path, encoding="utf-8", errors="replace").read()[-limit:] except FileNotFoundError: return "" -create_rc, change_rc, unset_rc, api_rc = map(int, sys.argv[1:5]) +webhook_secret_rc, create_rc, change_rc, unset_rc, api_rc = map(int, sys.argv[1:6]) try: - api = json.load(open(sys.argv[11], encoding="utf-8")) + api = json.load(open(sys.argv[14], encoding="utf-8")) except Exception: api = {} payload = { - "ok": create_rc == 0 and change_rc == 0 and unset_rc == 0 and api_rc == 0, + "ok": webhook_secret_rc == 0 and create_rc == 0 and change_rc == 0 and unset_rc == 0 and api_rc == 0, "target": __import__("os").environ.get("UNIDESK_GITEA_TARGET_ID"), "namespace": __import__("os").environ.get("UNIDESK_GITEA_NAMESPACE"), "steps": { - "adminCreate": {"exitCode": create_rc, "stdoutTail": text(sys.argv[5]), "stderrTail": text(sys.argv[6])}, - "adminPassword": {"exitCode": change_rc, "stdoutTail": text(sys.argv[7]), "stderrTail": text(sys.argv[8])}, - "adminMustChangePasswordUnset": {"exitCode": unset_rc, "stdoutTail": text(sys.argv[9]), "stderrTail": text(sys.argv[10])}, + "webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[6]), "stderrTail": text(sys.argv[7])}, + "adminCreate": {"exitCode": create_rc, "stdoutTail": text(sys.argv[8]), "stderrTail": text(sys.argv[9])}, + "adminPassword": {"exitCode": change_rc, "stdoutTail": text(sys.argv[10]), "stderrTail": text(sys.argv[11])}, + "adminMustChangePasswordUnset": {"exitCode": unset_rc, "stdoutTail": text(sys.argv[12]), "stderrTail": text(sys.argv[13])}, "apiBootstrap": {"exitCode": api_rc}, }, "api": api, diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 7b21bc97..0f01c99b 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -401,7 +401,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P }, }; } - const secrets = ensureMirrorSecrets(gitea, target, repos, false, true, true); + const secrets = ensureMirrorSecrets(gitea, target, repositoriesForTarget(gitea, target), true, 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); return { @@ -1164,6 +1164,10 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername; env.UNIDESK_GITEA_ADMIN_PASSWORD = params.secrets.adminPassword; for (const [key, token] of Object.entries(params.secrets.githubTokens)) env[githubTokenEnvName(key)] = token; + env.UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP = Object.keys(params.secrets.githubTokens) + .sort() + .map((key) => `${key}=${githubTokenEnvName(key)}`) + .join(","); env.UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET = params.secrets.webhookSecret; } const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n");