Merge pull request #1929 from pikasTech/feat/selfmedia-repo-github-authority
修复 Gitea bootstrap 仓库级凭据 Secret 物化
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user