feat: add github to gitea webhook sync

This commit is contained in:
Codex
2026-07-06 01:56:10 +00:00
parent b68c8e3a3e
commit 7efaf3e27b
5 changed files with 925 additions and 30 deletions
+22
View File
@@ -53,6 +53,28 @@ sourceAuthority:
- localhost
- .svc
- .svc.cluster.local
webhookSync:
enabled: true
direction: github-to-gitea
publicPath: /_unidesk/github-to-gitea
events:
- push
secret:
sourceRef: .env/gitea.github-webhook.env
sourceKey: GITHUB_WEBHOOK_SECRET
createIfMissing: true
bridge:
deploymentName: gitea-github-sync
serviceName: gitea-github-sync
secretName: gitea-github-sync-secrets
configMapName: gitea-github-sync-config
image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1
replicas: 1
httpPort: 8080
serviceAccountName: gitea-github-sync
frpc:
proxyName: platform-infra-gitea-jd01-github-webhook
remotePort: 22081
responsibilities:
- name: source-read
current: legacy-git-mirror
+15 -8
View File
@@ -64,16 +64,23 @@ export async function applyPk01CaddyManagedBlock(
serviceId: string,
exposure: Pk01CaddyBlockExposure,
markers: Pk01CaddyManagedBlockMarkers,
): Promise<Record<string, unknown>> {
return await applyPk01CaddySiteBlock(config, serviceId, exposure, renderSimpleReverseProxyCaddySiteBlock({
hostname: exposure.dns.hostname,
upstream: `127.0.0.1:${exposure.frpc.remotePort}`,
responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds,
}), markers);
}
export async function applyPk01CaddySiteBlock(
config: UniDeskConfig,
serviceId: string,
exposure: Pk01CaddyBlockExposure,
siteBlock: string,
markers: Pk01CaddyManagedBlockMarkers,
): Promise<Record<string, unknown>> {
if (!exposure.enabled) return { ok: true, action: "not-enabled" };
const block = renderCaddyManagedBlock(
markerNameFromMarkers(markers),
renderSimpleReverseProxyCaddySiteBlock({
hostname: exposure.dns.hostname,
upstream: `127.0.0.1:${exposure.frpc.remotePort}`,
responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds,
}),
);
const block = renderCaddyManagedBlock(markerNameFromMarkers(markers), siteBlock);
const blockB64 = Buffer.from(block, "utf8").toString("base64");
const script = `
set -u
@@ -0,0 +1,143 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const port = Number.parseInt(process.env.UNIDESK_GITEA_WEBHOOK_PORT || "8080", 10);
const path = process.env.UNIDESK_GITEA_WEBHOOK_PATH || "/_unidesk/github-to-gitea";
const repos = JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8"));
const githubToken = requiredEnv("GITHUB_TOKEN");
const giteaUsername = requiredEnv("GITEA_USERNAME");
const giteaPassword = requiredEnv("GITEA_PASSWORD");
const webhookSecret = requiredEnv("GITHUB_WEBHOOK_SECRET");
const giteaBaseUrl = requiredEnv("UNIDESK_GITEA_SERVICE_BASE_URL").replace(/\/+$/u, "");
const githubAuthHeader = `Authorization: Basic ${Buffer.from(`x-access-token:${githubToken}`).toString("base64")}`;
const giteaAuthHeader = `Authorization: Basic ${Buffer.from(`${giteaUsername}:${giteaPassword}`).toString("base64")}`;
const running = new Set();
createServer(async (req, res) => {
const startedAt = Date.now();
try {
const url = new URL(req.url || "/", "http://gitea-github-sync.local");
if (req.method === "GET" && url.pathname === "/healthz") {
writeJson(res, 200, { ok: true, repos: repos.length, valuesPrinted: false });
return;
}
if (req.method !== "POST" || url.pathname !== path) {
writeJson(res, 404, { ok: false, error: "not-found", valuesPrinted: false });
return;
}
const body = await readBody(req);
verifySignature(req.headers["x-hub-signature-256"], body);
const event = String(req.headers["x-github-event"] || "");
if (event === "ping") {
writeJson(res, 200, { ok: true, event, disposition: "pong", valuesPrinted: false });
return;
}
if (event !== "push") {
writeJson(res, 202, { ok: true, event, disposition: "ignored-event", valuesPrinted: false });
return;
}
const payload = JSON.parse(body.toString("utf8") || "{}");
const repository = payload?.repository?.full_name;
const ref = payload?.ref;
const repo = repos.find((item) => item?.upstream?.repository === repository && ref === `refs/heads/${item?.upstream?.branch}`);
if (!repo) {
writeJson(res, 202, { ok: true, event, repository, ref, disposition: "ignored-repo-or-ref", valuesPrinted: false });
return;
}
if (running.has(repo.key)) {
writeJson(res, 202, { ok: true, event, repository, ref, disposition: "already-running", repo: repo.key, valuesPrinted: false });
return;
}
running.add(repo.key);
try {
const sync = syncRepository(repo);
log({ event: "github-to-gitea-sync", repo: repo.key, ok: sync.ok, sourceCommit: sync.sourceCommit, snapshotRef: sync.snapshotRef, elapsedMs: Date.now() - startedAt, valuesPrinted: false });
writeJson(res, sync.ok ? 200 : 500, { ok: sync.ok, event, repository, ref, repo: repo.key, ...sync, valuesPrinted: false });
} finally {
running.delete(repo.key);
}
} catch (error) {
log({ event: "github-to-gitea-sync-error", ok: false, error: String(error?.message || error), valuesPrinted: false });
writeJson(res, 500, { ok: false, error: String(error?.message || error), valuesPrinted: false });
}
}).listen(port, "0.0.0.0", () => {
log({ event: "github-to-gitea-sync-listening", port, path, repos: repos.map((repo) => repo.key), valuesPrinted: false });
});
function syncRepository(repo) {
const work = mkdtempSync(join(tmpdir(), `gitea-gh-sync-${repo.key}-`));
try {
run(["git", "init", "--bare", work]);
run(["git", "-C", work, "-c", `http.extraHeader=${githubAuthHeader}`, "fetch", "--prune", repo.upstream.cloneUrl, `+refs/heads/${repo.upstream.branch}:refs/remotes/github/${repo.upstream.branch}`]);
const sourceCommit = run(["git", "-C", work, "rev-parse", `refs/remotes/github/${repo.upstream.branch}`]).stdout.trim();
const snapshotRef = `${repo.snapshot.prefix.replace(/\/+$/u, "")}/${sourceCommit}`;
const giteaUrl = giteaBaseUrl + new URL(repo.gitea.readUrl).pathname;
run(["git", "-C", work, "-c", `http.extraHeader=${giteaAuthHeader}`, "push", "--force", giteaUrl, `${sourceCommit}:refs/heads/${repo.upstream.branch}`, `${sourceCommit}:${snapshotRef}`]);
return { ok: true, sourceCommit, snapshotRef };
} catch (error) {
return { ok: false, error: String(error?.message || error) };
} finally {
rmSync(work, { recursive: true, force: true });
}
}
function run(args) {
const result = spawnSync(args[0], args.slice(1), {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 120000,
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
},
});
if (result.error || result.status !== 0) {
const stderr = String(result.stderr || result.error?.message || "").slice(-1600);
throw new Error(`${args[0]} failed status=${result.status ?? "error"} stderr=${stderr}`);
}
return { stdout: result.stdout || "", stderr: result.stderr || "" };
}
function verifySignature(signatureHeader, body) {
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
if (!signature || !signature.startsWith("sha256=")) throw new Error("missing-github-signature");
const expected = `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`;
if (!timingSafeEqualString(signature, expected)) throw new Error("invalid-github-signature");
}
function timingSafeEqualString(a, b) {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function writeJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(`${JSON.stringify(payload)}\n`);
}
function log(payload) {
console.log(JSON.stringify(payload));
}
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`missing env ${name}`);
return value;
}
+278 -8
View File
@@ -45,17 +45,34 @@ 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
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \
--from-literal=github-token="$UNIDESK_GITEA_GITHUB_TOKEN" \
--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=$?
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
dry_arg=""
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
dry_arg="--dry-run=server"
fi
if [ "$frpc_rc" -eq 0 ]; then
if [ "$frpc_rc" -eq 0 ] && [ "$webhook_secret_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"
printf '%s\n' "frpc or webhook 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"
@@ -69,9 +86,45 @@ run_apply() {
printf '%s\n' "rollout wait not requested" >"$tmp/rollout.err"
fi
fi
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'
if [ "$apply_rc" -eq 0 ] && [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl -n "$UNIDESK_GITEA_NAMESPACE" rollout restart "deployment/$UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME" >"$tmp/frpc-rollout.out" 2>"$tmp/frpc-rollout.err"
frpc_rollout_rc=$?
if [ "$frpc_rollout_rc" -eq 0 ] && [ "$UNIDESK_GITEA_WAIT" = "1" ]; then
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl -n "$UNIDESK_GITEA_NAMESPACE" rollout status "deployment/$UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME" --timeout="${UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS}s" >>"$tmp/frpc-rollout.out" 2>>"$tmp/frpc-rollout.err"
frpc_rollout_rc=$?
fi
else
frpc_rollout_rc=0
: >"$tmp/frpc-rollout.out"
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
printf '%s\n' "dry-run: frpc rollout skipped" >"$tmp/frpc-rollout.err"
elif [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ]; then
printf '%s\n' "manifest apply failed; frpc rollout skipped" >"$tmp/frpc-rollout.err"
else
printf '%s\n' "frpc disabled" >"$tmp/frpc-rollout.err"
fi
fi
if [ "$apply_rc" -eq 0 ] && [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl -n "$UNIDESK_GITEA_NAMESPACE" rollout restart "deployment/$UNIDESK_GITEA_WEBHOOK_DEPLOYMENT" >"$tmp/webhook-rollout.out" 2>"$tmp/webhook-rollout.err"
webhook_rollout_rc=$?
if [ "$webhook_rollout_rc" -eq 0 ] && [ "$UNIDESK_GITEA_WAIT" = "1" ]; then
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl -n "$UNIDESK_GITEA_NAMESPACE" rollout status "deployment/$UNIDESK_GITEA_WEBHOOK_DEPLOYMENT" --timeout="${UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS}s" >>"$tmp/webhook-rollout.out" 2>>"$tmp/webhook-rollout.err"
webhook_rollout_rc=$?
fi
else
webhook_rollout_rc=0
: >"$tmp/webhook-rollout.out"
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
printf '%s\n' "dry-run: webhook sync rollout skipped" >"$tmp/webhook-rollout.err"
elif [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ]; then
printf '%s\n' "manifest apply failed; webhook sync rollout skipped" >"$tmp/webhook-rollout.err"
else
printf '%s\n' "webhook sync disabled" >"$tmp/webhook-rollout.err"
fi
fi
python3 - "$frpc_rc" "$webhook_secret_rc" "$apply_rc" "$rollout_rc" "$frpc_rollout_rc" "$webhook_rollout_rc" "$tmp/frpc-secret.out" "$tmp/frpc-secret.err" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout.out" "$tmp/rollout.err" "$tmp/frpc-rollout.out" "$tmp/frpc-rollout.err" "$tmp/webhook-rollout.out" "$tmp/webhook-rollout.err" <<'PY'
import json, os, sys
frpc_rc, apply_rc, rollout_rc = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3])
frpc_rc, webhook_secret_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc = [int(value) for value in sys.argv[1:7]]
def text(path, limit=3000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
@@ -79,7 +132,7 @@ def text(path, limit=3000):
return ""
dry = os.environ.get("UNIDESK_GITEA_DRY_RUN") == "1"
payload = {
"ok": frpc_rc == 0 and apply_rc == 0 and rollout_rc == 0,
"ok": all(value == 0 for value in [frpc_rc, webhook_secret_rc, apply_rc, rollout_rc, frpc_rollout_rc, webhook_rollout_rc]),
"target": os.environ.get("UNIDESK_GITEA_TARGET_ID"),
"route": os.environ.get("UNIDESK_GITEA_ROUTE"),
"namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"),
@@ -92,9 +145,12 @@ payload = {
"networkPolicy": "allow-all",
},
"steps": {
"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])},
"frpcSecret": {"exitCode": frpc_rc, "stdoutTail": text(sys.argv[7]), "stderrTail": text(sys.argv[8])},
"webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[9]), "stderrTail": text(sys.argv[10])},
"apply": {"exitCode": apply_rc, "stdoutTail": text(sys.argv[11]), "stderrTail": text(sys.argv[12])},
"rollout": {"exitCode": rollout_rc, "stdoutTail": text(sys.argv[13]), "stderrTail": text(sys.argv[14])},
"frpcRollout": {"exitCode": frpc_rollout_rc, "stdoutTail": text(sys.argv[15]), "stderrTail": text(sys.argv[16])},
"webhookSyncRollout": {"exitCode": webhook_rollout_rc, "stdoutTail": text(sys.argv[17]), "stderrTail": text(sys.argv[18])},
},
"valuesPrinted": False,
}
@@ -573,6 +629,217 @@ sys.exit(0 if payload["ok"] else 1)
PY
}
github_api_env() {
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
}
run_mirror_webhook_apply() {
mirror_repos_json
github_api_env
python3 - "$tmp/repos.json" <<'PY'
import json, os, sys, urllib.error, urllib.request
repos = json.load(open(sys.argv[1], encoding="utf-8"))
token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"]
url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"]
secret = os.environ["UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET"]
events = [item for item in os.environ.get("UNIDESK_GITEA_WEBHOOK_EVENTS", "push").split(",") if item]
def request(method, api_path, payload=None, expected=(200, 201, 204), tolerate=()):
data = None if payload is None else json.dumps(payload).encode()
req = urllib.request.Request("https://api.github.com" + api_path, data=data, method=method)
req.add_header("Authorization", "Bearer " + token)
req.add_header("Accept", "application/vnd.github+json")
req.add_header("X-GitHub-Api-Version", "2022-11-28")
if payload is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8", errors="replace")
return {"ok": resp.status in expected, "status": resp.status, "body": body}
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]}
rows = []
for repo in repos:
repository = repo["upstream"]["repository"]
list_result = request("GET", f"/repos/{repository}/hooks")
hooks = []
try:
hooks = json.loads(list_result.get("body") or "[]")
except Exception:
hooks = []
matches = [item for item in hooks if (item.get("config") or {}).get("url") == url]
body = {"name": "web", "active": True, "events": events, "config": {"url": url, "content_type": "json", "secret": secret, "insecure_ssl": "0"}}
if matches:
hook_id = matches[0].get("id")
result = request("PATCH", f"/repos/{repository}/hooks/{hook_id}", body)
for duplicate in matches[1:]:
duplicate_id = duplicate.get("id")
if duplicate_id:
request("DELETE", f"/repos/{repository}/hooks/{duplicate_id}", expected=(204,), tolerate=(404,))
else:
result = request("POST", f"/repos/{repository}/hooks", body, expected=(201,))
try:
hook_id = json.loads(result.get("body") or "{}").get("id")
except Exception:
hook_id = None
rows.append({"key": repo["key"], "repository": repository, "hookId": hook_id, "hookReady": result.get("ok") is True, "status": result.get("status"), "error": None if result.get("ok") else result.get("body", "")[:500], "valuesPrinted": False})
payload = {"ok": all(row["hookReady"] for row in rows), "repositories": rows, "url": url, "valuesPrinted": False}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
}
run_mirror_webhook_status() {
mirror_repos_json
github_api_env
bridge_ready=$(kubectl -n "$UNIDESK_GITEA_NAMESPACE" get deploy "$UNIDESK_GITEA_WEBHOOK_DEPLOYMENT" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0")
service_exists=$(kubectl -n "$UNIDESK_GITEA_NAMESPACE" get service "$UNIDESK_GITEA_WEBHOOK_SERVICE" -o name 2>/dev/null || true)
python3 - "$tmp/repos.json" "$bridge_ready" "$service_exists" <<'PY'
import json, os, sys, urllib.error, urllib.request
repos = json.load(open(sys.argv[1], encoding="utf-8"))
bridge_ready = sys.argv[2]
service_exists = bool(sys.argv[3])
token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"]
url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"]
def request(api_path):
req = urllib.request.Request("https://api.github.com" + api_path, method="GET")
req.add_header("Authorization", "Bearer " + token)
req.add_header("Accept", "application/vnd.github+json")
req.add_header("X-GitHub-Api-Version", "2022-11-28")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return {"ok": True, "status": resp.status, "body": resp.read().decode("utf-8", errors="replace")}
except urllib.error.HTTPError as exc:
return {"ok": False, "status": exc.code, "body": exc.read().decode("utf-8", errors="replace")[:1000]}
rows = []
for repo in repos:
repository = repo["upstream"]["repository"]
result = request(f"/repos/{repository}/hooks")
hooks = []
try:
hooks = json.loads(result.get("body") or "[]")
except Exception:
hooks = []
match = next((item for item in hooks if (item.get("config") or {}).get("url") == url), None)
rows.append({"key": repo["key"], "repository": repository, "hookId": match.get("id") if match else None, "hookReady": bool(match and match.get("active") is True), "active": match.get("active") if match else None, "status": result.get("status"), "error": None if result.get("ok") else result.get("body", "")[:500], "valuesPrinted": False})
bridge = {"deployment": os.environ["UNIDESK_GITEA_WEBHOOK_DEPLOYMENT"], "ready": bridge_ready not in ("0/0", "/") and bridge_ready.split("/")[0] == bridge_ready.split("/")[-1], "readyReplicas": bridge_ready, "serviceExists": service_exists}
payload = {"ok": bridge["ready"] and service_exists and all(row["hookReady"] for row in rows), "bridge": bridge, "repositories": rows, "url": url, "valuesPrinted": False}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
}
run_mirror_webhook_test() {
mirror_repos_json
repo_count=$(python3 - "$tmp/repos.json" <<'PY'
import json, sys
print(len(json.load(open(sys.argv[1], encoding="utf-8"))))
PY
)
if [ "$repo_count" != "1" ]; then
printf '{"ok":false,"error":"webhook test requires exactly one repo","valuesPrinted":false}\n'
exit 2
fi
repo_key=$(python3 - "$tmp/repos.json" <<'PY'
import json, sys
print(json.load(open(sys.argv[1], encoding="utf-8"))[0]["key"])
PY
)
github_api_env
python3 - "$tmp/repos.json" >"$tmp/webhook-ping.json" 2>"$tmp/webhook-ping.err" <<'PY'
import json, os, sys, time, urllib.error, urllib.request
repo = json.load(open(sys.argv[1], encoding="utf-8"))[0]
repository = repo["upstream"]["repository"]
token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"]
url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"]
def request(method, path, payload=None, expected=(200, 201, 204)):
data = None if payload is None else json.dumps(payload).encode()
req = urllib.request.Request("https://api.github.com" + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + token)
req.add_header("Accept", "application/vnd.github+json")
req.add_header("X-GitHub-Api-Version", "2022-11-28")
if payload is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return {"ok": resp.status in expected, "status": resp.status, "body": resp.read().decode("utf-8", errors="replace")}
except urllib.error.HTTPError as exc:
return {"ok": False, "status": exc.code, "body": exc.read().decode("utf-8", errors="replace")[:1000]}
hooks_result = request("GET", f"/repos/{repository}/hooks")
hooks = json.loads(hooks_result.get("body") or "[]") if hooks_result.get("ok") else []
hook = next((item for item in hooks if (item.get("config") or {}).get("url") == url), None)
hook_id = hook.get("id") if hook else None
ping = request("POST", f"/repos/{repository}/hooks/{hook_id}/pings", expected=(204,)) if hook_id else {"ok": False, "status": None, "body": "hook not found"}
delivery = None
if hook_id and ping.get("ok"):
time.sleep(3)
deliveries = request("GET", f"/repos/{repository}/hooks/{hook_id}/deliveries")
try:
rows = json.loads(deliveries.get("body") or "[]")
except Exception:
rows = []
delivery = next((item for item in rows if item.get("event") == "ping"), rows[0] if rows else None)
payload = {"ok": bool(ping.get("ok") and (delivery is None or delivery.get("status_code") in (200, 202))), "repository": repository, "hookId": hook_id, "pingStatus": ping.get("status"), "delivery": {"id": delivery.get("id"), "event": delivery.get("event"), "statusCode": delivery.get("status_code"), "status": delivery.get("status"), "deliveredAt": delivery.get("delivered_at")} if isinstance(delivery, dict) else None, "error": None if ping.get("ok") else ping.get("body"), "valuesPrinted": False}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
ping_rc=$?
repos_inline=$(cat "$tmp/repos.json")
kubectl -n "$UNIDESK_GITEA_NAMESPACE" exec deploy/"$UNIDESK_GITEA_WEBHOOK_DEPLOYMENT" -- node - "$UNIDESK_GITEA_WEBHOOK_PATH" "$repos_inline" >"$tmp/webhook-test.out" 2>"$tmp/webhook-test.err" <<'NODE'
const http = require('node:http');
const crypto = require('node:crypto');
const path = process.argv[2];
const repos = JSON.parse(process.argv[3]);
const repo = repos[0];
const payload = Buffer.from(JSON.stringify({ ref: `refs/heads/${repo.upstream.branch}`, repository: { full_name: repo.upstream.repository } }));
const signature = `sha256=${crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET).update(payload).digest('hex')}`;
const req = http.request({ hostname: '127.0.0.1', port: Number(process.env.UNIDESK_GITEA_WEBHOOK_PORT || '8080'), path, method: 'POST', headers: { 'content-type': 'application/json', 'content-length': payload.length, 'x-github-event': 'push', 'x-hub-signature-256': signature } }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
process.stdout.write(JSON.stringify({ statusCode: res.statusCode, body }) + '\n');
process.exit(res.statusCode >= 200 && res.statusCode < 300 ? 0 : 1);
});
});
req.on('error', (error) => { console.error(error.stack || error.message); process.exit(1); });
req.write(payload);
req.end();
NODE
test_rc=$?
run_mirror_status >"$tmp/status-after-test.json" 2>"$tmp/status-after-test.err"
status_rc=$?
python3 - "$ping_rc" "$test_rc" "$status_rc" "$repo_key" "$tmp/webhook-ping.json" "$tmp/webhook-ping.err" "$tmp/webhook-test.out" "$tmp/webhook-test.err" "$tmp/status-after-test.json" "$tmp/status-after-test.err" <<'PY'
import json, sys
ping_rc, test_rc, status_rc = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3])
repo_key = sys.argv[4]
def text(path, limit=2000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
try:
ping = json.loads(text(sys.argv[5], 100000))
except Exception:
ping = {"ok": False, "error": text(sys.argv[6])}
try:
status = json.loads(text(sys.argv[9], 100000))
except Exception:
status = {}
rows = []
for repo in status.get("repositories", []):
if repo.get("key") == repo_key:
rows.append({**repo, "pingOk": ping_rc == 0 and ping.get("ok") is True, "testOk": test_rc == 0, "error": None if ping_rc == 0 and test_rc == 0 and repo.get("sourceBundleReady") else (ping.get("error") or text(sys.argv[8]) or text(sys.argv[10]))[:500]})
payload = {"ok": ping_rc == 0 and test_rc == 0 and status_rc == 0 and all(row.get("sourceBundleReady") for row in rows), "repositories": rows, "githubPing": ping, "test": {"exitCode": test_rc, "stdoutTail": text(sys.argv[7]), "stderrTail": text(sys.argv[8])}, "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 ;;
@@ -580,5 +847,8 @@ case "$UNIDESK_GITEA_ACTION" in
mirror-bootstrap) run_mirror_bootstrap ;;
mirror-sync) run_mirror_sync ;;
mirror-status) run_mirror_status ;;
mirror-webhook-apply) run_mirror_webhook_apply ;;
mirror-webhook-status) run_mirror_webhook_status ;;
mirror-webhook-test) run_mirror_webhook_test ;;
*) printf '{"ok":false,"error":"unsupported-gitea-remote-action"}\n'; exit 2 ;;
esac
+467 -14
View File
@@ -8,17 +8,20 @@ import {
capture,
compactCapture,
createYamlFieldReader,
fingerprintValues,
parseJsonOutput,
readYamlRecord,
shQuote,
} from "./platform-infra-ops-library";
import { applyPk01CaddyBlock, prepareFrpcSecret, renderFrpcManifest } from "./platform-infra-public-service";
import { applyPk01CaddyBlock, escapeTomlString, prepareFrpcSecret, renderFrpcManifest } from "./platform-infra-public-service";
import type { PublicServiceExposure, PublicServiceTarget, FrpcSecretMaterial } from "./platform-infra-public-service";
import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy";
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
const configFile = rootPath("config", "platform-infra", "gitea.yaml");
const configLabel = "config/platform-infra/gitea.yaml";
const remoteScriptFile = rootPath("scripts", "src", "platform-infra-gitea-remote.sh");
const githubSyncServerFile = rootPath("scripts", "src", "platform-infra-gitea-github-sync-server.mjs");
const fieldManager = "unidesk-platform-infra-gitea";
const y = createYamlFieldReader(configLabel);
@@ -67,6 +70,7 @@ interface GiteaConfig {
url: string;
noProxy: string[];
};
webhookSync: GiteaWebhookSync;
responsibilities: GiteaSourceResponsibility[];
repositories: GiteaMirrorRepository[];
};
@@ -149,6 +153,32 @@ interface GiteaGithubCredential {
requiredFor: string[];
}
interface GiteaWebhookSync {
enabled: boolean;
direction: "github-to-gitea";
publicPath: string;
events: string[];
secret: {
sourceRef: string;
sourceKey: string;
createIfMissing: boolean;
};
bridge: {
deploymentName: string;
serviceName: string;
secretName: string;
configMapName: string;
image: string;
replicas: number;
httpPort: number;
serviceAccountName: string;
};
frpc: {
proxyName: string;
remotePort: number;
};
}
interface GiteaSourceResponsibility {
name: string;
current: string;
@@ -206,6 +236,7 @@ interface MirrorSecrets {
adminUsername: string;
adminPassword: string;
githubToken: string;
webhookSecret: string;
}
interface MirrorRemoteParams {
@@ -275,6 +306,7 @@ function readGiteaConfig(): GiteaConfig {
const sourceAuthority = y.objectField(root, "sourceAuthority", "");
const sourceCredentials = y.objectField(sourceAuthority, "credentials", "sourceAuthority");
const githubProxy = y.objectField(sourceAuthority, "githubProxy", "sourceAuthority");
const webhookSync = y.objectField(sourceAuthority, "webhookSync", "sourceAuthority");
const app = y.objectField(root, "app", "");
const image = y.objectField(app, "image", "app");
const service = y.objectField(app, "service", "app");
@@ -328,6 +360,7 @@ function readGiteaConfig(): GiteaConfig {
url: urlField(githubProxy, "url", "sourceAuthority.githubProxy"),
noProxy: y.stringArrayField(githubProxy, "noProxy", "sourceAuthority.githubProxy"),
},
webhookSync: parseWebhookSync(webhookSync, "sourceAuthority.webhookSync"),
responsibilities: y.arrayOfRecords(sourceAuthority.responsibilities, "sourceAuthority.responsibilities").map(parseResponsibility),
repositories: y.arrayOfRecords(sourceAuthority.repositories, "sourceAuthority.repositories").map(parseMirrorRepository),
},
@@ -417,6 +450,37 @@ function parseGithubCredential(record: Record<string, unknown>, path: string): G
};
}
function parseWebhookSync(record: Record<string, unknown>, path: string): GiteaWebhookSync {
const secret = y.objectField(record, "secret", path);
const bridge = y.objectField(record, "bridge", path);
const frpc = y.objectField(record, "frpc", path);
return {
enabled: y.booleanField(record, "enabled", path),
direction: y.enumField(record, "direction", path, ["github-to-gitea"] as const),
publicPath: y.apiPathField(record, "publicPath", path),
events: y.stringArrayField(record, "events", path),
secret: {
sourceRef: secretSourceRefField(secret, "sourceRef", `${path}.secret`),
sourceKey: y.envKeyField(secret, "sourceKey", `${path}.secret`),
createIfMissing: y.booleanField(secret, "createIfMissing", `${path}.secret`),
},
bridge: {
deploymentName: y.kubernetesNameField(bridge, "deploymentName", `${path}.bridge`),
serviceName: y.kubernetesNameField(bridge, "serviceName", `${path}.bridge`),
secretName: y.kubernetesNameField(bridge, "secretName", `${path}.bridge`),
configMapName: y.kubernetesNameField(bridge, "configMapName", `${path}.bridge`),
image: y.stringField(bridge, "image", `${path}.bridge`),
replicas: positiveInteger(bridge, "replicas", `${path}.bridge`),
httpPort: y.portField(bridge, "httpPort", `${path}.bridge`),
serviceAccountName: y.kubernetesNameField(bridge, "serviceAccountName", `${path}.bridge`),
},
frpc: {
proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`),
remotePort: y.portField(frpc, "remotePort", `${path}.frpc`),
},
};
}
function parsePublicExposure(record: Record<string, unknown>, path: string): PublicServiceExposure & { secretRoot: string } {
const dns = y.objectField(record, "dns", path);
const frpc = y.objectField(record, "frpc", path);
@@ -527,6 +591,11 @@ function validateConfig(gitea: GiteaConfig): void {
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`);
if (gitea.sourceAuthority.webhookSync.enabled) {
const events = new Set(gitea.sourceAuthority.webhookSync.events);
if (!events.has("push")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.events must include push for GitHub -> Gitea source sync`);
if (gitea.sourceAuthority.webhookSync.frpc.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel}.sourceAuthority.webhookSync.frpc.remotePort must differ from app.publicExposure.frpc.remotePort`);
}
const repoKeys = new Set<string>();
for (const repo of gitea.sourceAuthority.repositories) {
if (repoKeys.has(repo.key)) throw new Error(`${configLabel}.sourceAuthority.repositories contains duplicate key ${repo.key}`);
@@ -571,11 +640,12 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
const manifest = renderManifest(gitea, target);
const policy = policyChecks(gitea, target, manifest);
if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-gitea-apply", mode: "policy-blocked", mutation: false, policy };
const frpcSecret = prepareGiteaFrpcSecret(gitea);
const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options, { frpcSecret }));
const frpcSecret = prepareGiteaFrpcSecret(gitea, target);
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun ? ensureMirrorSecrets(gitea, true, true) : undefined;
const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets }));
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)
? await applyGiteaCaddyBlock(config, gitea)
: { ok: true, skipped: true, reason: options.dryRun ? "dry-run" : "apply-not-ready" };
return {
ok: result.exitCode === 0 && parsed?.ok === true && record(caddy).ok !== false,
@@ -586,7 +656,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
config: compactConfigSummary(gitea, target),
policy,
publicExposure: publicExposureSummary(gitea),
secrets: { frpc: frpcSecretSummary(frpcSecret) },
secrets: { frpc: frpcSecretSummary(frpcSecret), webhookSync: secrets === undefined ? { enabled: false } : webhookSecretSummary(gitea, secrets) },
pk01Caddy: caddy,
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id),
@@ -652,6 +722,9 @@ async function mirrorCommand(config: UniDeskConfig, args: string[]): Promise<Rec
const result = await mirrorStatus(config, options);
return options.full || options.raw ? result : renderMirrorStatus(result);
}
if (action === "webhook") {
return await mirrorWebhookCommand(config, args.slice(1));
}
return {
ok: false,
error: "unsupported-platform-infra-gitea-mirror-command",
@@ -662,11 +735,38 @@ async function mirrorCommand(config: UniDeskConfig, args: string[]): Promise<Rec
"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 webhook apply --target JD01 --confirm",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01",
"bun scripts/cli.ts platform-infra gitea mirror webhook test --target JD01 --repo <key> --confirm",
],
},
};
}
async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "status"] = args;
if (action === "apply") {
const options = parseMirrorOptions(args.slice(1));
const result = await mirrorWebhookApply(config, options);
return options.full || options.raw ? result : renderMirrorWebhookApply(result);
}
if (action === "status") {
const options = parseMirrorOptions(args.slice(1));
const result = await mirrorWebhookStatus(config, options);
return options.full || options.raw ? result : renderMirrorWebhookStatus(result);
}
if (action === "test") {
const options = parseMirrorOptions(args.slice(1));
const result = await mirrorWebhookTest(config, options);
return options.full || options.raw ? result : renderMirrorWebhookTest(result);
}
return {
ok: false,
error: "unsupported-platform-infra-gitea-mirror-webhook-command",
usage: "platform-infra gitea mirror webhook apply|status|test --target <node> [--repo <key>] [--confirm]",
};
}
function mirrorPlan(options: CommonOptions): Record<string, unknown> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
@@ -689,7 +789,7 @@ async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { re
const health = await validate(config, options);
const healthValidation = record(health.validation);
const credentials = credentialSummaries(gitea);
const secrets = ensureMirrorSecrets(gitea, false);
const secrets = ensureMirrorSecrets(gitea, false, false);
const selectedRepos = selectedRepositories(gitea, target, options.repoKey ?? null);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos: selectedRepos, secrets }));
const parsed = parseJsonOutput(result.stdout);
@@ -724,7 +824,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
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 secrets = ensureMirrorSecrets(gitea, true, 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 {
@@ -744,7 +844,7 @@ async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promis
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 secrets = ensureMirrorSecrets(gitea, false, 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 {
@@ -759,6 +859,69 @@ async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promis
};
}
async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
if (!gitea.sourceAuthority.webhookSync.enabled) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, error: "sourceAuthority.webhookSync.enabled is false" };
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, mode: "missing-confirm", error: "mirror webhook apply requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-apply", 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-webhook-apply",
mutation: true,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea),
repositories: arrayRecords(record(parsed).repositories),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
};
}
async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }));
const parsed = parseJsonOutput(result.stdout);
const repositories = arrayRecords(record(parsed).repositories);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-gitea-mirror-webhook-status",
mutation: false,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea),
repositories,
bridge: record(parsed).bridge,
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
};
}
async function mirrorWebhookTest(config: UniDeskConfig, options: MirrorOptions): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveTarget(gitea, options.targetId);
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-webhook-test", mutation: false, mode: "missing-confirm", error: "mirror webhook test requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
if (repos.length !== 1) throw new Error("mirror webhook test requires exactly one --repo <key>");
const secrets = ensureMirrorSecrets(gitea, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-test", 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-webhook-test",
mutation: true,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea),
repositories: arrayRecords(record(parsed).repositories),
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}`;
@@ -918,7 +1081,139 @@ ${envVars(gitea, target)}
resources:
requests:
storage: ${app.storage.config.size}
${renderFrpcManifest(publicServiceTarget(gitea, target))}`;
${renderFrpcManifest(publicServiceTarget(gitea, target))}
${renderGithubSyncManifest(gitea, target)}`;
}
function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): string {
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return "";
const labels = ` app.kubernetes.io/name: ${sync.bridge.deploymentName}
app.kubernetes.io/component: github-to-gitea-sync
app.kubernetes.io/part-of: devops-infra
app.kubernetes.io/managed-by: unidesk`;
const reposJson = JSON.stringify(repositoriesForTarget(gitea, target).map(remoteRepoSpec), null, 2);
const serverSource = readFileSync(githubSyncServerFile, "utf8");
const configHash = createHash("sha256").update(reposJson).update("\0").update(serverSource).digest("hex").slice(0, 16);
return `---
apiVersion: v1
kind: ConfigMap
metadata:
name: ${sync.bridge.configMapName}
namespace: ${target.namespace}
labels:
${labels}
data:
repos.json: |
${indentBlock(reposJson, 4)}
server.mjs: |
${indentBlock(serverSource, 4)}
---
apiVersion: v1
kind: Service
metadata:
name: ${sync.bridge.serviceName}
namespace: ${target.namespace}
labels:
${labels}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: ${sync.bridge.deploymentName}
app.kubernetes.io/component: github-to-gitea-sync
ports:
- name: http
port: ${sync.bridge.httpPort}
targetPort: http
protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${sync.bridge.deploymentName}
namespace: ${target.namespace}
labels:
${labels}
annotations:
unidesk.ai/sync-direction: ${yamlQuote(sync.direction)}
unidesk.ai/public-path: ${yamlQuote(sync.publicPath)}
spec:
replicas: ${sync.bridge.replicas}
selector:
matchLabels:
app.kubernetes.io/name: ${sync.bridge.deploymentName}
app.kubernetes.io/component: github-to-gitea-sync
template:
metadata:
labels:
app.kubernetes.io/name: ${sync.bridge.deploymentName}
app.kubernetes.io/component: github-to-gitea-sync
app.kubernetes.io/part-of: devops-infra
app.kubernetes.io/managed-by: unidesk
annotations:
unidesk.ai/github-sync-config-sha: ${yamlQuote(configHash)}
spec:
containers:
- name: github-to-gitea-sync
image: ${sync.bridge.image}
imagePullPolicy: IfNotPresent
command:
- node
- /etc/gitea-github-sync/server.mjs
ports:
- name: http
containerPort: ${sync.bridge.httpPort}
env:
- name: UNIDESK_GITEA_WEBHOOK_PORT
value: ${yamlQuote(String(sync.bridge.httpPort))}
- name: UNIDESK_GITEA_WEBHOOK_PATH
value: ${yamlQuote(sync.publicPath)}
- name: UNIDESK_GITEA_REPOS_PATH
value: /etc/gitea-github-sync/repos.json
- name: UNIDESK_GITEA_SERVICE_BASE_URL
value: ${yamlQuote(`http://${gitea.app.serviceName}.${target.namespace}.svc.cluster.local:${gitea.app.service.httpPort}`)}
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: github-token
- name: GITEA_USERNAME
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: gitea-username
- name: GITEA_PASSWORD
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: gitea-password
- name: GITHUB_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: github-webhook-secret
- name: HTTPS_PROXY
value: ${yamlQuote(gitea.sourceAuthority.githubProxy.enabled ? gitea.sourceAuthority.githubProxy.url : "")}
- name: HTTP_PROXY
value: ${yamlQuote(gitea.sourceAuthority.githubProxy.enabled ? gitea.sourceAuthority.githubProxy.url : "")}
- name: NO_PROXY
value: ${yamlQuote(gitea.sourceAuthority.githubProxy.noProxy.join(","))}
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
volumeMounts:
- name: config
mountPath: /etc/gitea-github-sync
readOnly: true
volumes:
- name: config
configMap:
name: ${sync.bridge.configMapName}
`;
}
function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
@@ -947,7 +1242,7 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
value: ${yamlQuote(value)}`).join("\n");
}
function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): string {
function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status" | "mirror-webhook-test", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): string {
const env: Record<string, string> = {
UNIDESK_GITEA_ACTION: action,
UNIDESK_GITEA_TARGET_ID: target.id,
@@ -971,6 +1266,15 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
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",
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: gitea.app.publicExposure.frpc.deploymentName,
UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED: gitea.sourceAuthority.webhookSync.enabled ? "1" : "0",
UNIDESK_GITEA_WEBHOOK_PUBLIC_URL: githubWebhookPublicUrl(gitea),
UNIDESK_GITEA_WEBHOOK_PATH: gitea.sourceAuthority.webhookSync.publicPath,
UNIDESK_GITEA_WEBHOOK_EVENTS: gitea.sourceAuthority.webhookSync.events.join(","),
UNIDESK_GITEA_WEBHOOK_DEPLOYMENT: gitea.sourceAuthority.webhookSync.bridge.deploymentName,
UNIDESK_GITEA_WEBHOOK_SERVICE: gitea.sourceAuthority.webhookSync.bridge.serviceName,
UNIDESK_GITEA_WEBHOOK_SERVICE_PORT: String(gitea.sourceAuthority.webhookSync.bridge.httpPort),
UNIDESK_GITEA_WEBHOOK_SECRET_NAME: gitea.sourceAuthority.webhookSync.bridge.secretName,
};
if (params.frpcSecret !== undefined) {
env.UNIDESK_GITEA_FRPC_SECRET_NAME = params.frpcSecret.secretName;
@@ -981,6 +1285,7 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername;
env.UNIDESK_GITEA_ADMIN_PASSWORD = params.secrets.adminPassword;
env.UNIDESK_GITEA_GITHUB_TOKEN = params.secrets.githubToken;
env.UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET = params.secrets.webhookSecret;
}
const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n");
return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`;
@@ -1062,8 +1367,8 @@ function publicServiceTarget(gitea: GiteaConfig, target: GiteaTarget): PublicSer
};
}
function prepareGiteaFrpcSecret(gitea: GiteaConfig): FrpcSecretMaterial {
return prepareFrpcSecret({
function prepareGiteaFrpcSecret(gitea: GiteaConfig, target: GiteaTarget): FrpcSecretMaterial {
const base = prepareFrpcSecret({
secretRoot: gitea.app.publicExposure.secretRoot,
exposure: gitea.app.publicExposure,
sourcePathRedactor: redactRepoPath,
@@ -1071,6 +1376,47 @@ function prepareGiteaFrpcSecret(gitea: GiteaConfig): FrpcSecretMaterial {
requiredEnvValue,
readTextFile,
});
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return base;
const extraProxy = [
"[[proxies]]",
`name = "${escapeTomlString(sync.frpc.proxyName)}"`,
'type = "tcp"',
`localIP = "${escapeTomlString(sync.bridge.serviceName)}.${escapeTomlString(target.namespace)}.svc.cluster.local"`,
`localPort = ${sync.bridge.httpPort}`,
`remotePort = ${sync.frpc.remotePort}`,
"",
].join("\n");
const frpcToml = `${base.frpcToml.trim()}\n\n${extraProxy}`;
return {
...base,
frpcToml,
fingerprint: fingerprintValues({ frpcToml }, ["frpcToml"]),
};
}
async function applyGiteaCaddyBlock(config: UniDeskConfig, gitea: GiteaConfig): Promise<Record<string, unknown>> {
const exposure = gitea.app.publicExposure;
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return await applyPk01CaddyBlock(config, "gitea", exposure);
const siteBlock = `${exposure.dns.hostname} {
handle ${sync.publicPath}* {
reverse_proxy 127.0.0.1:${sync.frpc.remotePort} {
transport http {
response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s
}
}
}
handle {
reverse_proxy 127.0.0.1:${exposure.frpc.remotePort} {
transport http {
response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s
}
}
}
}`;
return await applyPk01CaddySiteBlock(config, "gitea", exposure, siteBlock, caddyManagedBlockMarkers("gitea"));
}
function publicExposureSummary(gitea: GiteaConfig): Record<string, unknown> {
@@ -1103,6 +1449,24 @@ function frpcSecretSummary(secret: FrpcSecretMaterial): Record<string, unknown>
};
}
function webhookSecretSummary(gitea: GiteaConfig, secrets: MirrorSecrets): Record<string, unknown> {
const sync = gitea.sourceAuthority.webhookSync;
return {
enabled: sync.enabled,
publicUrl: githubWebhookPublicUrl(gitea),
sourceRef: sync.secret.sourceRef,
targetSecret: sync.bridge.secretName,
keys: ["github-token", "gitea-username", "gitea-password", "github-webhook-secret"],
fingerprint: fingerprintSecretParts([secrets.webhookSecret]),
valuesPrinted: false,
};
}
function githubWebhookPublicUrl(gitea: GiteaConfig): string {
const base = gitea.app.publicExposure.publicBaseUrl.replace(/\/+$/u, "");
return `${base}${gitea.sourceAuthority.webhookSync.publicPath}`;
}
function statusSummary(payload: Record<string, unknown>): Record<string, unknown> {
return {
ready: payload.ready === true,
@@ -1183,10 +1547,25 @@ function mirrorNextCommands(targetId: string): Record<string, string> {
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}`,
webhookApply: `bun scripts/cli.ts platform-infra gitea mirror webhook apply --target ${targetId} --confirm`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
full: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`,
};
}
function webhookSyncSummary(gitea: GiteaConfig): Record<string, unknown> {
const sync = gitea.sourceAuthority.webhookSync;
return {
enabled: sync.enabled,
direction: sync.direction,
publicUrl: githubWebhookPublicUrl(gitea),
events: sync.events,
bridge: sync.bridge,
frpc: sync.frpc,
valuesPrinted: false,
};
}
function credentialSummaries(gitea: GiteaConfig): Array<Record<string, unknown>> {
const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef);
const githubPath = credentialPath(gitea, gitea.sourceAuthority.credentials.github.sourceRef);
@@ -1220,7 +1599,7 @@ function credentialBlockers(credentials: Array<Record<string, unknown>>): string
return credentials.filter((item) => item.present !== true || item.requiredKeysPresent !== true).map((item) => String(item.id));
}
function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean): MirrorSecrets {
function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean, createWebhookSecret: boolean): MirrorSecrets {
const admin = gitea.sourceAuthority.credentials.admin;
const adminPath = credentialPath(gitea, admin.sourceRef);
if (!existsSync(adminPath)) {
@@ -1236,12 +1615,29 @@ function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean): MirrorSe
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 webhookSecret = ensureWebhookSecret(gitea, createWebhookSecret);
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 };
return { adminUsername, adminPassword, githubToken, webhookSecret };
}
function ensureWebhookSecret(gitea: GiteaConfig, createIfMissing: boolean): string {
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return "";
const path = credentialPath(gitea, sync.secret.sourceRef);
if (!existsSync(path)) {
if (!createIfMissing || !sync.secret.createIfMissing) throw new Error(`${sync.secret.sourceRef} is missing; run platform-infra gitea apply --target <node> --confirm first`);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${sync.secret.sourceKey}=ghs_${randomBytes(32).toString("base64url")}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(path, 0o600);
}
const values = parseEnvFileSafe(path);
const value = values[sync.secret.sourceKey];
if (!value) throw new Error(`${sync.secret.sourceRef} must contain ${sync.secret.sourceKey}`);
return value;
}
function credentialPath(gitea: GiteaConfig, sourceRef: string): string {
@@ -1382,6 +1778,52 @@ function renderMirrorStatus(result: Record<string, unknown>): RenderedCliResult
]);
}
function renderMirrorWebhookApply(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.repository), boolText(repo.hookReady), stringValue(repo.hookId), compactTail(stringValue(repo.error))]);
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook apply", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK APPLY",
...table(["OK", "MUTATION", "TARGET", "URL"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id), stringValue(record(result.webhook).publicUrl)]]),
"",
"GITHUB HOOKS",
...(repos.length === 0 ? ["-"] : table(["KEY", "REPOSITORY", "READY", "HOOK_ID", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookStatus)}`,
]);
}
function renderMirrorWebhookStatus(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.repository), boolText(repo.hookReady), stringValue(repo.hookId), stringValue(repo.active), compactTail(stringValue(repo.error))]);
const bridge = record(result.bridge);
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook status", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS",
...table(["TARGET", "BRIDGE", "READY", "URL"], [[stringValue(record(result.target).id), stringValue(bridge.deployment), boolText(bridge.ready), stringValue(record(result.webhook).publicUrl)]]),
"",
"GITHUB HOOKS",
...(repos.length === 0 ? ["-"] : table(["KEY", "REPOSITORY", "READY", "HOOK_ID", "ACTIVE", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookApply)}`,
]);
}
function renderMirrorWebhookTest(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), boolText(repo.pingOk), boolText(repo.testOk), boolText(repo.sourceBundleReady), stringValue(repo.sourceCommit ?? repo.branchCommit).slice(0, 12), stringValue(repo.snapshotCommit).slice(0, 12), compactTail(stringValue(repo.error))]);
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook test", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK TEST",
...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]),
"",
"TEST",
...(repos.length === 0 ? ["-"] : table(["KEY", "GH_PING", "POST_OK", "SNAPSHOT_READY", "SOURCE", "SNAPSHOT", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookStatus)}`,
]);
}
function renderPlan(result: Record<string, unknown>): RenderedCliResult {
const config = record(result.config);
@@ -1420,8 +1862,11 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
const remote = record(result.remote);
const steps = record(remote.steps);
const frpcStep = record(steps.frpcSecret);
const webhookSecretStep = record(steps.webhookSyncSecret);
const applyStep = record(steps.apply);
const rolloutStep = record(steps.rollout);
const frpcRolloutStep = record(steps.frpcRollout);
const webhookRolloutStep = record(steps.webhookSyncRollout);
const caddy = record(result.pk01Caddy);
return rendered(result, "platform-infra gitea apply", [
"PLATFORM-INFRA GITEA APPLY",
@@ -1430,8 +1875,11 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
"STEPS",
...table(["STEP", "EXIT", "DETAIL"], [
["frpc-secret", stringValue(frpcStep.exitCode), compactLine(stringValue(frpcStep.stderrTail, stringValue(frpcStep.stdoutTail)))],
["webhook-secret", stringValue(webhookSecretStep.exitCode), compactLine(stringValue(webhookSecretStep.stderrTail, stringValue(webhookSecretStep.stdoutTail)))],
["apply", stringValue(applyStep.exitCode), compactLine(stringValue(applyStep.stderrTail, stringValue(applyStep.stdoutTail)))],
["rollout", stringValue(rolloutStep.exitCode), compactLine(stringValue(rolloutStep.stderrTail, stringValue(rolloutStep.stdoutTail)))],
["frpc-rollout", stringValue(frpcRolloutStep.exitCode), compactLine(stringValue(frpcRolloutStep.stderrTail, stringValue(frpcRolloutStep.stdoutTail)))],
["webhook-rollout", stringValue(webhookRolloutStep.exitCode), compactLine(stringValue(webhookRolloutStep.stderrTail, stringValue(webhookRolloutStep.stdoutTail)))],
["pk01-caddy", stringValue(caddy.exitCode ?? (caddy.ok === false ? 1 : 0)), compactLine(stringValue(caddy.error, stringValue(caddy.status, stringValue(caddy.reason))))],
]),
...remoteErrorLines(result),
@@ -1660,6 +2108,11 @@ function yamlQuote(value: string): string {
return JSON.stringify(value);
}
function indentBlock(value: string, spaces: number): string {
const prefix = " ".repeat(spaces);
return value.split(/\r?\n/u).map((line) => `${prefix}${line}`).join("\n");
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}