diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 08767f6d..90f6bac9 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -4,10 +4,11 @@ kind: platform-infra-gitea metadata: id: gitea-internal-mirror owner: unidesk - spec: GH-1548 + spec: GH-1548/GH-1550 relatedIssues: - 1548 - 1549 + - 1550 defaults: targetId: JD01 @@ -20,6 +21,100 @@ migration: buildPlane: controlled-docker-or-buildkit-outside-runtime runtimePlane: k3s-gitea-service-zero-docker +sourceAuthority: + enabled: true + stage: gitea-controlled-mirror-source-authority + statusAuthority: gitea-repository-refs-plus-snapshot-ref + firstCiConsumer: agentrun-jd01-v02 + credentials: + sourceRoot: /root/unidesk + admin: + sourceRef: .env/gitea.auth + format: line-pair + usernameLine: 1 + passwordLine: 2 + requiredFor: + - repo-bootstrap + - mirror-sync + - snapshot-create + github: + transport: https-token + sourceRef: /root/.config/unidesk/github.env + sourceKey: GH_TOKEN + requiredFor: + - upstream-mirror + - mirror-sync + githubProxy: + enabled: true + url: http://127.0.0.1:10808 + noProxy: + - 127.0.0.1 + - localhost + - .svc + - .svc.cluster.local + responsibilities: + - name: source-read + current: legacy-git-mirror + target: gitea + disposition: replaced-by-gitea + - name: branch-observation + current: branch-follower + target: gitea-mirror + disposition: replaced-by-gitea + - name: immutable-snapshot-ref + current: legacy-git-mirror-cache + target: gitea-ref + disposition: replaced-by-gitea + - name: gitops-flush + current: legacy-git-mirror + target: github-direct-or-gitea-writeback + disposition: retained-for-gitops-flush + - name: legacy-status + current: branch-follower-status + target: gitea-mirror-status + disposition: migration-readonly + repositories: + - key: agentrun-jd01-v02 + targetId: JD01 + upstream: + repository: pikasTech/agentrun + cloneUrl: https://github.com/pikasTech/agentrun.git + branch: v0.2 + gitea: + owner: mirrors + name: pikasTech-agentrun + mirrorMode: controlled-push + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git + gitops: + branch: jd01-v0.2-gitops + flushDisposition: retained-for-gitops-flush + snapshot: + prefix: refs/unidesk/snapshots/gitea-actions/agentrun-v0.2 + legacyGitMirror: + readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git + configRef: config/cicd-branch-followers.yaml#followers.agentrun-jd01-v02.nativeStatus.source.gitMirrorReadUrl + disposition: replaced-by-gitea + - key: unidesk-master + targetId: JD01 + upstream: + repository: pikasTech/unidesk + cloneUrl: https://github.com/pikasTech/unidesk.git + branch: master + gitea: + owner: mirrors + name: pikasTech-unidesk + mirrorMode: controlled-push + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git + gitops: + branch: master + flushDisposition: not-a-gitops-branch + snapshot: + prefix: refs/unidesk/snapshots/gitea-actions/unidesk-master + legacyGitMirror: + readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + configRef: config/cicd-branch-followers.yaml#controller.source.gitMirrorReadUrl + disposition: replaced-by-gitea + targets: - id: JD01 route: JD01:k3s @@ -43,11 +138,37 @@ app: httpPort: 3000 sshPort: 2222 server: - domain: gitea-http.devops-infra.svc.cluster.local - rootUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/ + domain: gitea.pikapython.com + rootUrl: https://gitea.pikapython.com/ sshDomain: gitea-http.devops-infra.svc.cluster.local protocol: http startSshServer: true + publicExposure: + enabled: true + publicBaseUrl: https://gitea.pikapython.com + secretRoot: /root/unidesk/.state/secrets + dns: + hostname: gitea.pikapython.com + expectedA: 82.156.23.220 + resolvers: [1.1.1.1, 8.8.8.8, 223.5.5.5, 114.114.114.114] + frpc: + deploymentName: gitea-frpc + secretName: gitea-frpc-secrets + secretKey: frpc.toml + image: fatedier/frpc:v0.68.1 + serverAddr: 82.156.23.220 + serverPort: 22000 + proxyName: platform-infra-gitea-jd01-web + remotePort: 22080 + localIP: gitea-http.devops-infra.svc.cluster.local + localPort: 3000 + tokenSourceRef: platform-infra/pk01-frp.env + tokenSourceKey: FRP_TOKEN + pk01: + route: PK01 + caddyConfigPath: /etc/caddy/Caddyfile + caddyServiceName: caddy + responseHeaderTimeoutSeconds: 600 database: type: sqlite3 path: /var/lib/gitea/gitea.db diff --git a/scripts/src/help.ts b/scripts/src/help.ts index d1832530..92ca454b 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -693,6 +693,9 @@ function platformInfraHelpSummary(): unknown { "bun scripts/cli.ts platform-infra gitea apply --target JD01 --confirm", "bun scripts/cli.ts platform-infra gitea status --target JD01", "bun scripts/cli.ts platform-infra gitea validate --target JD01", + "bun scripts/cli.ts platform-infra gitea mirror plan --target JD01", + "bun scripts/cli.ts platform-infra gitea mirror sync --target JD01 --confirm", + "bun scripts/cli.ts platform-infra gitea mirror status --target JD01", ], description: "Operate platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, WeChat archive workflows, the YAML-controlled Codex pool, and internal Gitea for the GH-1548/GH-1549 CI/CD migration.", }; diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index 29974a4a..4689e552 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -2,6 +2,7 @@ set -u tmp="$(mktemp -d)" +export tmp trap 'rm -rf "$tmp"' EXIT json_tail() { @@ -29,12 +30,33 @@ capture_json() { run_apply() { manifest="$tmp/gitea.k8s.yaml" printf '%s' "$UNIDESK_GITEA_MANIFEST_B64" | base64 -d >"$manifest" + if [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then + printf '%s' "$UNIDESK_GITEA_FRPC_TOML_B64" | base64 -d >"$tmp/frpc.toml" + kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_FRPC_SECRET_NAME" \ + --from-file="$UNIDESK_GITEA_FRPC_SECRET_KEY=$tmp/frpc.toml" \ + --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/frpc-secret.out" 2>"$tmp/frpc-secret.err" + frpc_rc=$? + else + frpc_rc=0 + : >"$tmp/frpc-secret.out" + if [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ]; then + printf '%s\n' "dry-run: frpc secret apply skipped" >"$tmp/frpc-secret.err" + else + printf '%s\n' "frpc disabled" >"$tmp/frpc-secret.err" + fi + fi dry_arg="" if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then dry_arg="--dry-run=server" fi - timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side $dry_arg --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err" - apply_rc=$? + if [ "$frpc_rc" -eq 0 ]; then + timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side $dry_arg --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err" + apply_rc=$? + else + apply_rc=1 + : >"$tmp/apply.out" + printf '%s\n' "frpc secret apply failed; manifest apply skipped" >"$tmp/apply.err" + fi if [ "$apply_rc" -eq 0 ] && [ "$UNIDESK_GITEA_WAIT" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl -n "$UNIDESK_GITEA_NAMESPACE" rollout status "statefulset/$UNIDESK_GITEA_STATEFULSET_NAME" --timeout="${UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS}s" >"$tmp/rollout.out" 2>"$tmp/rollout.err" rollout_rc=$? @@ -47,9 +69,9 @@ run_apply() { printf '%s\n' "rollout wait not requested" >"$tmp/rollout.err" fi fi - python3 - "$apply_rc" "$rollout_rc" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" <<'PY' + python3 - "$frpc_rc" "$apply_rc" "$rollout_rc" "$tmp/frpc-secret.out" "$tmp/frpc-secret.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" <<'PY' import json, os, sys -apply_rc, rollout_rc = int(sys.argv[1]), int(sys.argv[2]) +frpc_rc, apply_rc, rollout_rc = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3]) def text(path, limit=3000): try: return open(path, encoding="utf-8", errors="replace").read()[-limit:] @@ -57,7 +79,7 @@ def text(path, limit=3000): return "" dry = os.environ.get("UNIDESK_GITEA_DRY_RUN") == "1" payload = { - "ok": apply_rc == 0 and rollout_rc == 0, + "ok": frpc_rc == 0 and apply_rc == 0 and rollout_rc == 0, "target": os.environ.get("UNIDESK_GITEA_TARGET_ID"), "route": os.environ.get("UNIDESK_GITEA_ROUTE"), "namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"), @@ -70,8 +92,9 @@ payload = { "networkPolicy": "allow-all", }, "steps": { - "apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[3]), "stderrTail": text(sys.argv[4])}, - "rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[5]), "stderrTail": text(sys.argv[6])}, + "frpcSecret": {"exitCode": frpc_rc, "stdoutTail": text(sys.argv[4]), "stderrTail": text(sys.argv[5])}, + "apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[6]), "stderrTail": text(sys.argv[7])}, + "rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[8]), "stderrTail": text(sys.argv[9])}, }, "valuesPrinted": False, } @@ -238,9 +261,313 @@ sys.exit(0 if ok else 1) PY } +service_base_url() { + kubectl -n "$UNIDESK_GITEA_NAMESPACE" get svc "$UNIDESK_GITEA_SERVICE_NAME" -o json >"$tmp/gitea-svc.json" 2>"$tmp/gitea-svc.err" + python3 - "$tmp/gitea-svc.json" "$UNIDESK_GITEA_HTTP_PORT" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +print(f"http://{data['spec']['clusterIP']}:{sys.argv[2]}") +PY +} + +mirror_repos_json() { + printf '%s' "$UNIDESK_GITEA_REPOS_B64" | base64 -d >"$tmp/repos.json" +} + +admin_basic_b64() { + python3 - <<'PY' +import base64, os +login = os.environ["UNIDESK_GITEA_ADMIN_USERNAME"] +username = login.split("@", 1)[0] if "@" in login else login +raw = f"{username}:{os.environ['UNIDESK_GITEA_ADMIN_PASSWORD']}".encode() +print(base64.b64encode(raw).decode()) +PY +} + +admin_cli_username() { + python3 - <<'PY' +import os +login = os.environ["UNIDESK_GITEA_ADMIN_USERNAME"] +print(login.split("@", 1)[0] if "@" in login else login) +PY +} + +run_mirror_bootstrap() { + mirror_repos_json + 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 + unset_rc=1 + if [ -n "$pod" ]; then + admin_username="$(admin_cli_username)" + admin_email="$UNIDESK_GITEA_ADMIN_USERNAME" + case "$admin_email" in + *@*) ;; + *) admin_email="$admin_username@local" ;; + esac + kubectl -n "$UNIDESK_GITEA_NAMESPACE" exec "$pod" -- gitea --work-path /var/lib/gitea --config /etc/gitea/app.ini admin user create --username "$admin_username" --password "$UNIDESK_GITEA_ADMIN_PASSWORD" --email "$admin_email" --admin --must-change-password=false >"$tmp/admin-create.out" 2>"$tmp/admin-create.err" + create_rc=$? + if [ "$create_rc" -ne 0 ] && grep -qi 'user already exists' "$tmp/admin-create.err"; then + create_rc=0 + fi + kubectl -n "$UNIDESK_GITEA_NAMESPACE" exec "$pod" -- gitea --work-path /var/lib/gitea --config /etc/gitea/app.ini admin user change-password --username "$admin_username" --password "$UNIDESK_GITEA_ADMIN_PASSWORD" --must-change-password=false >"$tmp/admin-pass.out" 2>"$tmp/admin-pass.err" + change_rc=$? + kubectl -n "$UNIDESK_GITEA_NAMESPACE" exec "$pod" -- gitea --work-path /var/lib/gitea --config /etc/gitea/app.ini admin user must-change-password --unset "$admin_username" >"$tmp/admin-unset.out" 2>"$tmp/admin-unset.err" + unset_rc=$? + else + printf '%s\n' "gitea pod not found" >"$tmp/admin-create.err" + : >"$tmp/admin-create.out" + : >"$tmp/admin-pass.out" + : >"$tmp/admin-pass.err" + : >"$tmp/admin-unset.out" + : >"$tmp/admin-unset.err" + fi + + base_url="$(service_base_url)" + basic="$(admin_basic_b64)" + python3 - "$base_url" "$basic" "$tmp/repos.json" "$tmp/api.json" <<'PY' +import json, sys, traceback, urllib.error, urllib.request +base_url, basic, repos_path, out_path = sys.argv[1:5] +def request(method, path, payload=None, expected=(200, 201, 204), tolerate=()): + data = None if payload is None else json.dumps(payload).encode() + req = urllib.request.Request(base_url + path, data=data, method=method) + req.add_header("Authorization", "Basic " + basic) + if payload is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=20) as resp: + body = resp.read().decode("utf-8", errors="replace") + return {"ok": resp.status in expected, "status": resp.status, "body": body[:1000]} + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + return {"ok": exc.code in tolerate, "status": exc.code, "body": body[:1000]} +try: + repos = json.load(open(repos_path, encoding="utf-8")) + orgs = {} + repositories = [] + auth = request("GET", "/api/v1/user") + for repo in repos: + owner = repo["gitea"]["owner"] + if owner not in orgs: + orgs[owner] = request("POST", "/api/v1/orgs", {"username": owner, "full_name": "UniDesk internal mirrors", "visibility": "private"}, expected=(201,), tolerate=(409, 422)) + repositories.append({ + "key": repo["key"], + "owner": owner, + "name": repo["gitea"]["name"], + "create": request("POST", f"/api/v1/orgs/{owner}/repos", {"name": repo["gitea"]["name"], "private": False, "auto_init": False, "description": f"UniDesk controlled mirror for {repo['upstream']['repository']}"}, expected=(201,), tolerate=(409, 422)), + }) + payload = {"auth": auth, "orgs": orgs, "repositories": repositories} + ok = auth.get("ok") and all(v.get("ok") for v in orgs.values()) and all(item["create"].get("ok") for item in repositories) +except Exception as exc: + payload = { + "auth": {"ok": False}, + "orgs": {}, + "repositories": [], + "error": str(exc), + "traceTail": traceback.format_exc()[-1600:], + } + ok = False +json.dump(payload, open(out_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2) +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' +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]) +try: + api = json.load(open(sys.argv[11], encoding="utf-8")) +except Exception: + api = {} +payload = { + "ok": 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])}, + "apiBootstrap": {"exitCode": api_rc}, + }, + "api": api, + "repositories": api.get("repositories", []), + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if payload["ok"] else 1) +PY +} + +run_mirror_sync() { + mirror_repos_json + base_url="$(service_base_url)" + basic="$(admin_basic_b64)" + python3 - "$tmp/repos.json" "$tmp/sync-plan.sh" "$tmp/repo-meta.json" "$base_url" <<'PY' +import json, shlex, sys, urllib.parse +repos = json.load(open(sys.argv[1], encoding="utf-8")) +base_url = sys.argv[4].rstrip("/") +script = [] +meta = [] +for i, repo in enumerate(repos): + key = repo["key"] + source_branch = repo["upstream"]["branch"] + gitops_branch = repo["gitops"]["branch"] + branches = [source_branch] + ([] if gitops_branch == source_branch or gitops_branch == "not-a-gitops-branch" else [gitops_branch]) + work = f"$tmp/repo-{i}.git" + gitea_write_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path + script += [ + f"mkdir -p {shlex.quote(work)}", + f"git init --bare {shlex.quote(work)} >$tmp/{key}.init.out 2>$tmp/{key}.init.err; printf '%s' \"$?\" >$tmp/{key}.init.rc", + f"git -C {shlex.quote(work)} -c http.extraHeader=\"$GITHUB_AUTH_HEADER\" fetch --prune {shlex.quote(repo['upstream']['cloneUrl'])} " + " ".join(shlex.quote(f"+refs/heads/{b}:refs/remotes/github/{b}") for b in branches) + f" >$tmp/{key}.fetch.out 2>$tmp/{key}.fetch.err; fetch_rc=$?; printf '%s' \"$fetch_rc\" >$tmp/{key}.fetch.rc", + f"if [ \"$fetch_rc\" -eq 0 ]; then sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(source_branch)} 2>$tmp/{key}.revparse.err); revparse_rc=$?; printf '%s\\n' \"$sha\" >$tmp/{key}.revparse.out; else sha=''; revparse_rc=1; : >$tmp/{key}.revparse.out; printf '%s\\n' 'fetch failed; revparse skipped' >$tmp/{key}.revparse.err; fi; printf '%s' \"$revparse_rc\" >$tmp/{key}.revparse.rc", + f"if [ \"$revparse_rc\" -eq 0 ]; then snapshot_ref={shlex.quote(repo['snapshot']['prefix'])}/$sha; git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $sha:refs/heads/{shlex.quote(source_branch)} $sha:$snapshot_ref >$tmp/{key}.push.out 2>$tmp/{key}.push.err; push_rc=$?; else snapshot_ref=''; push_rc=1; : >$tmp/{key}.push.out; printf '%s\\n' 'revparse failed; push skipped' >$tmp/{key}.push.err; fi; printf '%s' \"$push_rc\" >$tmp/{key}.push.rc", + ] + if gitops_branch != source_branch and gitops_branch != "not-a-gitops-branch": + script.append(f"if [ \"$fetch_rc\" -eq 0 ]; then gitops_sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(gitops_branch)} 2>/dev/null || true); else gitops_sha=''; fi") + script.append(f"if [ -n \"$gitops_sha\" ] && [ \"$push_rc\" -eq 0 ]; then git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $gitops_sha:refs/heads/{shlex.quote(gitops_branch)} >>$tmp/{key}.push.out 2>>$tmp/{key}.push.err; gitops_push_rc=$?; else gitops_push_rc=0; fi; printf '%s' \"$gitops_push_rc\" >$tmp/{key}.gitops-push.rc") + else: + script.append(f"printf '%s' '0' >$tmp/{key}.gitops-push.rc") + script.append(f"if [ \"$push_rc\" -eq 0 ]; then printf '%s %s\\n' \"$sha\" \"$snapshot_ref\" >$tmp/{key}.bundle; fi") + meta.append({"key": key, "sourceBranch": source_branch, "gitopsBranch": gitops_branch, "readUrl": repo["gitea"]["readUrl"], "writeUrlHost": urllib.parse.urlparse(gitea_write_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": repo["legacyGitMirror"]["readUrl"]}) +open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n") +json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False, indent=2) +PY + github_basic="$(python3 - <<'PY' +import base64, os +raw = f"x-access-token:{os.environ['UNIDESK_GITEA_GITHUB_TOKEN']}".encode() +print(base64.b64encode(raw).decode()) +PY +)" + GITHUB_AUTH_HEADER="Authorization: Basic $github_basic" + GITEA_AUTH_HEADER="Authorization: Basic $basic" + export GITHUB_AUTH_HEADER GITEA_AUTH_HEADER + if [ "$UNIDESK_GITEA_GITHUB_PROXY_ENABLED" = "1" ]; then + export https_proxy="$UNIDESK_GITEA_GITHUB_PROXY_URL" + export http_proxy="$UNIDESK_GITEA_GITHUB_PROXY_URL" + export no_proxy="$UNIDESK_GITEA_NO_PROXY" + fi + sh "$tmp/sync-plan.sh" >"$tmp/sync.out" 2>"$tmp/sync.err" + sync_rc=$? + python3 - "$sync_rc" "$tmp/repo-meta.json" "$tmp" <<'PY' +import json, os, sys +sync_rc = int(sys.argv[1]) +meta = json.load(open(sys.argv[2], encoding="utf-8")) +tmp = sys.argv[3] +def text(path, limit=1200): + try: + return open(path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" +def rc(path): + try: + return int(open(path, encoding="utf-8").read() or "1") + except Exception: + return 1 +repos = [] +bundles = [] +for repo in meta: + bundle_path = os.path.join(tmp, f"{repo['key']}.bundle") + source_commit = None + snapshot_ref = None + try: + parts = open(bundle_path, encoding="utf-8").read().strip().split() + source_commit, snapshot_ref = parts[0], parts[1] + except Exception: + pass + repos.append({ + **repo, + "sourceCommit": source_commit, + "snapshotRef": snapshot_ref, + "syncOk": source_commit is not None and snapshot_ref is not None and rc(os.path.join(tmp, f"{repo['key']}.gitops-push.rc")) == 0, + "initRc": rc(os.path.join(tmp, f"{repo['key']}.init.rc")), + "fetchRc": rc(os.path.join(tmp, f"{repo['key']}.fetch.rc")), + "revparseRc": rc(os.path.join(tmp, f"{repo['key']}.revparse.rc")), + "pushRc": rc(os.path.join(tmp, f"{repo['key']}.push.rc")), + "gitopsPushRc": rc(os.path.join(tmp, f"{repo['key']}.gitops-push.rc")), + "fetchStdoutTail": text(os.path.join(tmp, f"{repo['key']}.fetch.out")), + "fetchTail": text(os.path.join(tmp, f"{repo['key']}.fetch.err")), + "pushStdoutTail": text(os.path.join(tmp, f"{repo['key']}.push.out")), + "pushTail": text(os.path.join(tmp, f"{repo['key']}.push.err")), + "revparseTail": text(os.path.join(tmp, f"{repo['key']}.revparse.err")), + }) + if source_commit and snapshot_ref: + bundles.append({"key": repo["key"], "sourceCommit": source_commit, "snapshotRef": snapshot_ref, "readUrl": repo["readUrl"], "sourceAuthority": "gitea"}) +payload = {"ok": sync_rc == 0 and all(r["syncOk"] for r in repos), "repositories": repos, "sourceBundles": bundles, "valuesPrinted": False} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if payload["ok"] else 1) +PY +} + +run_mirror_status() { + mirror_repos_json + base_url="$(service_base_url)" + basic="$(admin_basic_b64)" + python3 - "$tmp/repos.json" "$tmp/status-plan.sh" "$tmp/repo-meta.json" "$base_url" <<'PY' +import json, shlex, sys, urllib.parse +repos = json.load(open(sys.argv[1], encoding="utf-8")) +base_url = sys.argv[4].rstrip("/") +script = [] +meta = [] +for repo in repos: + key = repo["key"] + branch = repo["upstream"]["branch"] + gitops = repo["gitops"]["branch"] + gitea_probe_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path + script.append(f"git -c http.extraHeader=\"$GITEA_AUTH_HEADER\" ls-remote {shlex.quote(gitea_probe_url)} refs/heads/{shlex.quote(branch)} '{shlex.quote(repo['snapshot']['prefix'])}/*' refs/heads/{shlex.quote(gitops)} >$tmp/{key}.ls.out 2>$tmp/{key}.ls.err") + meta.append({"key": key, "branch": branch, "gitopsBranch": gitops, "readUrl": repo["gitea"]["readUrl"], "probeUrlHost": urllib.parse.urlparse(gitea_probe_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": repo["legacyGitMirror"]["readUrl"], "legacyDisposition": repo["legacyGitMirror"]["disposition"]}) +open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n") +json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False) +PY + GITEA_AUTH_HEADER="Authorization: Basic $basic" + export GITEA_AUTH_HEADER + sh "$tmp/status-plan.sh" >"$tmp/status.out" 2>"$tmp/status.err" + status_rc=$? + python3 - "$status_rc" "$tmp/repo-meta.json" "$tmp" <<'PY' +import json, os, sys +status_rc = int(sys.argv[1]) +meta = json.load(open(sys.argv[2], encoding="utf-8")) +tmp = sys.argv[3] +def text(path, limit=1200): + try: + return open(path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" +repos = [] +for repo in meta: + out = text(os.path.join(tmp, f"{repo['key']}.ls.out"), 20000) + refs = {} + for line in out.splitlines(): + parts = line.split() + if len(parts) == 2: + refs[parts[1]] = parts[0] + branch_ref = f"refs/heads/{repo['branch']}" + snapshots = sorted((ref, sha) for ref, sha in refs.items() if ref.startswith(repo["snapshotPrefix"] + "/")) + latest_snapshot = snapshots[-1] if snapshots else None + repos.append({ + **repo, + "branchCommit": refs.get(branch_ref), + "snapshotRef": latest_snapshot[0] if latest_snapshot else None, + "snapshotCommit": latest_snapshot[1] if latest_snapshot else None, + "sourceBundleReady": bool(refs.get(branch_ref) and latest_snapshot), + "mirrorState": "ready" if refs.get(branch_ref) and latest_snapshot else "missing-ref", + "errorTail": text(os.path.join(tmp, f"{repo['key']}.ls.err")), + }) +payload = {"ok": status_rc == 0 and all(r["sourceBundleReady"] for r in repos), "repositories": repos, "valuesPrinted": False} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if payload["ok"] else 1) +PY +} + case "$UNIDESK_GITEA_ACTION" in apply) run_apply ;; status) run_status ;; validate) run_validate ;; + mirror-bootstrap) run_mirror_bootstrap ;; + mirror-sync) run_mirror_sync ;; + mirror-status) run_mirror_status ;; *) printf '{"ok":false,"error":"unsupported-gitea-remote-action"}\n'; exit 2 ;; esac diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index e519d2e6..7d224591 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -1,4 +1,6 @@ -import { readFileSync } from "node:fs"; +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import type { RenderedCliResult } from "./output"; @@ -10,6 +12,9 @@ import { readYamlRecord, shQuote, } from "./platform-infra-ops-library"; +import { applyPk01CaddyBlock, prepareFrpcSecret, renderFrpcManifest } from "./platform-infra-public-service"; +import type { PublicServiceExposure, PublicServiceTarget, FrpcSecretMaterial } from "./platform-infra-public-service"; +import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets"; const configFile = rootPath("config", "platform-infra", "gitea.yaml"); const configLabel = "config/platform-infra/gitea.yaml"; @@ -47,6 +52,24 @@ interface GiteaConfig { buildPlane: string; runtimePlane: string; }; + sourceAuthority: { + enabled: boolean; + stage: string; + statusAuthority: string; + firstCiConsumer: string; + credentials: { + sourceRoot: string; + admin: GiteaAdminCredential; + github: GiteaGithubCredential; + }; + githubProxy: { + enabled: boolean; + url: string; + noProxy: string[]; + }; + responsibilities: GiteaSourceResponsibility[]; + repositories: GiteaMirrorRepository[]; + }; targets: GiteaTarget[]; app: { name: string; @@ -70,6 +93,7 @@ interface GiteaConfig { protocol: "http" | "https"; startSshServer: boolean; }; + publicExposure: PublicServiceExposure & { secretRoot: string }; database: { type: "sqlite3"; path: string; @@ -107,6 +131,56 @@ interface GiteaConfig { }; } +interface GiteaAdminCredential { + sourceRef: string; + format: "line-pair"; + usernameLine: number; + passwordLine: number; + requiredFor: string[]; +} + +interface GiteaGithubCredential { + transport: "https-token"; + sourceRef: string; + sourceKey: string; + requiredFor: string[]; +} + +interface GiteaSourceResponsibility { + name: string; + current: string; + target: string; + disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly"; +} + +interface GiteaMirrorRepository { + key: string; + targetId: string; + upstream: { + repository: string; + cloneUrl: string; + branch: string; + }; + gitea: { + owner: string; + name: string; + mirrorMode: "controlled-push"; + readUrl: string; + }; + gitops: { + branch: string; + flushDisposition: string; + }; + snapshot: { + prefix: string; + }; + legacyGitMirror: { + readUrl: string; + configRef: string; + disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly"; + }; +} + interface CommonOptions { targetId: string | null; full: boolean; @@ -119,6 +193,23 @@ interface ApplyOptions extends CommonOptions { wait: boolean; } +interface MirrorOptions extends CommonOptions { + confirm: boolean; + repoKey: string | null; +} + +interface MirrorSecrets { + adminUsername: string; + adminPassword: string; + githubToken: string; +} + +interface MirrorRemoteParams { + repos?: GiteaMirrorRepository[]; + secrets?: MirrorSecrets; + frpcSecret?: FrpcSecretMaterial; +} + export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [action = "plan"] = args; if (action === "help" || action === "--help") return giteaHelp(); @@ -138,6 +229,7 @@ export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: return options.full || options.raw ? result : renderStatus(result); } if (action === "validate") return await validate(config, parseCommonOptions(args.slice(1))); + if (action === "mirror") return await mirrorCommand(config, args.slice(1)); return { ok: false, error: "unsupported-platform-infra-gitea-command", @@ -148,7 +240,7 @@ export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: export function giteaHelp(): Record { return { - command: "platform-infra gitea plan|apply|status|validate", + command: "platform-infra gitea plan|apply|status|validate|mirror", configTruth: configLabel, usage: [ "bun scripts/cli.ts platform-infra gitea plan --target JD01", @@ -156,8 +248,16 @@ export function giteaHelp(): Record { "bun scripts/cli.ts platform-infra gitea apply --target JD01 --confirm", "bun scripts/cli.ts platform-infra gitea status --target JD01 [--full|--raw]", "bun scripts/cli.ts platform-infra gitea validate --target JD01 [--full|--raw]", + "bun scripts/cli.ts platform-infra gitea mirror plan --target JD01", + "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target JD01 --confirm", + "bun scripts/cli.ts platform-infra gitea mirror sync --target JD01 --confirm", + "bun scripts/cli.ts platform-infra gitea mirror status --target JD01 [--full|--raw]", + "bun scripts/cli.ts platform-infra gitea mirror plan --target JD01", + "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target JD01 --confirm", + "bun scripts/cli.ts platform-infra gitea mirror sync --target JD01 --confirm", + "bun scripts/cli.ts platform-infra gitea mirror status --target JD01 [--full|--raw]", ], - boundary: "Gitea is installed as an internal ClusterIP source-authority service for GH-1548/GH-1549; runner registration and repository mirror bootstrap are later controlled stages.", + boundary: "Gitea is installed as an internal ClusterIP source-authority service for GH-1548/GH-1549/GH-1550; mirror source-authority takeover precedes Actions/CI integration.", }; } @@ -168,6 +268,9 @@ function readGiteaConfig(): GiteaConfig { const metadata = y.objectField(root, "metadata", ""); const defaults = y.objectField(root, "defaults", ""); const migration = y.objectField(root, "migration", ""); + const sourceAuthority = y.objectField(root, "sourceAuthority", ""); + const sourceCredentials = y.objectField(sourceAuthority, "credentials", "sourceAuthority"); + const githubProxy = y.objectField(sourceAuthority, "githubProxy", "sourceAuthority"); const app = y.objectField(root, "app", ""); const image = y.objectField(app, "image", "app"); const service = y.objectField(app, "service", "app"); @@ -178,6 +281,7 @@ function readGiteaConfig(): GiteaConfig { const storage = y.objectField(app, "storage", "app"); const dataStorage = y.objectField(storage, "data", "app.storage"); const configStorage = y.objectField(storage, "config", "app.storage"); + const publicExposure = y.objectField(app, "publicExposure", "app"); const securityContext = y.objectField(app, "securityContext", "app"); const resources = y.objectField(app, "resources", "app"); const requests = y.objectField(resources, "requests", "app.resources"); @@ -204,6 +308,24 @@ function readGiteaConfig(): GiteaConfig { buildPlane: y.stringField(migration, "buildPlane", "migration"), runtimePlane: y.stringField(migration, "runtimePlane", "migration"), }, + sourceAuthority: { + enabled: y.booleanField(sourceAuthority, "enabled", "sourceAuthority"), + stage: y.stringField(sourceAuthority, "stage", "sourceAuthority"), + statusAuthority: y.stringField(sourceAuthority, "statusAuthority", "sourceAuthority"), + firstCiConsumer: y.stringField(sourceAuthority, "firstCiConsumer", "sourceAuthority"), + credentials: { + sourceRoot: y.absolutePathField(sourceCredentials, "sourceRoot", "sourceAuthority.credentials"), + admin: parseAdminCredential(y.objectField(sourceCredentials, "admin", "sourceAuthority.credentials"), "sourceAuthority.credentials.admin"), + github: parseGithubCredential(y.objectField(sourceCredentials, "github", "sourceAuthority.credentials"), "sourceAuthority.credentials.github"), + }, + githubProxy: { + enabled: y.booleanField(githubProxy, "enabled", "sourceAuthority.githubProxy"), + url: urlField(githubProxy, "url", "sourceAuthority.githubProxy"), + noProxy: y.stringArrayField(githubProxy, "noProxy", "sourceAuthority.githubProxy"), + }, + responsibilities: y.arrayOfRecords(sourceAuthority.responsibilities, "sourceAuthority.responsibilities").map(parseResponsibility), + repositories: y.arrayOfRecords(sourceAuthority.repositories, "sourceAuthority.repositories").map(parseMirrorRepository), + }, targets: y.arrayOfRecords(root.targets, "targets").map(parseTarget), app: { name: y.kubernetesNameField(app, "name", "app"), @@ -227,6 +349,7 @@ function readGiteaConfig(): GiteaConfig { protocol: y.enumField(server, "protocol", "app.server", ["http", "https"] as const), startSshServer: y.booleanField(server, "startSshServer", "app.server"), }, + publicExposure: parsePublicExposure(publicExposure, "app.publicExposure"), database: { type: y.enumField(database, "type", "app.database", ["sqlite3"] as const), path: y.absolutePathField(database, "path", "app.database"), @@ -267,6 +390,110 @@ function readGiteaConfig(): GiteaConfig { return parsed; } +function parseAdminCredential(record: Record, path: string): GiteaAdminCredential { + return { + sourceRef: secretSourceRefField(record, "sourceRef", path), + format: literalField(record, "format", path, ["line-pair"]), + usernameLine: positiveInteger(record, "usernameLine", path), + passwordLine: positiveInteger(record, "passwordLine", path), + requiredFor: y.stringArrayField(record, "requiredFor", path), + }; +} + +function parseGithubCredential(record: Record, path: string): GiteaGithubCredential { + return { + transport: y.enumField(record, "transport", path, ["https-token"] as const), + sourceRef: secretSourceRefField(record, "sourceRef", path), + sourceKey: y.envKeyField(record, "sourceKey", path), + requiredFor: y.stringArrayField(record, "requiredFor", path), + }; +} + +function parsePublicExposure(record: Record, path: string): PublicServiceExposure & { secretRoot: string } { + const dns = y.objectField(record, "dns", path); + const frpc = y.objectField(record, "frpc", path); + const pk01 = y.objectField(record, "pk01", path); + const publicBaseUrl = httpsUrlField(record, "publicBaseUrl", path); + const hostname = y.hostField(dns, "hostname", `${path}.dns`); + if (new URL(publicBaseUrl).hostname !== hostname) throw new Error(`${configLabel}.${path}.dns.hostname must match publicBaseUrl`); + return { + enabled: y.booleanField(record, "enabled", path), + publicBaseUrl, + secretRoot: y.absolutePathField(record, "secretRoot", path), + dns: { + hostname, + expectedA: y.stringField(dns, "expectedA", `${path}.dns`), + resolvers: y.stringArrayField(dns, "resolvers", `${path}.dns`), + }, + frpc: { + deploymentName: y.kubernetesNameField(frpc, "deploymentName", `${path}.frpc`), + secretName: y.kubernetesNameField(frpc, "secretName", `${path}.frpc`), + secretKey: y.stringField(frpc, "secretKey", `${path}.frpc`), + image: y.stringField(frpc, "image", `${path}.frpc`), + serverAddr: y.hostField(frpc, "serverAddr", `${path}.frpc`), + serverPort: y.portField(frpc, "serverPort", `${path}.frpc`), + proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`), + remotePort: y.portField(frpc, "remotePort", `${path}.frpc`), + localIP: y.hostField(frpc, "localIP", `${path}.frpc`), + localPort: y.portField(frpc, "localPort", `${path}.frpc`), + tokenSourceRef: secretSourceRefField(frpc, "tokenSourceRef", `${path}.frpc`), + tokenSourceKey: y.envKeyField(frpc, "tokenSourceKey", `${path}.frpc`), + }, + pk01: { + route: y.stringField(pk01, "route", `${path}.pk01`), + caddyConfigPath: y.absolutePathField(pk01, "caddyConfigPath", `${path}.pk01`), + caddyServiceName: y.stringField(pk01, "caddyServiceName", `${path}.pk01`), + responseHeaderTimeoutSeconds: y.integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.pk01`), + }, + }; +} + +function parseResponsibility(record: Record, index: number): GiteaSourceResponsibility { + const path = `sourceAuthority.responsibilities[${index}]`; + return { + name: y.stringField(record, "name", path), + current: y.stringField(record, "current", path), + target: y.stringField(record, "target", path), + disposition: y.enumField(record, "disposition", path, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const), + }; +} + +function parseMirrorRepository(record: Record, index: number): GiteaMirrorRepository { + const path = `sourceAuthority.repositories[${index}]`; + const upstream = y.objectField(record, "upstream", path); + const gitea = y.objectField(record, "gitea", path); + const gitops = y.objectField(record, "gitops", path); + const snapshot = y.objectField(record, "snapshot", path); + const legacyGitMirror = y.objectField(record, "legacyGitMirror", path); + return { + key: y.stringField(record, "key", path), + targetId: y.stringField(record, "targetId", path), + upstream: { + repository: repositoryField(upstream, "repository", `${path}.upstream`), + cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`), + branch: y.stringField(upstream, "branch", `${path}.upstream`), + }, + gitea: { + owner: y.stringField(gitea, "owner", `${path}.gitea`), + name: giteaRepoNameField(gitea, "name", `${path}.gitea`), + mirrorMode: y.enumField(gitea, "mirrorMode", `${path}.gitea`, ["controlled-push"] as const), + readUrl: urlField(gitea, "readUrl", `${path}.gitea`), + }, + gitops: { + branch: y.stringField(gitops, "branch", `${path}.gitops`), + flushDisposition: y.stringField(gitops, "flushDisposition", `${path}.gitops`), + }, + snapshot: { + prefix: refPrefixField(snapshot, "prefix", `${path}.snapshot`), + }, + legacyGitMirror: { + readUrl: urlField(legacyGitMirror, "readUrl", `${path}.legacyGitMirror`), + configRef: y.stringField(legacyGitMirror, "configRef", `${path}.legacyGitMirror`), + disposition: y.enumField(legacyGitMirror, "disposition", `${path}.legacyGitMirror`, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const), + }, + }; +} + function parseTarget(record: Record, index: number): GiteaTarget { const path = `targets[${index}]`; return { @@ -282,12 +509,24 @@ function parseTarget(record: Record, index: number): GiteaTarge function validateConfig(gitea: GiteaConfig): void { resolveTarget(gitea, gitea.defaults.targetId); + if (!gitea.sourceAuthority.enabled) throw new Error(`${configLabel}.sourceAuthority.enabled must be true for GH-1550`); + if (gitea.sourceAuthority.repositories.length < 1) throw new Error(`${configLabel}.sourceAuthority.repositories must not be empty`); if (!/^docker\.gitea\.com\/gitea$/u.test(gitea.app.image.repository)) throw new Error(`${configLabel}.app.image.repository must use the official Gitea image registry`); if (!/-rootless$/u.test(gitea.app.image.tag)) throw new Error(`${configLabel}.app.image.tag must use a rootless Gitea image`); if (gitea.app.service.type !== "ClusterIP") throw new Error(`${configLabel}.app.service.type must stay ClusterIP`); if (!gitea.app.actions.enabled) throw new Error(`${configLabel}.app.actions.enabled must be true for GH-1548/GH-1549`); if (!gitea.app.registration.disabled) throw new Error(`${configLabel}.app.registration.disabled must be true for the internal POC service`); if (gitea.app.probes.healthPath !== gitea.validation.healthPath) throw new Error(`${configLabel}.app.probes.healthPath must match validation.healthPath`); + if (gitea.app.server.rootUrl !== gitea.app.publicExposure.publicBaseUrl.replace(/\/?$/u, "/")) throw new Error(`${configLabel}.app.server.rootUrl must match app.publicExposure.publicBaseUrl for the Web UI`); + const repoKeys = new Set(); + for (const repo of gitea.sourceAuthority.repositories) { + if (repoKeys.has(repo.key)) throw new Error(`${configLabel}.sourceAuthority.repositories contains duplicate key ${repo.key}`); + repoKeys.add(repo.key); + resolveTarget(gitea, repo.targetId); + const readUrl = new URL(repo.gitea.readUrl); + if (readUrl.hostname !== `${gitea.app.serviceName}.${resolveTarget(gitea, repo.targetId).namespace}.svc.cluster.local`) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must use the internal k3s Gitea Service DNS`); + if (readUrl.hostname === new URL(gitea.app.publicExposure.publicBaseUrl).hostname) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must not use the public Web UI hostname`); + } } function resolveTarget(gitea: GiteaConfig, targetId: string | null): GiteaTarget { @@ -323,16 +562,23 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise check.ok)) return { ok: false, action: "platform-infra-gitea-apply", mode: "policy-blocked", mutation: false, policy }; - const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options)); + const frpcSecret = prepareGiteaFrpcSecret(gitea); + const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options, { frpcSecret })); const parsed = parseJsonOutput(result.stdout); + const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && gitea.app.publicExposure.enabled + ? await applyPk01CaddyBlock(config, "gitea", gitea.app.publicExposure) + : { ok: true, skipped: true, reason: options.dryRun ? "dry-run" : "apply-not-ready" }; return { - ok: result.exitCode === 0 && parsed?.ok === true, + ok: result.exitCode === 0 && parsed?.ok === true && record(caddy).ok !== false, action: "platform-infra-gitea-apply", mode: options.dryRun ? "dry-run" : "confirmed", mutation: !options.dryRun, target: targetSummary(target), config: compactConfigSummary(gitea, target), policy, + publicExposure: publicExposureSummary(gitea), + secrets: { frpc: frpcSecretSummary(frpcSecret) }, + pk01Caddy: caddy, remote: parsed ?? compactCapture(result, { full: true }), next: nextCommands(target.id), }; @@ -375,6 +621,134 @@ async function validate(config: UniDeskConfig, options: CommonOptions): Promise< }; } +async function mirrorCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + const [action = "plan"] = args; + if (action === "plan") { + const options = parseCommonOptions(args.slice(1)); + const result = mirrorPlan(options); + return options.full || options.raw ? result : renderMirrorPlan(result); + } + if (action === "bootstrap") { + const options = parseMirrorOptions(args.slice(1)); + const result = await mirrorBootstrap(config, options); + return options.full || options.raw ? result : renderMirrorBootstrap(result); + } + if (action === "sync" || action === "snapshot") { + const options = parseMirrorOptions(args.slice(1)); + const result = await mirrorSync(config, options); + return options.full || options.raw ? result : renderMirrorSync(result); + } + if (action === "status") { + const options = parseCommonOptions(args.slice(1)); + const result = await mirrorStatus(config, options); + return options.full || options.raw ? result : renderMirrorStatus(result); + } + return { + ok: false, + error: "unsupported-platform-infra-gitea-mirror-command", + args, + help: { + usage: [ + "bun scripts/cli.ts platform-infra gitea mirror plan --target JD01", + "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target JD01 --confirm", + "bun scripts/cli.ts platform-infra gitea mirror sync --target JD01 --confirm", + "bun scripts/cli.ts platform-infra gitea mirror status --target JD01 [--full|--raw]", + ], + }, + }; +} + +function mirrorPlan(options: CommonOptions): Record { + const gitea = readGiteaConfig(); + const target = resolveTarget(gitea, options.targetId); + return { + ok: true, + action: "platform-infra-gitea-mirror-plan", + mutation: false, + target: targetSummary(target), + sourceAuthority: sourceAuthoritySummary(gitea, target), + responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary), + repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)), + credentials: credentialSummaries(gitea), + next: mirrorNextCommands(target.id), + }; +} + +async function mirrorStatus(config: UniDeskConfig, options: CommonOptions): Promise> { + const gitea = readGiteaConfig(); + const target = resolveTarget(gitea, options.targetId); + const health = await validate(config, options); + const healthValidation = record(health.validation); + const credentials = credentialSummaries(gitea); + const secrets = ensureMirrorSecrets(gitea, false); + const result = await capture(config, target.route, ["sh"], remoteScript("mirror-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos: repositoriesForTarget(gitea, target), secrets })); + const parsed = parseJsonOutput(result.stdout); + const repositories = arrayRecords(record(parsed).repositories); + return { + ok: health.ok === true && result.exitCode === 0 && parsed?.ok === true, + action: "platform-infra-gitea-mirror-status", + mutation: false, + target: targetSummary(target), + serviceHealth: { + ok: health.ok === true, + healthStatus: record(healthValidation.health).status, + endpointsReady: healthValidation.endpointsReady === true, + serviceProxyPath: healthValidation.serviceProxyPath, + }, + sourceAuthority: { + ...sourceAuthoritySummary(gitea, target), + mirrorReady: repositories.length > 0 && repositories.every((repo) => repo.sourceBundleReady === true), + stageReady: health.ok === true, + readinessDetail: "Gitea source-authority is ready when every configured repo has branch and snapshot refs in Gitea.", + }, + responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary), + repositories: repositories.length > 0 ? repositories : repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)), + credentials, + remote: parsed ?? compactCapture(result, { full: options.full || result.exitCode !== 0 }), + next: mirrorNextCommands(target.id), + }; +} + +async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): Promise> { + const gitea = readGiteaConfig(); + const target = resolveTarget(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); + const secrets = ensureMirrorSecrets(gitea, true); + const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets })); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && parsed?.ok === true, + action: "platform-infra-gitea-mirror-bootstrap", + mutation: true, + target: targetSummary(target), + repositories: arrayRecords(record(parsed).repositories), + credentials: credentialSummaries(gitea), + remote: parsed ?? compactCapture(result, { full: true }), + next: mirrorNextCommands(target.id), + }; +} + +async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promise> { + const gitea = readGiteaConfig(); + const target = resolveTarget(gitea, options.targetId); + if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-sync", mutation: false, mode: "missing-confirm", error: "mirror sync requires --confirm" }; + const repos = selectedRepositories(gitea, target, options.repoKey); + const secrets = ensureMirrorSecrets(gitea, false); + const result = await capture(config, target.route, ["sh"], remoteScript("mirror-sync", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets })); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && parsed?.ok === true, + action: "platform-infra-gitea-mirror-sync", + mutation: true, + target: targetSummary(target), + repositories: arrayRecords(record(parsed).repositories), + sourceBundles: arrayRecords(record(parsed).sourceBundles), + remote: parsed ?? compactCapture(result, { full: true }), + next: mirrorNextCommands(target.id), + }; +} + function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string { const app = gitea.app; const image = `${app.image.repository}:${app.image.tag}`; @@ -534,7 +908,7 @@ ${envVars(gitea, target)} resources: requests: storage: ${app.storage.config.size} -`; +${renderFrpcManifest(publicServiceTarget(gitea, target))}`; } function envVars(gitea: GiteaConfig, target: GiteaTarget): string { @@ -562,7 +936,7 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string { value: ${yamlQuote(value)}`).join("\n"); } -function remoteScript(action: "apply" | "status" | "validate", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions): string { +function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): string { const env: Record = { UNIDESK_GITEA_ACTION: action, UNIDESK_GITEA_TARGET_ID: target.id, @@ -580,7 +954,23 @@ function remoteScript(action: "apply" | "status" | "validate", gitea: GiteaConfi UNIDESK_GITEA_WAIT: options.wait ? "1" : "0", UNIDESK_GITEA_FULL: options.full ? "1" : "0", UNIDESK_GITEA_MANIFEST_B64: Buffer.from(manifest, "utf8").toString("base64"), + UNIDESK_GITEA_ROOT_URL: gitea.app.server.rootUrl, + UNIDESK_GITEA_REPOS_B64: Buffer.from(JSON.stringify((params.repos ?? []).map((repo) => remoteRepoSpec(repo))), "utf8").toString("base64"), + UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0", + UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url, + UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","), + UNIDESK_GITEA_FRPC_ENABLED: gitea.app.publicExposure.enabled ? "1" : "0", }; + if (params.frpcSecret !== undefined) { + env.UNIDESK_GITEA_FRPC_SECRET_NAME = params.frpcSecret.secretName; + env.UNIDESK_GITEA_FRPC_SECRET_KEY = params.frpcSecret.secretKey; + env.UNIDESK_GITEA_FRPC_TOML_B64 = Buffer.from(params.frpcSecret.frpcToml, "utf8").toString("base64"); + } + if (params.secrets !== undefined) { + env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername; + env.UNIDESK_GITEA_ADMIN_PASSWORD = params.secrets.adminPassword; + env.UNIDESK_GITEA_GITHUB_TOKEN = params.secrets.githubToken; + } const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n"); return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`; } @@ -616,6 +1006,7 @@ function compactConfigSummary(gitea: GiteaConfig, target: GiteaTarget): Record { replicas: gitea.app.replicas, service: gitea.app.service, rootUrl: gitea.app.server.rootUrl, + publicExposure: publicExposureSummary(gitea), actionsEnabled: gitea.app.actions.enabled, registrationDisabled: gitea.app.registration.disabled, storage: gitea.app.storage, @@ -649,6 +1041,57 @@ function appSummary(gitea: GiteaConfig): Record { }; } +function publicServiceTarget(gitea: GiteaConfig, target: GiteaTarget): PublicServiceTarget { + return { + id: target.id, + route: target.route, + namespace: target.namespace, + replicas: 1, + publicExposure: gitea.app.publicExposure, + }; +} + +function prepareGiteaFrpcSecret(gitea: GiteaConfig): FrpcSecretMaterial { + return prepareFrpcSecret({ + secretRoot: gitea.app.publicExposure.secretRoot, + exposure: gitea.app.publicExposure, + sourcePathRedactor: redactRepoPath, + parseEnvFile, + requiredEnvValue, + readTextFile, + }); +} + +function publicExposureSummary(gitea: GiteaConfig): Record { + const exposure = gitea.app.publicExposure; + return { + enabled: exposure.enabled, + publicBaseUrl: exposure.publicBaseUrl, + internalReadUrlPolicy: "k3s-consumers-use-gitea-http-svc-not-public-url", + dns: exposure.dns, + frpc: { + deploymentName: exposure.frpc.deploymentName, + remotePort: exposure.frpc.remotePort, + localIP: exposure.frpc.localIP, + localPort: exposure.frpc.localPort, + tokenSourceRef: exposure.frpc.tokenSourceRef, + valuesPrinted: false, + }, + pk01: exposure.pk01, + }; +} + +function frpcSecretSummary(secret: FrpcSecretMaterial): Record { + return { + sourceRef: secret.sourceRef, + sourcePath: secret.sourcePath, + secretName: secret.secretName, + secretKey: secret.secretKey, + fingerprint: secret.fingerprint, + valuesPrinted: false, + }; +} + function statusSummary(payload: Record): Record { return { ready: payload.ready === true, @@ -668,6 +1111,267 @@ function statusSummary(payload: Record): Record { + return { + enabled: gitea.sourceAuthority.enabled, + stage: gitea.sourceAuthority.stage, + statusAuthority: gitea.sourceAuthority.statusAuthority, + firstCiConsumer: gitea.sourceAuthority.firstCiConsumer, + target: target.id, + serviceBaseUrl: gitea.app.server.rootUrl, + repositoryCount: repositoriesForTarget(gitea, target).length, + }; +} + +function responsibilitySummary(item: GiteaSourceResponsibility): Record { + return { ...item }; +} + +function repositoriesForTarget(gitea: GiteaConfig, target: GiteaTarget): GiteaMirrorRepository[] { + return gitea.sourceAuthority.repositories.filter((repo) => repo.targetId.toLowerCase() === target.id.toLowerCase()); +} + +function selectedRepositories(gitea: GiteaConfig, target: GiteaTarget, repoKey: string | null): GiteaMirrorRepository[] { + const repos = repositoriesForTarget(gitea, target); + if (repoKey === null) return repos; + const repo = repos.find((item) => item.key === repoKey); + if (repo === undefined) throw new Error(`unknown gitea mirror repo ${repoKey}; known repos: ${repos.map((item) => item.key).join(", ")}`); + return [repo]; +} + +function repositorySummary(gitea: GiteaConfig, repo: GiteaMirrorRepository): Record { + return { + key: repo.key, + upstreamRepository: repo.upstream.repository, + upstreamBranch: repo.upstream.branch, + giteaOwner: repo.gitea.owner, + giteaRepo: repo.gitea.name, + mirrorMode: repo.gitea.mirrorMode, + readUrl: repo.gitea.readUrl, + snapshotPrefix: repo.snapshot.prefix, + legacyReadUrl: repo.legacyGitMirror.readUrl, + legacyDisposition: repo.legacyGitMirror.disposition, + sourceAuthority: "gitea", + rootUrlMatches: repo.gitea.readUrl.startsWith(gitea.app.server.rootUrl), + }; +} + +function remoteRepoSpec(repo: GiteaMirrorRepository): Record { + return { + key: repo.key, + upstream: repo.upstream, + gitea: repo.gitea, + gitops: repo.gitops, + snapshot: repo.snapshot, + legacyGitMirror: repo.legacyGitMirror, + }; +} + +function mirrorNextCommands(targetId: string): Record { + return { + bootstrap: `bun scripts/cli.ts platform-infra gitea mirror bootstrap --target ${targetId} --confirm`, + sync: `bun scripts/cli.ts platform-infra gitea mirror sync --target ${targetId} --confirm`, + status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`, + full: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`, + }; +} + +function credentialSummaries(gitea: GiteaConfig): Array> { + const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef); + const githubPath = credentialPath(gitea, gitea.sourceAuthority.credentials.github.sourceRef); + const adminLinePair = existsSync(adminPath) ? parseLinePairCredential(adminPath, gitea.sourceAuthority.credentials.admin) : { username: null, password: null }; + const githubEnv = existsSync(githubPath) ? parseEnvFileSafe(githubPath) : {}; + return [ + { + id: "gitea-admin", + format: gitea.sourceAuthority.credentials.admin.format, + sourceRef: gitea.sourceAuthority.credentials.admin.sourceRef, + sourcePath: adminPath, + present: existsSync(adminPath), + requiredKeysPresent: Boolean(adminLinePair.username) && Boolean(adminLinePair.password), + fingerprint: fingerprintSecretParts([adminLinePair.username, adminLinePair.password]), + valuesPrinted: false, + }, + { + id: "github-upstream", + transport: gitea.sourceAuthority.credentials.github.transport, + sourceRef: gitea.sourceAuthority.credentials.github.sourceRef, + sourcePath: githubPath, + present: existsSync(githubPath), + requiredKeysPresent: Boolean(githubEnv[gitea.sourceAuthority.credentials.github.sourceKey]), + fingerprint: fingerprintKeys(githubEnv, [gitea.sourceAuthority.credentials.github.sourceKey]), + valuesPrinted: false, + }, + ]; +} + +function credentialBlockers(credentials: Array>): string[] { + return credentials.filter((item) => item.present !== true || item.requiredKeysPresent !== true).map((item) => String(item.id)); +} + +function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean): MirrorSecrets { + const admin = gitea.sourceAuthority.credentials.admin; + const adminPath = credentialPath(gitea, admin.sourceRef); + if (!existsSync(adminPath)) { + if (!createAdmin) throw new Error(`${admin.sourceRef} is missing; run mirror bootstrap --confirm first`); + mkdirSync(dirname(adminPath), { recursive: true }); + const username = "unidesk-admin"; + const password = `udg_${randomBytes(32).toString("base64url")}`; + writeFileSync(adminPath, `${username}\n${password}\n`, { encoding: "utf8", mode: 0o600 }); + chmodSync(adminPath, 0o600); + } + const adminLinePair = parseLinePairCredential(adminPath, admin); + const github = gitea.sourceAuthority.credentials.github; + const githubPath = credentialPath(gitea, github.sourceRef); + if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`); + const githubEnv = parseEnvFileSafe(githubPath); + const adminUsername = adminLinePair.username; + const adminPassword = adminLinePair.password; + const githubToken = githubEnv[github.sourceKey]; + if (!adminUsername || !adminPassword) throw new Error(`${admin.sourceRef} must contain username on line ${admin.usernameLine} and password on line ${admin.passwordLine}`); + if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`); + return { adminUsername, adminPassword, githubToken }; +} + +function credentialPath(gitea: GiteaConfig, sourceRef: string): string { + return sourceRef.startsWith("/") ? sourceRef : join(gitea.sourceAuthority.credentials.sourceRoot, sourceRef); +} + +function parseLinePairCredential(path: string, admin: GiteaAdminCredential): { username: string | null; password: string | null } { + const lines = readFileSync(path, "utf8").split(/\r?\n/u); + return { + username: lines[admin.usernameLine - 1]?.trim() || null, + password: lines[admin.passwordLine - 1]?.trim() || null, + }; +} + +function parseEnvFileSafe(path: string): Record { + const values: Record = {}; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/u)) { + let line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#")) continue; + if (line.startsWith("export ")) line = line.slice("export ".length).trim(); + const eq = line.indexOf("="); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue; + values[key] = unquote(line.slice(eq + 1).trim()); + } + return values; +} + +function unquote(value: string): string { + if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1); + return value; +} + +function fingerprintKeys(values: Record, keys: string[]): string | null { + if (keys.some((key) => !values[key])) return null; + const hash = createHash("sha256"); + for (const key of keys.slice().sort()) { + hash.update(key); + hash.update("\0"); + hash.update(values[key]); + hash.update("\0"); + } + return `sha256:${hash.digest("hex").slice(0, 16)}`; +} + +function fingerprintSecretParts(parts: Array): string | null { + if (parts.some((part) => !part)) return null; + const hash = createHash("sha256"); + for (const part of parts) { + hash.update(part ?? ""); + hash.update("\0"); + } + return `sha256:${hash.digest("hex").slice(0, 16)}`; +} + +function renderMirrorPlan(result: Record): RenderedCliResult { + const target = record(result.target); + const source = record(result.sourceAuthority); + const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.upstreamRepository), stringValue(repo.upstreamBranch), stringValue(repo.readUrl), stringValue(repo.legacyDisposition)]); + const responsibilities = arrayRecords(result.responsibilities).map((item) => [stringValue(item.name), stringValue(item.current), stringValue(item.target), stringValue(item.disposition)]); + const next = record(result.next); + return rendered(result, "platform-infra gitea mirror plan", [ + "PLATFORM-INFRA GITEA MIRROR PLAN", + ...table(["TARGET", "STAGE", "AUTHORITY", "REPOS"], [[stringValue(target.id), stringValue(source.stage), stringValue(source.statusAuthority), stringValue(source.repositoryCount)]]), + "", + "REPOSITORIES", + ...(repos.length === 0 ? ["-"] : table(["KEY", "UPSTREAM", "BRANCH", "GITEA_READ_URL", "LEGACY"], repos)), + "", + "RESPONSIBILITIES", + ...(responsibilities.length === 0 ? ["-"] : table(["NAME", "CURRENT", "TARGET", "DISPOSITION"], responsibilities)), + "", + "NEXT", + ` bootstrap: ${stringValue(next.bootstrap)}`, + ` sync: ${stringValue(next.sync)}`, + ` status: ${stringValue(next.status)}`, + "", + "Disclosure: credentials are summarized by presence/fingerprint only.", + ]); +} + +function renderMirrorBootstrap(result: Record): RenderedCliResult { + const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.owner), stringValue(repo.name), boolText(record(repo.create).ok)]); + 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)]]), + "", + "REPOSITORIES", + ...(repos.length === 0 ? ["-"] : table(["KEY", "OWNER", "REPO", "API_OK"], repos)), + ...remoteErrorLines(result), + "", + `NEXT ${stringValue(next.sync)}`, + ]); +} + +function renderMirrorSync(result: Record): RenderedCliResult { + const bundles = arrayRecords(result.sourceBundles).map((bundle) => [stringValue(bundle.key), stringValue(bundle.sourceCommit).slice(0, 12), stringValue(bundle.snapshotRef), stringValue(bundle.readUrl)]); + const repos = arrayRecords(result.repositories).map((repo) => [ + stringValue(repo.key), + boolText(repo.syncOk), + stringValue(repo.fetchRc), + stringValue(repo.pushRc), + stringValue(repo.gitopsPushRc), + stringValue(repo.sourceCommit).slice(0, 12), + compactTail(stringValue(repo.fetchTail) || stringValue(repo.pushTail) || stringValue(repo.revparseTail)), + ]); + const next = record(result.next); + return rendered(result, "platform-infra gitea mirror sync", [ + "PLATFORM-INFRA GITEA MIRROR SYNC", + ...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]), + "", + "REPOSITORIES", + ...(repos.length === 0 ? ["-"] : table(["KEY", "SYNC", "FETCH", "PUSH", "GITOPS", "COMMIT", "ERROR"], repos)), + "", + "SOURCE BUNDLES", + ...(bundles.length === 0 ? ["-"] : table(["KEY", "COMMIT", "SNAPSHOT_REF", "READ_URL"], bundles)), + ...remoteErrorLines(result), + "", + `NEXT ${stringValue(next.status)}`, + ]); +} + +function renderMirrorStatus(result: Record): RenderedCliResult { + const source = record(result.sourceAuthority); + const service = record(result.serviceHealth); + const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.mirrorState), stringValue(repo.branchCommit).slice(0, 12), stringValue(repo.snapshotCommit).slice(0, 12), stringValue(repo.snapshotRef)]); + const next = record(result.next); + return rendered(result, "platform-infra gitea mirror status", [ + "PLATFORM-INFRA GITEA MIRROR STATUS", + ...table(["TARGET", "SERVICE", "MIRROR_READY", "AUTHORITY"], [[stringValue(record(result.target).id), boolText(service.ok), boolText(source.mirrorReady), stringValue(source.statusAuthority)]]), + "", + "REPOSITORIES", + ...(repos.length === 0 ? ["-"] : table(["KEY", "STATE", "BRANCH", "SNAPSHOT", "SNAPSHOT_REF"], repos)), + ...remoteErrorLines(result), + "", + `NEXT ${stringValue(next.sync)}`, + ]); +} + + function renderPlan(result: Record): RenderedCliResult { const config = record(result.config); const target = record(config.target); @@ -683,6 +1387,7 @@ function renderPlan(result: Record): RenderedCliResult { ["NAMESPACE", stringValue(target.namespace), "role", stringValue(target.role)], ["IMAGE", stringValue(app.image), "replicas", stringValue(app.replicas)], ["SERVICE", `${stringValue(app.serviceName)}:${stringValue(record(app.service).httpPort)}`, "dns", serviceDnsFromObjects(app, target)], + ["WEBUI", stringValue(record(app.publicExposure).publicBaseUrl), "internalGit", serviceDnsFromObjects(app, target)], ["ACTIONS", boolText(app.actionsEnabled), "registrationDisabled", boolText(app.registrationDisabled)], ["MIGRATION", stringValue(migration.role), "replaces", stringValue(migration.replaces)], ["POLICY", failed.length === 0 ? "ok" : `failed=${failed.length}`, "valuesPrinted", "false"], @@ -703,16 +1408,20 @@ function renderApply(result: Record): RenderedCliResult { const target = record(result.target); const remote = record(result.remote); const steps = record(remote.steps); + const frpcStep = record(steps.frpcSecret); const applyStep = record(steps.apply); const rolloutStep = record(steps.rollout); + const caddy = record(result.pk01Caddy); return rendered(result, "platform-infra gitea apply", [ "PLATFORM-INFRA GITEA APPLY", ...table(["TARGET", "NAMESPACE", "MODE", "OK"], [[stringValue(target.id), stringValue(target.namespace), stringValue(result.mode), boolText(result.ok)]]), "", "STEPS", ...table(["STEP", "EXIT", "DETAIL"], [ + ["frpc-secret", stringValue(frpcStep.exitCode), compactLine(stringValue(frpcStep.stderrTail, stringValue(frpcStep.stdoutTail)))], ["apply", stringValue(applyStep.exitCode), compactLine(stringValue(applyStep.stderrTail, stringValue(applyStep.stdoutTail)))], ["rollout", stringValue(rolloutStep.exitCode), compactLine(stringValue(rolloutStep.stderrTail, stringValue(rolloutStep.stdoutTail)))], + ["pk01-caddy", stringValue(caddy.exitCode ?? (caddy.ok === false ? 1 : 0)), compactLine(stringValue(caddy.error, stringValue(caddy.status, stringValue(caddy.reason))))], ]), ...remoteErrorLines(result), "", @@ -760,6 +1469,11 @@ function remoteErrorLines(result: Record): string[] { return ["", "ERROR", ...table(["EXIT", "DETAIL"], [[exitCode, detail]])]; } +function compactTail(value: string): string { + const line = value.split(/\r?\n/u).map((item) => item.trim()).filter(Boolean).at(-1) ?? ""; + return line.length > 96 ? `${line.slice(0, 93)}...` : line; +} + function rendered(result: Record, command: string, lines: string[]): RenderedCliResult { return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" }; } @@ -786,6 +1500,31 @@ function parseApplyOptions(args: string[]): ApplyOptions { return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait }; } +function parseMirrorOptions(args: string[]): MirrorOptions { + const commonArgs: string[] = []; + let confirm = 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 === "--repo") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--repo requires a value"); + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error("--repo must be a simple repo key"); + repoKey = value; + index += 1; + } else { + commonArgs.push(arg); + if (arg === "--target" || arg === "--node") { + commonArgs.push(args[index + 1] ?? ""); + index += 1; + } + } + } + return { ...parseCommonOptions(commonArgs), confirm, repoKey }; +} + function parseCommonOptions(args: string[]): CommonOptions { let targetId: string | null = null; let full = false; @@ -851,12 +1590,48 @@ function boundedTimeout(obj: Record, key: string, path: string) return value; } +function literalField(obj: Record, key: string, path: string, allowed: T[]): T { + const value = y.stringField(obj, key, path); + if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`); + return value as T; +} + function quantity(obj: Record, key: string, path: string): string { const value = y.stringField(obj, key, path); if (!/^[0-9]+(?:m|Ki|Mi|Gi|Ti)?$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a Kubernetes quantity`); return value; } +function secretSourceRefField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a source ref path without ..`); + return value; +} + +function repositoryField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be owner/repo`); + return value; +} + +function giteaRepoNameField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (!/^[A-Za-z0-9_.-]+$/u.test(value) || value.includes("..")) throw new Error(`${configLabel}.${path}.${key} must be a Gitea repository name`); + return value; +} + +function gitCloneUrlField(obj: Record, key: string, path: string): string { + const value = urlField(obj, key, path); + if (!/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be an official GitHub HTTPS clone URL`); + return value; +} + +function refPrefixField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (!/^refs\/[A-Za-z0-9._/-]+$/u.test(value) || value.includes("..")) throw new Error(`${configLabel}.${path}.${key} must be a git ref prefix`); + return value.replace(/\/+$/u, ""); +} + function urlField(obj: Record, key: string, path: string): string { const value = y.stringField(obj, key, path); const parsed = new URL(value); @@ -864,6 +1639,12 @@ function urlField(obj: Record, key: string, path: string): stri return value; } +function httpsUrlField(obj: Record, key: string, path: string): string { + const value = urlField(obj, key, path); + if (new URL(value).protocol !== "https:") throw new Error(`${configLabel}.${path}.${key} must be an https URL`); + return value; +} + function yamlQuote(value: string): string { return JSON.stringify(value); } diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index 91779db4..02002a13 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -435,6 +435,10 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra gitea apply --target JD01 --confirm", "bun scripts/cli.ts platform-infra gitea status --target JD01", "bun scripts/cli.ts platform-infra gitea validate --target JD01", + "bun scripts/cli.ts platform-infra gitea mirror plan --target JD01", + "bun scripts/cli.ts platform-infra gitea mirror bootstrap --target JD01 --confirm", + "bun scripts/cli.ts platform-infra gitea mirror sync --target JD01 --confirm", + "bun scripts/cli.ts platform-infra gitea mirror status --target JD01", ], description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the D518 Kafka event bus, and the internal Gitea source-authority service for GH-1548/GH-1549. Public services use PK01 Caddy+FRP rather than Kubernetes Ingress, NodePort, or LoadBalancer.", target,