Merge pull request #1929 from pikasTech/feat/selfmedia-repo-github-authority
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Failed
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

修复 Gitea bootstrap 仓库级凭据 Secret 物化
This commit is contained in:
Lyon
2026-07-13 20:31:55 +08:00
committed by GitHub
4 changed files with 168 additions and 45 deletions
@@ -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 私钥,未修改仓库可见性。
@@ -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");
});
+79 -44
View File
@@ -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,
+5 -1
View File
@@ -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");