fix: 持久化 GitHub 到 Gitea 源码权威

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-07-11 11:12:39 +02:00
parent b125fb358d
commit 9352f10d0d
24 changed files with 4355 additions and 425 deletions
+4 -7
View File
@@ -772,13 +772,10 @@ function platformInfraHelpSummary(): unknown {
"bun scripts/cli.ts platform-infra wechat-archive pull --remote-path /UniDesk/WeChatArchive/...",
"bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full",
"bun scripts/cli.ts platform-infra wechat-archive collector-status --full",
"bun scripts/cli.ts platform-infra gitea plan --target JD01",
"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",
"bun scripts/cli.ts platform-infra gitea status --target NC01",
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01",
],
description: "Operate platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, Web Terminal, WeChat archive workflows, the YAML-controlled Codex pool, and internal Gitea for the GH-1548/GH-1549 CI/CD migration.",
};
@@ -0,0 +1,72 @@
import { expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { assertNoDuplicateYamlMappingKeys } from "./platform-infra-ops-library";
const repositoryRoot = resolve(import.meta.dir, "../..");
test("Gitea plan renders a single-writer durable inbox PVC from owning YAML", () => {
const result = cli(["platform-infra", "gitea", "plan", "--target", "NC01", "--raw"]);
expect(result.status).toBe(0);
const data = result.payload.data;
expect(data.ok).toBe(true);
expect(data.renderPlan.objects).toContainEqual({
kind: "PersistentVolumeClaim",
name: "gitea-github-sync-inbox",
namespace: "devops-infra",
});
expect(data.policy).toContainEqual(expect.objectContaining({ name: "durable-inbox-single-writer", ok: true }));
expect(data.policy).toContainEqual(expect.objectContaining({ name: "durable-inbox-pvc", ok: true }));
});
test("manual mirror sync and synthetic webhook delivery fail before remote capture", () => {
const sync = cli(["platform-infra", "gitea", "mirror", "sync", "--target", "NC01", "--confirm", "--raw"]);
expect(sync.status).not.toBe(0);
expect(sync.payload.data).toMatchObject({
ok: false,
mutation: false,
mode: "automatic-source-authority-manual-sync-disabled",
});
expect(sync.payload.data.remote).toBeUndefined();
const webhookTest = cli(["platform-infra", "gitea", "mirror", "webhook", "test", "--target", "NC01", "--repo", "unidesk-master-nc01", "--confirm", "--raw"]);
expect(webhookTest.status).not.toBe(0);
expect(webhookTest.payload.data).toMatchObject({
ok: false,
mutation: false,
mode: "manual-source-delivery-test-disabled",
});
expect(webhookTest.payload.data.remote).toBeUndefined();
});
test("YAML parser rejects duplicate mapping keys before silent overwrite", () => {
expect(() => assertNoDuplicateYamlMappingKeys(`sourceAuthority:
repositories:
- key: agentrun-jd01-v02
legacyGitMirror:
configRef: first
configRef: second
`, "duplicate.yaml")).toThrow(/duplicate YAML mapping key configRef.*first declared at line 5/);
});
test("default platform-infra help exposes only read-only Gitea authority commands", () => {
const result = cli(["platform-infra", "gitea", "--help"]);
expect(result.status).toBe(0);
const giteaUsage = (result.payload.data.usage as string[]).filter((line) => line.includes("platform-infra gitea "));
expect(giteaUsage).toEqual([
"bun scripts/cli.ts platform-infra gitea status --target NC01",
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01",
]);
});
function cli(args: string[]): { status: number | null; payload: Record<string, any> } {
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
cwd: repositoryRoot,
encoding: "utf8",
env: { ...process.env, NO_COLOR: "1" },
});
expect(result.stdout).not.toBe("");
return { status: result.status, payload: JSON.parse(result.stdout) };
}
@@ -0,0 +1,211 @@
import { afterEach, expect, test } from "bun:test";
import { spawn, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle, validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";
const caddyBin = process.env.CADDY_BIN;
const integrationTest = caddyBin ? test : test.skip;
const cleanup: Array<() => void | Promise<void>> = [];
afterEach(async () => {
while (cleanup.length > 0) await cleanup.pop()?.();
});
test("one YAML response budget owns Caddy retry and bridge cannot reset it", () => {
const timing = { maxRetries: 1, tryIntervalMs: 250, dialTimeoutMs: 500, writeTimeoutMs: 500 };
expect(() => validateGiteaWebhookTiming(6500, timing)).not.toThrow();
expect(() => validateGiteaWebhookTiming(9000, timing)).toThrow(/below 9000/);
expect(() => validateGiteaWebhookTiming(6500, { ...timing, maxRetries: 2 })).toThrow(/maxRetries must be 1/);
expect(deriveGiteaWebhookAttemptBudgetMs(6500, { maxRetries: 1, tryIntervalMs: 250 })).toBe(3125);
expect(deriveGiteaWebhookResponseHeaderTimeoutMs(6500, timing)).toBe(2125);
const route = renderGiteaWebhookCaddyHandle(
{ publicPath: "/hook", remotePort: 8080 },
{ enabled: true, maxBodyBytes: 64, ...timing, retryStatusCodes: [502, 503, 504] },
6500,
600,
);
expect(route).toContain("lb_try_duration 6500ms");
expect(route).toContain("dial_timeout 500ms");
expect(route).toContain("write_timeout 500ms");
expect(route).toContain("response_header_timeout 2125ms");
expect(route).toContain("request_buffers 65");
const disabled = renderGiteaWebhookCaddyHandle(
{ publicPath: "/hook", remotePort: 8080 },
{ enabled: false, maxBodyBytes: 64, ...timing, retryStatusCodes: [502, 503, 504] },
6500,
600,
);
expect(disabled).toContain("response_header_timeout 600s");
expect(disabled).not.toContain("lb_retries");
});
integrationTest("Caddy replays exact POST bytes for retryable responses and rejects oversized bodies with 413", async () => {
const version = spawnSync(caddyBin!, ["version"], { encoding: "utf8" });
expect(`${version.stdout}${version.stderr}`).toMatch(/v2\.11\./);
const backendPort = await freePort();
const caddyPort = await freePort();
let mode: "retry-once" | "client-error" | "exhaust" | "slow-budget" = "retry-once";
let hits = 0;
let bodies: Buffer[] = [];
const backendTimers = new Set<ReturnType<typeof setTimeout>>();
const backendHandler = (req: IncomingMessage, res: ServerResponse) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => {
hits += 1;
const body = Buffer.concat(chunks);
bodies.push(body);
if (mode === "slow-budget" && hits === 1) {
const timer = setTimeout(() => {
backendTimers.delete(timer);
res.writeHead(503).end("slow-retry");
}, 180);
backendTimers.add(timer);
} else if (mode === "slow-budget") {
const timer = setTimeout(() => {
backendTimers.delete(timer);
if (!res.destroyed) res.writeHead(200).end("too-late");
}, 600);
backendTimers.add(timer);
} else if (mode === "retry-once" && hits === 1) {
res.writeHead(503).end("retry");
} else if (mode === "client-error") {
res.writeHead(422).end("invalid");
} else if (mode === "exhaust") {
res.writeHead(503).end("unavailable");
} else {
res.writeHead(200, { "content-type": "application/octet-stream" }).end(body);
}
});
};
let backend = createServer(backendHandler);
await listen(backend, backendPort);
cleanup.push(() => close(backend));
const root = mkdtempSync(join(tmpdir(), "unidesk-caddy-retry-test-"));
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const route = renderGiteaWebhookCaddyHandle(
{ publicPath: "/hook", remotePort: backendPort },
{ enabled: true, maxBodyBytes: 64, maxRetries: 1, tryIntervalMs: 50, dialTimeoutMs: 20, writeTimeoutMs: 20, retryStatusCodes: [502, 503, 504] },
500,
600,
);
const caddyfile = join(root, "Caddyfile");
writeFileSync(caddyfile, `{
admin off
auto_https off
}
http://127.0.0.1:${caddyPort} {
${route}
}
`);
const caddy = spawn(caddyBin!, ["run", "--config", caddyfile, "--adapter", "caddyfile"], { stdio: ["ignore", "pipe", "pipe"] });
const stderr: Buffer[] = [];
caddy.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
cleanup.push(async () => {
caddy.kill("SIGTERM");
await Promise.race([new Promise((resolve) => caddy.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 1000))]);
});
await waitForCaddy(caddyPort, stderr);
const exactBody = Buffer.from(Array.from({ length: 64 }, (_, index) => index));
let response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
expect(response.status).toBe(200);
expect(Buffer.from(await response.arrayBuffer())).toEqual(exactBody);
expect(hits).toBe(2);
expect(bodies).toEqual([exactBody, exactBody]);
mode = "client-error";
hits = 0;
bodies = [];
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
expect(response.status).toBe(422);
expect(hits).toBe(1);
mode = "exhaust";
hits = 0;
bodies = [];
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
expect(response.status).toBe(503);
expect(hits).toBe(2);
expect(bodies).toEqual([exactBody, exactBody]);
hits = 0;
bodies = [];
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: Buffer.alloc(65, 0x5a) });
expect(response.status).toBe(413);
expect(hits).toBe(0);
mode = "retry-once";
hits = 1;
bodies = [];
await close(backend);
const recoveringRequest = fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
await new Promise((resolve) => setTimeout(resolve, 25));
backend = createServer(backendHandler);
await listen(backend, backendPort);
response = await recoveringRequest;
expect(response.status).toBe(200);
expect(Buffer.from(await response.arrayBuffer())).toEqual(exactBody);
expect(bodies).toEqual([exactBody]);
mode = "slow-budget";
hits = 0;
bodies = [];
const startedAt = performance.now();
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
const elapsedMs = performance.now() - startedAt;
expect([502, 504]).toContain(response.status);
expect(hits).toBe(2);
expect(elapsedMs).toBeGreaterThanOrEqual(350);
expect(elapsedMs).toBeLessThan(700);
for (const timer of backendTimers) clearTimeout(timer);
backendTimers.clear();
await close(backend);
hits = 0;
const unavailableStartedAt = performance.now();
response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
const unavailableElapsedMs = performance.now() - unavailableStartedAt;
expect(response.status).toBe(502);
expect(hits).toBe(0);
expect(unavailableElapsedMs).toBeLessThan(700);
});
async function freePort(): Promise<number> {
const server = createServer();
await listen(server, 0);
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
await close(server);
return port;
}
async function listen(server: Server, port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", resolve);
});
}
async function close(server: Server): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
async function waitForCaddy(port: number, stderr: Buffer[]): Promise<void> {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
try {
await fetch(`http://127.0.0.1:${port}/not-a-hook`);
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 30));
}
}
throw new Error(`Caddy did not start: ${Buffer.concat(stderr).toString("utf8")}`);
}
+97
View File
@@ -0,0 +1,97 @@
export interface GiteaWebhookIngressRetry {
enabled: boolean;
maxBodyBytes: number;
maxRetries: number;
tryIntervalMs: number;
dialTimeoutMs: number;
writeTimeoutMs: number;
retryStatusCodes: number[];
}
export interface GiteaWebhookCaddyRoute {
publicPath: string;
remotePort: number;
}
export function validateGiteaWebhookTiming(
responseBudgetMs: number,
ingressRetry: Pick<GiteaWebhookIngressRetry, "maxRetries" | "tryIntervalMs" | "dialTimeoutMs" | "writeTimeoutMs">,
): void {
if (responseBudgetMs >= 9000) {
throw new Error("responseBudgetMs must stay below 9000 to leave margin under GitHub's 10 second webhook deadline");
}
if (ingressRetry.maxRetries !== 1) {
throw new Error("ingressRetry.maxRetries must be 1 so the fsync acceptance path has one bounded replay");
}
const responseHeaderTimeoutMs = deriveGiteaWebhookResponseHeaderTimeoutMs(responseBudgetMs, ingressRetry);
const hardBoundMs = (ingressRetry.dialTimeoutMs + ingressRetry.writeTimeoutMs + responseHeaderTimeoutMs) * (ingressRetry.maxRetries + 1)
+ ingressRetry.tryIntervalMs * ingressRetry.maxRetries;
if (hardBoundMs > responseBudgetMs) {
throw new Error("Caddy retry attempts and intervals must fit inside responseBudgetMs");
}
}
export function deriveGiteaWebhookAttemptBudgetMs(
responseBudgetMs: number,
ingressRetry: Pick<GiteaWebhookIngressRetry, "maxRetries" | "tryIntervalMs">,
): number {
const attempts = ingressRetry.maxRetries + 1;
const intervalBudgetMs = ingressRetry.maxRetries * ingressRetry.tryIntervalMs;
const attemptBudgetMs = Math.floor((responseBudgetMs - intervalBudgetMs) / attempts);
if (!Number.isFinite(attemptBudgetMs) || attemptBudgetMs < 1) {
throw new Error("responseBudgetMs must leave a positive response-header timeout for every Caddy attempt");
}
return attemptBudgetMs;
}
export function deriveGiteaWebhookResponseHeaderTimeoutMs(
responseBudgetMs: number,
ingressRetry: Pick<GiteaWebhookIngressRetry, "maxRetries" | "tryIntervalMs" | "dialTimeoutMs" | "writeTimeoutMs">,
): number {
const attemptBudgetMs = deriveGiteaWebhookAttemptBudgetMs(responseBudgetMs, ingressRetry);
const responseHeaderTimeoutMs = attemptBudgetMs - ingressRetry.dialTimeoutMs - ingressRetry.writeTimeoutMs;
if (!Number.isFinite(responseHeaderTimeoutMs) || responseHeaderTimeoutMs < 1) {
throw new Error("dialTimeoutMs and writeTimeoutMs must leave a positive response-header timeout in every Caddy attempt");
}
return responseHeaderTimeoutMs;
}
export function renderGiteaWebhookCaddyHandle(
route: GiteaWebhookCaddyRoute,
retry: GiteaWebhookIngressRetry,
responseBudgetMs: number,
legacyResponseHeaderTimeoutSeconds: number,
): string {
if (!retry.enabled) {
return ` handle ${route.publicPath}* {
reverse_proxy 127.0.0.1:${route.remotePort} {
transport http {
response_header_timeout ${legacyResponseHeaderTimeoutSeconds}s
}
}
}`;
}
validateGiteaWebhookTiming(responseBudgetMs, retry);
const responseHeaderTimeoutMs = deriveGiteaWebhookResponseHeaderTimeoutMs(responseBudgetMs, retry);
const replayBufferBytes = retry.maxBodyBytes + 1;
const retryStatuses = retry.retryStatusCodes.join(", ");
return ` handle ${route.publicPath}* {
request_body {
max_size ${retry.maxBodyBytes}
}
reverse_proxy 127.0.0.1:${route.remotePort} {
request_buffers ${replayBufferBytes}
lb_retries ${retry.maxRetries}
lb_try_duration ${responseBudgetMs}ms
lb_try_interval ${retry.tryIntervalMs}ms
lb_retry_match {
expression \`method('POST') && ({rp.is_transport_error} || {rp.status_code} in [${retryStatuses}])\`
}
transport http {
dial_timeout ${retry.dialTimeoutMs}ms
write_timeout ${retry.writeTimeoutMs}ms
response_header_timeout ${responseHeaderTimeoutMs}ms
}
}
}`;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { readGiteaWebhookGitOpsDelivery, renderGiteaWebhookSyncDesiredManifest } from "./platform-infra-gitea";
import { renderPlatformInfraGiteaApplication } from "../native/cicd/publish-platform-infra-gitea-gitops.mjs";
const root = resolve(import.meta.dir, "../..");
test("existing NC01 Repository can bootstrap the independent desired publisher without new RBAC", () => {
const pipeline = Bun.YAML.parse(readFileSync(resolve(root, ".tekton/platform-infra-gitea-nc01-pac.yaml"), "utf8")) as any;
expect(pipeline.metadata.annotations).toMatchObject({
"pipelinesascode.tekton.dev/on-event": "[push]",
"pipelinesascode.tekton.dev/on-target-branch": "[master]",
});
expect(pipeline.metadata.annotations["pipelinesascode.tekton.dev/on-cel-expression"]).toContain("node == 'NC01'");
expect(pipeline.spec.taskRunTemplate.serviceAccountName).toBe("default");
const script = pipeline.spec.pipelineSpec.tasks[0].taskSpec.steps[0].script as string;
expect(script).toContain("$SOURCE_SNAPSHOT_PREFIX/$SOURCE_COMMIT");
expect(script).toContain("publish-platform-infra-gitea-gitops.mjs");
expect(script).not.toContain("build-unidesk-host-image");
expect(script).not.toContain("todo-note");
const pac = Bun.YAML.parse(readFileSync(resolve(root, "config/platform-infra/pipelines-as-code.yaml"), "utf8")) as any;
const consumer = pac.consumers.find((item: any) => item.id === "platform-infra-gitea-nc01");
expect(consumer).toMatchObject({
repositoryRef: "sentinel-nc01-v03",
node: "NC01",
namespace: "devops-infra",
pipeline: "platform-infra-gitea-nc01-pac",
closeoutGitOpsMirrorFlush: false,
});
expect(consumer.argoBootstrap).toBeUndefined();
});
test("owning YAML renders one child Application and the complete durable bridge desired state", () => {
const delivery = readGiteaWebhookGitOpsDelivery();
expect(delivery.enabled).toBe(true);
expect(delivery.bootstrapApplicationPath).toStartWith("deploy/gitops/unidesk-host/");
expect(delivery.desiredManifestPath).toStartWith("deploy/gitops/platform-infra/gitea-nc01/");
const application = renderPlatformInfraGiteaApplication(delivery);
expect(application).toContain("kind: Application");
expect(application).toContain("name: platform-infra-gitea-nc01");
expect(application).toContain("path: deploy/gitops/platform-infra/gitea-nc01");
expect(application).toContain("prune: true");
expect(application).toContain("selfHeal: true");
const manifest = renderGiteaWebhookSyncDesiredManifest("NC01");
expect(manifest).toContain("kind: ServiceAccount");
expect(manifest).toContain("kind: PersistentVolumeClaim");
expect(manifest).toContain("strategy:\n type: Recreate");
expect(manifest).toContain("serviceAccountName: gitea-github-sync");
expect(manifest).toContain("name: UNIDESK_GITEA_WEBHOOK_RESPONSE_BUDGET_MS\n value: \"6500\"");
expect(manifest).toContain("name: UNIDESK_GITEA_WEBHOOK_WORKER_ATTEMPT_TIMEOUT_MS\n value: \"120000\"");
});
+63 -197
View File
@@ -5,6 +5,8 @@ tmp="$(mktemp -d)"
export tmp
trap 'rm -rf "$tmp"' EXIT
printf '%s' "$UNIDESK_GITEA_STATUS_EVALUATOR_B64" | base64 -d >"$tmp/platform_infra_gitea_status_evaluator.py"
json_tail() {
name="$1"
limit="${2:-2000}"
@@ -619,6 +621,8 @@ PY
status_rc=$?
python3 - "$status_rc" "$tmp/repo-meta.json" "$tmp" <<'PY'
import json, os, sys
sys.path.insert(0, sys.argv[3])
from platform_infra_gitea_status_evaluator import select_snapshot
status_rc = int(sys.argv[1])
meta = json.load(open(sys.argv[2], encoding="utf-8"))
tmp = sys.argv[3]
@@ -637,15 +641,14 @@ for repo in meta:
refs[parts[1]] = parts[0]
branch_ref = f"refs/heads/{repo['branch']}"
branch_commit = refs.get(branch_ref)
snapshots = sorted((ref, sha) for ref, sha in refs.items() if ref.startswith(repo["snapshotPrefix"] + "/"))
matching_snapshots = [item for item in snapshots if branch_commit and item[1] == branch_commit]
latest_snapshot = matching_snapshots[-1] if matching_snapshots else (snapshots[-1] if snapshots else None)
current_snapshot_ready = bool(branch_commit and latest_snapshot and latest_snapshot[1] == branch_commit)
selected_snapshot = select_snapshot(branch_commit, repo["snapshotPrefix"], refs)
current_snapshot_ready = selected_snapshot["sourceBundleReady"]
repos.append({
**repo,
"branchCommit": branch_commit,
"snapshotRef": latest_snapshot[0] if latest_snapshot else None,
"snapshotCommit": latest_snapshot[1] if latest_snapshot else None,
"expectedSnapshotRef": selected_snapshot["expectedSnapshotRef"],
"snapshotRef": selected_snapshot["snapshotRef"],
"snapshotCommit": selected_snapshot["snapshotCommit"],
"sourceBundleReady": current_snapshot_ready,
"mirrorState": "ready" if current_snapshot_ready else "missing-ref",
"errorTail": text(os.path.join(tmp, f"{repo['key']}.ls.err")),
@@ -729,17 +732,32 @@ run_mirror_webhook_status() {
mirror_status_rc=$?
kubectl -n "$UNIDESK_GITEA_NAMESPACE" logs "deployment/$UNIDESK_GITEA_WEBHOOK_DEPLOYMENT" --tail=80 >"$tmp/bridge.log" 2>"$tmp/bridge.err"
bridge_logs_rc=$?
python3 - "$tmp/repos.json" "$bridge_ready" "$service_exists" "$mirror_status_rc" "$bridge_logs_rc" "$tmp/mirror-status.json" "$tmp/mirror-status.err" "$tmp/bridge.log" "$tmp/bridge.err" <<'PY'
kubectl -n "$UNIDESK_GITEA_NAMESPACE" exec -i "deployment/$UNIDESK_GITEA_WEBHOOK_DEPLOYMENT" -- node - "$UNIDESK_GITEA_WEBHOOK_SERVICE_PORT" >"$tmp/inbox-status.json" 2>"$tmp/inbox-status.err" <<'NODE'
const port = process.argv[2];
fetch(`http://127.0.0.1:${port}/status`)
.then(async (response) => process.stdout.write(await response.text()))
.catch((error) => {
process.stderr.write(String(error?.message || error));
process.exitCode = 1;
});
NODE
inbox_status_rc=$?
python3 - "$tmp/repos.json" "$bridge_ready" "$service_exists" "$mirror_status_rc" "$bridge_logs_rc" "$inbox_status_rc" "$tmp/mirror-status.json" "$tmp/mirror-status.err" "$tmp/bridge.log" "$tmp/bridge.err" "$tmp/inbox-status.json" "$tmp/inbox-status.err" <<'PY'
import json, os, sys, urllib.error, urllib.parse, urllib.request
sys.path.insert(0, os.environ["tmp"])
from platform_infra_gitea_status_evaluator import evaluate_repository
repos = json.load(open(sys.argv[1], encoding="utf-8"))
bridge_ready = sys.argv[2]
service_exists = bool(sys.argv[3])
mirror_status_rc = int(sys.argv[4])
bridge_logs_rc = int(sys.argv[5])
mirror_status_path = sys.argv[6]
mirror_status_err_path = sys.argv[7]
bridge_log_path = sys.argv[8]
bridge_log_err_path = sys.argv[9]
inbox_status_rc = int(sys.argv[6])
mirror_status_path = sys.argv[7]
mirror_status_err_path = sys.argv[8]
bridge_log_path = sys.argv[9]
bridge_log_err_path = sys.argv[10]
inbox_status_path = sys.argv[11]
inbox_status_err_path = sys.argv[12]
token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"]
url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"]
def text(path, limit=1600):
@@ -776,7 +794,9 @@ def hook_deliveries(repository, hook_id):
if not isinstance(item, dict):
return None
return {
"id": item.get("id"),
"id": str(item.get("id")) if item.get("id") is not None else None,
"guid": str(item.get("guid")) if item.get("guid") is not None else None,
"deliveryId": str(item.get("guid") or item.get("id") or ""),
"event": item.get("event"),
"status": item.get("status"),
"statusCode": item.get("status_code"),
@@ -791,6 +811,11 @@ try:
except Exception:
mirror_status = {}
mirror_by_key = {item.get("key"): item for item in mirror_status.get("repositories", []) if isinstance(item, dict)}
try:
inbox_status = json.load(open(inbox_status_path, encoding="utf-8"))
except Exception:
inbox_status = {}
inbox_records = [item for item in inbox_status.get("deliveries", []) if isinstance(item, dict)]
bridge_events = []
for line in text(bridge_log_path, 12000).splitlines():
try:
@@ -801,10 +826,19 @@ for line in text(bridge_log_path, 12000).splitlines():
bridge_events.append({
"event": event.get("event"),
"repo": event.get("repo"),
"deliveryId": event.get("deliveryId"),
"repository": event.get("repository"),
"ref": event.get("ref"),
"requestedCommit": event.get("requestedCommit"),
"ok": event.get("ok"),
"sourceCommit": event.get("sourceCommit"),
"authorityCommit": event.get("authorityCommit"),
"snapshotRef": event.get("snapshotRef"),
"disposition": event.get("disposition"),
"errorType": event.get("errorType"),
"error": event.get("error"),
"syncAttempt": event.get("syncAttempt"),
"terminal": event.get("terminal"),
"elapsedMs": event.get("elapsedMs"),
})
rows = []
@@ -822,75 +856,34 @@ for repo in repos:
head = github_head(repository, branch)
delivery = hook_deliveries(repository, hook_id)
mirror = mirror_by_key.get(repo["key"], {})
branch_commit = mirror.get("branchCommit")
snapshot_commit = mirror.get("snapshotCommit")
branch_matches_github = bool(head.get("sha") and branch_commit == head.get("sha"))
snapshot_matches_branch = bool(branch_commit and snapshot_commit == branch_commit)
repo_bridge_events = [event for event in bridge_events if event.get("repo") == repo["key"]]
last_bridge_event = repo_bridge_events[-1] if repo_bridge_events else None
latest_push_delivery = delivery.get("latestPush")
delivery_accepted = isinstance(latest_push_delivery, dict) and latest_push_delivery.get("statusCode") in (200, 202)
bridge_event_stale = bool(delivery_accepted and head.get("sha") and (not branch_matches_github or not snapshot_matches_branch))
stale_reason = None
if bridge_event_stale:
if not last_bridge_event:
stale_reason = "no-bridge-event-in-log-tail"
elif last_bridge_event.get("ok") is not True:
stale_reason = "last-bridge-event-failed"
elif last_bridge_event.get("sourceCommit") != head.get("sha"):
stale_reason = "last-bridge-event-behind-github-head"
elif not branch_matches_github:
stale_reason = "gitea-branch-behind-github"
elif not snapshot_matches_branch:
stale_reason = "gitea-snapshot-behind-branch"
repair_command = None
if not branch_matches_github or not snapshot_matches_branch:
repair_command = f"bun scripts/cli.ts platform-infra gitea mirror sync --target {os.environ['UNIDESK_GITEA_TARGET_ID']} --repo {repo['key']} --confirm"
error = None
if not result.get("ok"):
error = result.get("body", "")[:500]
elif not head.get("ok"):
error = head.get("error")
elif not delivery.get("ok"):
error = delivery.get("error")
elif bridge_event_stale:
error = "bridge event stale: " + (stale_reason or "delivery-accepted-but-mirror-not-current")
elif not branch_matches_github:
error = "gitea branch does not match GitHub head"
elif not snapshot_matches_branch:
error = "gitea snapshot does not match branch"
rows.append({
"key": repo["key"],
"repository": repository,
"branch": branch,
hook = {
"hookId": hook_id,
"hookReady": bool(match and match.get("active") is True),
"active": match.get("active") if match else None,
"status": result.get("status"),
"githubHead": head.get("sha"),
"branchCommit": branch_commit,
"snapshotCommit": snapshot_commit,
"branchMatchesGitHub": branch_matches_github,
"snapshotMatchesBranch": snapshot_matches_branch,
"latestDelivery": delivery.get("latest"),
"latestPushDelivery": latest_push_delivery,
"lastBridgeEvent": last_bridge_event,
"deliveryAccepted": delivery_accepted,
"bridgeEventStale": bridge_event_stale,
"staleReason": stale_reason,
"repairCommand": repair_command,
"error": error,
"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}
"latest": delivery.get("latest"),
"latestPush": delivery.get("latestPush"),
"error": result.get("body", "")[:500] if not result.get("ok") else delivery.get("error"),
}
rows.append(evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records, inbox_status.get("journal")))
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,
"durableInbox": inbox_status,
}
payload = {
"ok": bridge["ready"] and service_exists and mirror_status_rc == 0 and bridge_logs_rc == 0 and all(row["hookReady"] and row["branchMatchesGitHub"] and row["snapshotMatchesBranch"] for row in rows),
"ok": bridge["ready"] and service_exists and mirror_status_rc == 0 and inbox_status_rc == 0 and inbox_status.get("ready") is True and all(row["hookReady"] and row["authorityMatches"] and not row["bridgeEventStale"] for row in rows),
"bridge": bridge,
"repositories": rows,
"url": url,
"repairCommands": [row["repairCommand"] for row in rows if row.get("repairCommand")],
"mirrorStatus": {"exitCode": mirror_status_rc, "errorTail": text(mirror_status_err_path)},
"bridgeLogs": {"exitCode": bridge_logs_rc, "events": bridge_events[-12:], "errorTail": text(bridge_log_err_path)},
"inboxStatus": {"exitCode": inbox_status_rc, "errorTail": text(inbox_status_err_path)},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
@@ -899,135 +892,9 @@ 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 -i 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/repos.json" "$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]
repos_path = sys.argv[5]
def text(path, limit=2000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
def github_head(repo):
import os, urllib.error, urllib.request
repository = repo["upstream"]["repository"]
branch = repo["upstream"]["branch"].replace("/", "%2F")
token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"]
req = urllib.request.Request(f"https://api.github.com/repos/{repository}/git/ref/heads/{branch}", 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:
payload = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
return payload.get("object", {}).get("sha")
except Exception:
return None
try:
ping = json.loads(text(sys.argv[6], 100000))
except Exception:
ping = {"ok": False, "error": text(sys.argv[7])}
try:
status = json.loads(text(sys.argv[10], 100000))
except Exception:
status = {}
try:
repo_specs = json.load(open(repos_path, encoding="utf-8"))
except Exception:
repo_specs = []
expected_heads = {repo.get("key"): github_head(repo) for repo in repo_specs}
rows = []
for repo in status.get("repositories", []):
if repo.get("key") == repo_key:
expected_head = expected_heads.get(repo_key)
branch_matches_github = bool(expected_head and repo.get("branchCommit") == expected_head)
rows.append({**repo, "expectedGitHubHead": expected_head, "branchMatchesGitHub": branch_matches_github, "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") and branch_matches_github else (ping.get("error") or text(sys.argv[9]) or text(sys.argv[11]) or "gitea mirror branch does not match GitHub head")[:500]})
payload = {"ok": ping_rc == 0 and test_rc == 0 and status_rc == 0 and all(row.get("sourceBundleReady") and row.get("branchMatchesGitHub") for row in rows), "repositories": rows, "githubPing": ping, "test": {"exitCode": test_rc, "stdoutTail": text(sys.argv[8]), "stderrTail": text(sys.argv[9])}, "valuesPrinted": False}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
printf '{"ok":false,"mutation":false,"mode":"manual-source-delivery-test-disabled","error":"only real GitHub PR merge deliveries may enter the durable inbox","valuesPrinted":false}\n'
exit 2
}
case "$UNIDESK_GITEA_ACTION" in
apply) run_apply ;;
status) run_status ;;
@@ -1037,6 +904,5 @@ case "$UNIDESK_GITEA_ACTION" in
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
@@ -0,0 +1,248 @@
import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
const evaluator = resolve(import.meta.dir, "platform_infra_gitea_status_evaluator.py");
describe("Gitea source authority status evaluator", () => {
test("selects only the exact branch-derived immutable snapshot", () => {
const head = "b".repeat(40);
const result = evaluate({
action: "select-snapshot",
branchCommit: head,
snapshotPrefix: "refs/snapshots/source",
refs: {
"refs/snapshots/source/zzzz": "f".repeat(40),
[`refs/snapshots/source/${head}`]: head,
},
});
expect(result).toEqual({
expectedSnapshotRef: `refs/snapshots/source/${head}`,
snapshotRef: `refs/snapshots/source/${head}`,
snapshotCommit: head,
sourceBundleReady: true,
});
const missing = evaluate({
action: "select-snapshot",
branchCommit: head,
snapshotPrefix: "refs/snapshots/source",
refs: { "refs/snapshots/source/zzzz": "f".repeat(40) },
});
expect(missing.snapshotRef).toBeNull();
expect(missing.sourceBundleReady).toBe(false);
});
test("latest failed delivery cannot be hidden by an older successful bridge event", () => {
const head = "b".repeat(40);
const row = evaluateRow({
head,
branchCommit: head,
snapshotCommit: head,
deliveryId: "new-delivery",
statusCode: 502,
bridgeEvents: [terminalEvent("old-delivery", head)],
inboxRecords: [committedInbox("old-delivery", head)],
});
expect(row.authorityMatches).toBe(true);
expect(row.deliveryAccepted).toBe(false);
expect(row.correlatedBridgeEvent).toBeNull();
expect(row.bridgeEventStale).toBe(true);
expect(row.staleReason).toBe("latest-push-delivery-failed-or-missing");
});
test("MATCH=false is always STALE=true and exact delivery correlation is repo isolated", () => {
const head = "c".repeat(40);
const old = "b".repeat(40);
const row = evaluateRow({
head,
branchCommit: old,
snapshotCommit: old,
deliveryId: "3830578865896423400",
statusCode: 202,
bridgeEvents: [
{ ...terminalEvent("3830578865896423400", head), repo: "other" },
terminalEvent("3830578865896423400", head),
],
});
expect(row.deliveryId).toBe("3830578865896423400");
expect(row.correlatedBridgeEvent.repo).toBe("fixture");
expect(row.authorityMatches).toBe(false);
expect(row.bridgeEventStale).toBe(true);
expect(row.staleReason).toBe("gitea-branch-behind-github");
});
test("a correlated terminal event and exact refs prove current authority", () => {
const head = "d".repeat(40);
const row = evaluateRow({
head,
branchCommit: head,
snapshotCommit: head,
deliveryId: "delivery-ok",
statusCode: 202,
bridgeEvents: [terminalEvent("delivery-ok", head)],
});
expect(row.authorityMatches).toBe(true);
expect(row.bridgeEventStale).toBe(false);
expect(row.staleReason).toBeNull();
});
test("an out-of-order delivery may prove a newer authority without rolling it back", () => {
const head = "e".repeat(40);
const older = "d".repeat(40);
const event = terminalEvent("delivery-old", older);
event.authorityCommit = head;
event.disposition = "superseded-with-immutable-snapshot";
const row = evaluateRow({
head,
branchCommit: head,
snapshotCommit: head,
deliveryId: "delivery-old",
statusCode: 202,
bridgeEvents: [event],
inboxRecords: [committedInbox("delivery-old", older, head, "superseded-with-immutable-snapshot")],
});
expect(row.authorityMatches).toBe(true);
expect(row.bridgeEventStale).toBe(false);
});
test("accepted, processing and failed inbox states never masquerade as committed refs", () => {
const head = "f".repeat(40);
for (const state of ["accepted", "processing", "failed"]) {
const row = evaluateRow({
head,
branchCommit: head,
snapshotCommit: head,
deliveryId: `delivery-${state}`,
statusCode: 202,
bridgeEvents: [],
inboxRecords: [{ ...committedInbox(`delivery-${state}`, head), state, result: null }],
});
expect(row.authorityMatches).toBe(true);
expect(row.durableInboxState).toBe(state);
expect(row.bridgeEventStale).toBe(true);
expect(row.staleReason).toBe(`durable-inbox-${state}-not-committed`);
}
});
test("missing committed watermark or mismatched proof remains stale", () => {
const head = "a".repeat(40);
const missing = evaluateRow({
head,
branchCommit: head,
snapshotCommit: head,
deliveryId: "delivery-missing",
statusCode: 202,
bridgeEvents: [],
inboxRecords: [],
});
expect(missing.staleReason).toBe("no-correlated-durable-inbox-record");
const wrong = evaluateRow({
head,
branchCommit: head,
snapshotCommit: head,
deliveryId: "delivery-wrong",
statusCode: 202,
bridgeEvents: [],
inboxRecords: [committedInbox("delivery-wrong", "b".repeat(40))],
});
expect(wrong.staleReason).toBe("correlated-inbox-proof-head-mismatch");
});
test("a delivery older than the persistent journal is marked pre-durable-bootstrap without faking committed", () => {
const head = "9".repeat(40);
const row = evaluateRow({
head,
branchCommit: head,
snapshotCommit: head,
deliveryId: "bootstrap-delivery",
statusCode: 202,
deliveredAt: "2026-07-11T06:00:00.000Z",
journalCreatedAt: "2026-07-11T07:00:00.000Z",
bridgeEvents: [],
inboxRecords: [],
});
expect(row.authorityMatches).toBe(true);
expect(row.durableInboxState).toBe("pre-durable-bootstrap");
expect(row.preDurableBootstrap).toBe(true);
expect(row.durableInboxCommitted).toBe(false);
expect(row.bridgeEventStale).toBe(false);
expect(row.staleReason).toBeNull();
});
});
function evaluateRow(input: {
head: string;
branchCommit: string;
snapshotCommit: string;
deliveryId: string;
statusCode: number;
bridgeEvents: Array<Record<string, unknown>>;
inboxRecords?: Array<Record<string, unknown>>;
deliveredAt?: string;
journalCreatedAt?: string;
}): Record<string, any> {
return evaluate({
action: "evaluate-repository",
repo: {
key: "fixture",
upstream: { repository: "pikasTech/fixture", branch: "main" },
snapshot: { prefix: "refs/snapshots/source" },
},
hook: {
hookId: 1,
hookReady: true,
active: true,
status: 200,
latest: { deliveryId: input.deliveryId, event: "push", statusCode: input.statusCode, deliveredAt: input.deliveredAt },
latestPush: { deliveryId: input.deliveryId, event: "push", statusCode: input.statusCode, deliveredAt: input.deliveredAt },
},
head: { ok: true, sha: input.head },
mirror: {
branchCommit: input.branchCommit,
snapshotCommit: input.snapshotCommit,
snapshotRef: `refs/snapshots/source/${input.snapshotCommit}`,
},
bridgeEvents: input.bridgeEvents,
inboxRecords: input.inboxRecords ?? [committedInbox(input.deliveryId, input.head)],
journal: input.journalCreatedAt === undefined ? undefined : { version: 1, kind: "GiteaGithubSyncDurableInbox", createdAt: input.journalCreatedAt },
});
}
function committedInbox(deliveryId: string, sourceCommit: string, authorityCommit = sourceCommit, disposition = "atomic-source-authority-committed"): Record<string, unknown> {
return {
deliveryId,
repo: "fixture",
state: "committed",
result: {
sourceCommit,
authorityCommit,
snapshotRef: `refs/snapshots/source/${sourceCommit}`,
disposition,
},
};
}
function terminalEvent(deliveryId: string, sourceCommit: string): Record<string, unknown> {
return {
event: "github-to-gitea-sync",
repo: "fixture",
deliveryId,
ok: true,
terminal: true,
sourceCommit,
authorityCommit: sourceCommit,
disposition: "atomic-source-authority-committed",
};
}
function evaluate(payload: Record<string, unknown>): Record<string, any> {
const result = spawnSync("python3", [evaluator], {
input: JSON.stringify(payload),
encoding: "utf8",
env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" },
});
expect(result.status).toBe(0);
return JSON.parse(result.stdout);
}
+320 -63
View File
@@ -16,12 +16,15 @@ import {
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 { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle, validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";
import type { GiteaWebhookIngressRetry } from "./platform-infra-gitea-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 statusEvaluatorFile = rootPath("scripts", "src", "platform_infra_gitea_status_evaluator.py");
const fieldManager = "unidesk-platform-infra-gitea";
const y = createYamlFieldReader(configLabel);
@@ -167,8 +170,10 @@ interface GiteaGithubCredential {
interface GiteaWebhookSync {
enabled: boolean;
direction: "github-to-gitea";
responseBudgetMs: number;
publicPath: string;
events: string[];
ingressRetry: GiteaWebhookIngressRetry;
secret: {
sourceRef: string;
sourceKey: string;
@@ -183,18 +188,60 @@ interface GiteaWebhookSync {
replicas: number;
httpPort: number;
serviceAccountName: string;
shutdownGraceMs: number;
inbox: {
claimName: string;
path: string;
storageSize: string;
maxBytes: number;
committedRetentionSeconds: number;
cleanupIntervalMs: number;
};
retry: {
maxAttempts: number;
initialDelayMs: number;
maxDelayMs: number;
terminalRetryDelayMs: number;
attemptTimeoutMs: number;
scanIntervalMs: number;
};
};
gitOpsDelivery: GiteaWebhookGitOpsDelivery;
frpc: {
proxyName: string;
remotePort: number;
};
}
export interface GiteaWebhookGitOpsDelivery {
enabled: boolean;
targetId: string;
readUrl: string;
writeUrl: string;
branch: string;
sourceSnapshotPrefix: string;
desiredManifestPath: string;
bootstrapApplicationPath: string;
releaseStatePath: string;
application: {
name: string;
namespace: string;
project: string;
repoUrl: string;
targetRevision: string;
path: string;
destinationNamespace: string;
automated: boolean;
};
author: {
name: string;
email: string;
};
cas: {
maxAttempts: number;
};
}
interface GiteaSourceResponsibility {
name: string;
current: string;
@@ -291,24 +338,15 @@ export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args:
export function giteaHelp(): Record<string, unknown> {
return {
command: "platform-infra gitea plan|apply|status|validate|mirror",
command: "platform-infra gitea status|validate|mirror status|mirror webhook status",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra gitea plan --target JD01",
"bun scripts/cli.ts platform-infra gitea apply --target JD01 --dry-run",
"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]",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01 [--full|--raw]",
],
boundary: "Gitea is installed as an internal ClusterIP source-authority service for GH-1560; CI trigger authority is Pipelines-as-Code, not Gitea Actions.",
boundary: "PR merge is the only delivery trigger; these default entries are read-only observation and validation, never post-merge repair actions.",
};
}
@@ -468,14 +506,27 @@ function parseGithubCredential(record: Record<string, unknown>, path: string): G
}
function parseWebhookSync(record: Record<string, unknown>, path: string): GiteaWebhookSync {
const ingressRetry = y.objectField(record, "ingressRetry", path);
const secret = y.objectField(record, "secret", path);
const bridge = y.objectField(record, "bridge", path);
const inbox = y.objectField(bridge, "inbox", `${path}.bridge`);
const gitOpsDelivery = y.objectField(record, "gitOpsDelivery", 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),
responseBudgetMs: positiveInteger(record, "responseBudgetMs", path),
publicPath: y.apiPathField(record, "publicPath", path),
events: y.stringArrayField(record, "events", path),
ingressRetry: {
enabled: y.booleanField(ingressRetry, "enabled", `${path}.ingressRetry`),
maxBodyBytes: positiveInteger(ingressRetry, "maxBodyBytes", `${path}.ingressRetry`),
maxRetries: positiveInteger(ingressRetry, "maxRetries", `${path}.ingressRetry`),
tryIntervalMs: positiveInteger(ingressRetry, "tryIntervalMs", `${path}.ingressRetry`),
dialTimeoutMs: positiveInteger(ingressRetry, "dialTimeoutMs", `${path}.ingressRetry`),
writeTimeoutMs: positiveInteger(ingressRetry, "writeTimeoutMs", `${path}.ingressRetry`),
retryStatusCodes: y.numberArrayField(ingressRetry, "retryStatusCodes", `${path}.ingressRetry`),
},
secret: {
sourceRef: secretSourceRefField(secret, "sourceRef", `${path}.secret`),
sourceKey: y.envKeyField(secret, "sourceKey", `${path}.secret`),
@@ -490,8 +541,18 @@ function parseWebhookSync(record: Record<string, unknown>, path: string): GiteaW
replicas: positiveInteger(bridge, "replicas", `${path}.bridge`),
httpPort: y.portField(bridge, "httpPort", `${path}.bridge`),
serviceAccountName: y.kubernetesNameField(bridge, "serviceAccountName", `${path}.bridge`),
shutdownGraceMs: positiveInteger(bridge, "shutdownGraceMs", `${path}.bridge`),
inbox: {
claimName: y.kubernetesNameField(inbox, "claimName", `${path}.bridge.inbox`),
path: y.absolutePathField(inbox, "path", `${path}.bridge.inbox`),
storageSize: quantity(inbox, "storageSize", `${path}.bridge.inbox`),
maxBytes: positiveInteger(inbox, "maxBytes", `${path}.bridge.inbox`),
committedRetentionSeconds: positiveInteger(inbox, "committedRetentionSeconds", `${path}.bridge.inbox`),
cleanupIntervalMs: positiveInteger(inbox, "cleanupIntervalMs", `${path}.bridge.inbox`),
},
retry: parseWebhookRetry(y.objectField(bridge, "retry", `${path}.bridge`), `${path}.bridge.retry`),
},
gitOpsDelivery: parseWebhookGitOpsDelivery(gitOpsDelivery, `${path}.gitOpsDelivery`),
frpc: {
proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`),
remotePort: y.portField(frpc, "remotePort", `${path}.frpc`),
@@ -499,11 +560,48 @@ function parseWebhookSync(record: Record<string, unknown>, path: string): GiteaW
};
}
function parseWebhookGitOpsDelivery(record: Record<string, unknown>, path: string): GiteaWebhookGitOpsDelivery {
const application = y.objectField(record, "application", path);
const author = y.objectField(record, "author", path);
const cas = y.objectField(record, "cas", path);
return {
enabled: y.booleanField(record, "enabled", path),
targetId: y.stringField(record, "targetId", path),
readUrl: urlField(record, "readUrl", path),
writeUrl: urlField(record, "writeUrl", path),
branch: gitBranchField(record, "branch", path),
sourceSnapshotPrefix: refPrefixField(record, "sourceSnapshotPrefix", path),
desiredManifestPath: safeRelativePathField(record, "desiredManifestPath", path),
bootstrapApplicationPath: safeRelativePathField(record, "bootstrapApplicationPath", path),
releaseStatePath: safeRelativePathField(record, "releaseStatePath", path),
application: {
name: y.kubernetesNameField(application, "name", `${path}.application`),
namespace: y.kubernetesNameField(application, "namespace", `${path}.application`),
project: y.kubernetesNameField(application, "project", `${path}.application`),
repoUrl: urlField(application, "repoUrl", `${path}.application`),
targetRevision: gitBranchField(application, "targetRevision", `${path}.application`),
path: safeRelativePathField(application, "path", `${path}.application`),
destinationNamespace: y.kubernetesNameField(application, "destinationNamespace", `${path}.application`),
automated: y.booleanField(application, "automated", `${path}.application`),
},
author: {
name: y.stringField(author, "name", `${path}.author`),
email: y.stringField(author, "email", `${path}.author`),
},
cas: {
maxAttempts: positiveInteger(cas, "maxAttempts", `${path}.cas`),
},
};
}
function parseWebhookRetry(record: Record<string, unknown>, path: string): GiteaWebhookSync["bridge"]["retry"] {
return {
maxAttempts: positiveInteger(record, "maxAttempts", path),
initialDelayMs: positiveInteger(record, "initialDelayMs", path),
maxDelayMs: positiveInteger(record, "maxDelayMs", path),
terminalRetryDelayMs: positiveInteger(record, "terminalRetryDelayMs", path),
attemptTimeoutMs: positiveInteger(record, "attemptTimeoutMs", path),
scanIntervalMs: positiveInteger(record, "scanIntervalMs", path),
};
}
@@ -643,6 +741,32 @@ function validateConfig(gitea: GiteaConfig): void {
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 ingressRetry = gitea.sourceAuthority.webhookSync.ingressRetry;
const bridge = gitea.sourceAuthority.webhookSync.bridge;
if (bridge.replicas !== 1) throw new Error(`${configLabel}.sourceAuthority.webhookSync.bridge.replicas must be 1 because the durable inbox uses a single-writer Recreate deployment`);
if (bridge.retry.terminalRetryDelayMs < bridge.retry.maxDelayMs) throw new Error(`${configLabel}.sourceAuthority.webhookSync.bridge.retry.terminalRetryDelayMs must be >= maxDelayMs`);
if (ingressRetry.retryStatusCodes.length < 1 || ingressRetry.retryStatusCodes.some((statusCode) => ![502, 503, 504].includes(statusCode))) {
throw new Error(`${configLabel}.sourceAuthority.webhookSync.ingressRetry.retryStatusCodes may only contain 502, 503 and 504`);
}
if (ingressRetry.enabled) {
try {
validateGiteaWebhookTiming(gitea.sourceAuthority.webhookSync.responseBudgetMs, ingressRetry);
} catch (error) {
throw new Error(`${configLabel}.sourceAuthority.webhookSync timing invalid: ${String((error as Error).message || error)}`);
}
}
const delivery = gitea.sourceAuthority.webhookSync.gitOpsDelivery;
if (delivery.enabled) {
const target = resolveTarget(gitea, delivery.targetId);
if (target.id !== "NC01") throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.targetId must be NC01`);
if (delivery.application.targetRevision !== delivery.branch) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.application.targetRevision must match branch`);
if (delivery.application.repoUrl !== delivery.readUrl) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.application.repoUrl must match readUrl`);
if (dirname(delivery.desiredManifestPath) !== delivery.application.path) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.desiredManifestPath must be directly under application.path`);
if (!delivery.bootstrapApplicationPath.startsWith("deploy/gitops/unidesk-host/")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.bootstrapApplicationPath must be under the existing unidesk-host bootstrap path`);
if (delivery.desiredManifestPath.startsWith("deploy/gitops/unidesk-host/")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.desiredManifestPath must use an independent GitOps path`);
if (!/^[^@\s]+@[^@\s]+$/u.test(delivery.author.email)) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.author.email must be an email address`);
if (delivery.cas.maxAttempts > 5) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.cas.maxAttempts must be <= 5`);
}
}
const webhookPaths = new Set<string>();
const webhookPorts = new Set<number>();
@@ -788,13 +912,8 @@ async function mirrorCommand(config: UniDeskConfig, args: string[]): Promise<Rec
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]",
"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",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target JD01 [--full|--raw]",
],
},
};
@@ -820,7 +939,7 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom
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]",
usage: "platform-infra gitea mirror webhook status --target <node> [--repo <key>] [--full|--raw]",
};
}
@@ -836,7 +955,7 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> {
responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary),
repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)),
credentials: credentialSummaries(gitea),
next: mirrorNextCommands(target.id),
next: mirrorSetupNextCommands(target.id),
};
}
@@ -872,7 +991,7 @@ async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { re
repositories: repositories.length > 0 ? repositories : selectedRepos.map((repo) => repositorySummary(gitea, repo)),
credentials,
remote: parsed ?? compactCapture(result, { full: options.full || result.exitCode !== 0 }),
next: mirrorNextCommands(target.id),
next: mirrorReadOnlyNextCommands(target.id),
};
}
@@ -892,13 +1011,24 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
repositories: arrayRecords(record(parsed).repositories),
credentials: credentialSummaries(gitea),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
next: mirrorReadOnlyNextCommands(target.id),
};
}
async function mirrorSync(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-sync",
mutation: false,
mode: "automatic-source-authority-manual-sync-disabled",
target: targetSummary(target),
error: "manual mirror sync is disabled while GitHub webhook durable source authority is enabled; the durable controller owns automatic convergence",
next: mirrorReadOnlyNextCommands(target.id),
};
}
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, false);
@@ -912,7 +1042,7 @@ async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promis
repositories: arrayRecords(record(parsed).repositories),
sourceBundles: arrayRecords(record(parsed).sourceBundles),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
next: mirrorReadOnlyNextCommands(target.id),
};
}
@@ -933,7 +1063,7 @@ async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions)
webhook: webhookSyncSummary(gitea, target),
repositories: arrayRecords(record(parsed).repositories),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
next: mirrorReadOnlyNextCommands(target.id),
};
}
@@ -945,7 +1075,6 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions
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);
const repairCommands = repositories.map((repo) => stringValue(repo.repairCommand, "")).filter((command) => command.length > 0);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
action: "platform-infra-gitea-mirror-webhook-status",
@@ -956,30 +1085,23 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions
bridge: record(parsed).bridge,
bridgeLogs: record(parsed).bridgeLogs,
mirrorStatus: record(parsed).mirrorStatus,
repairCommands,
remote: parsed ?? compactCapture(result, { full: true }),
next: { ...mirrorNextCommands(target.id), repair: repairCommands[0] ?? null },
next: mirrorReadOnlyNextCommands(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,
ok: false,
action: "platform-infra-gitea-mirror-webhook-test",
mutation: true,
mutation: false,
mode: "manual-source-delivery-test-disabled",
target: targetSummary(target),
webhook: webhookSyncSummary(gitea, target),
repositories: arrayRecords(record(parsed).repositories),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
error: "manual webhook source-delivery tests are disabled; only real GitHub PR merge deliveries may enter the durable inbox",
next: mirrorReadOnlyNextCommands(target.id),
};
}
@@ -1158,6 +1280,14 @@ function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): stri
const configHash = createHash("sha256").update(reposJson).update("\0").update(serverSource).digest("hex").slice(0, 16);
return `---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ${sync.bridge.serviceAccountName}
namespace: ${target.namespace}
labels:
${labels}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: ${sync.bridge.configMapName}
@@ -1171,6 +1301,21 @@ ${indentBlock(reposJson, 4)}
${indentBlock(serverSource, 4)}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ${sync.bridge.inbox.claimName}
namespace: ${target.namespace}
labels:
${labels}
spec:
accessModes:
- ReadWriteOnce
storageClassName: ${target.storageClassName}
resources:
requests:
storage: ${sync.bridge.inbox.storageSize}
---
apiVersion: v1
kind: Service
metadata:
name: ${sync.bridge.serviceName}
@@ -1200,6 +1345,8 @@ ${labels}
unidesk.ai/public-path: ${yamlQuote(sync.publicPath)}
spec:
replicas: ${sync.bridge.replicas}
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ${sync.bridge.deploymentName}
@@ -1214,6 +1361,8 @@ spec:
annotations:
unidesk.ai/github-sync-config-sha: ${yamlQuote(configHash)}
spec:
serviceAccountName: ${sync.bridge.serviceAccountName}
terminationGracePeriodSeconds: ${Math.ceil(sync.bridge.shutdownGraceMs / 1000) + 5}
containers:
- name: github-to-gitea-sync
image: ${sync.bridge.image}
@@ -1229,6 +1378,8 @@ spec:
value: ${yamlQuote(String(sync.bridge.httpPort))}
- name: UNIDESK_GITEA_WEBHOOK_PATH
value: ${yamlQuote(sync.publicPath)}
- name: UNIDESK_GITEA_WEBHOOK_RESPONSE_BUDGET_MS
value: ${yamlQuote(String(sync.responseBudgetMs))}
- name: UNIDESK_GITEA_REPOS_PATH
value: /etc/gitea-github-sync/repos.json
- name: UNIDESK_GITEA_SERVICE_BASE_URL
@@ -1239,6 +1390,24 @@ spec:
value: ${yamlQuote(String(sync.bridge.retry.initialDelayMs))}
- name: UNIDESK_GITEA_WEBHOOK_RETRY_MAX_DELAY_MS
value: ${yamlQuote(String(sync.bridge.retry.maxDelayMs))}
- name: UNIDESK_GITEA_WEBHOOK_RETRY_TERMINAL_DELAY_MS
value: ${yamlQuote(String(sync.bridge.retry.terminalRetryDelayMs))}
- name: UNIDESK_GITEA_WEBHOOK_WORKER_ATTEMPT_TIMEOUT_MS
value: ${yamlQuote(String(sync.bridge.retry.attemptTimeoutMs))}
- name: UNIDESK_GITEA_WEBHOOK_WORKER_SCAN_INTERVAL_MS
value: ${yamlQuote(String(sync.bridge.retry.scanIntervalMs))}
- name: UNIDESK_GITEA_WEBHOOK_MAX_BODY_BYTES
value: ${yamlQuote(String(sync.ingressRetry.maxBodyBytes))}
- name: UNIDESK_GITEA_WEBHOOK_INBOX_PATH
value: ${yamlQuote(sync.bridge.inbox.path)}
- name: UNIDESK_GITEA_WEBHOOK_INBOX_MAX_BYTES
value: ${yamlQuote(String(sync.bridge.inbox.maxBytes))}
- name: UNIDESK_GITEA_WEBHOOK_COMMITTED_RETENTION_SECONDS
value: ${yamlQuote(String(sync.bridge.inbox.committedRetentionSeconds))}
- name: UNIDESK_GITEA_WEBHOOK_CLEANUP_INTERVAL_MS
value: ${yamlQuote(String(sync.bridge.inbox.cleanupIntervalMs))}
- name: UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS
value: ${yamlQuote(String(sync.bridge.shutdownGraceMs))}
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
@@ -1276,13 +1445,31 @@ spec:
- name: config
mountPath: /etc/gitea-github-sync
readOnly: true
- name: durable-inbox
mountPath: ${sync.bridge.inbox.path}
volumes:
- name: config
configMap:
name: ${sync.bridge.configMapName}
- name: durable-inbox
persistentVolumeClaim:
claimName: ${sync.bridge.inbox.claimName}
`;
}
export function renderGiteaWebhookSyncDesiredManifest(targetId: string): string {
const gitea = readGiteaConfig();
const delivery = gitea.sourceAuthority.webhookSync.gitOpsDelivery;
const target = resolveTarget(gitea, targetId);
if (!delivery.enabled) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.enabled must be true`);
if (target.id !== delivery.targetId) throw new Error(`GitOps delivery target ${delivery.targetId} does not match requested target ${target.id}`);
return renderGithubSyncManifest(gitea, target);
}
export function readGiteaWebhookGitOpsDelivery(): GiteaWebhookGitOpsDelivery {
return readGiteaConfig().sourceAuthority.webhookSync.gitOpsDelivery;
}
function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
const app = gitea.app;
const values: Record<string, string> = {
@@ -1309,7 +1496,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" | "mirror-webhook-apply" | "mirror-webhook-status" | "mirror-webhook-test", 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", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): string {
const frpcExposure = targetFrpcExposure(gitea, target);
const sync = targetWebhookSync(gitea, target);
const env: Record<string, string> = {
@@ -1331,6 +1518,7 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
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_STATUS_EVALUATOR_B64: Buffer.from(readFileSync(statusEvaluatorFile, "utf8"), "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(","),
@@ -1363,6 +1551,8 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
}
function policyChecks(gitea: GiteaConfig, target: GiteaTarget, manifest: string): Array<Record<string, unknown>> {
const sync = targetWebhookSync(gitea, target);
const delivery = sync.gitOpsDelivery;
return [
{ name: "yaml-source-of-truth", ok: true, detail: "Gitea target, namespace, image, storage, ports and probes are read from config/platform-infra/gitea.yaml." },
{ name: "gitea-pac-service-contract", ok: target.namespace === "devops-infra" && gitea.app.serviceName === "gitea-http", detail: "The service matches the active Gitea source authority consumed by config/platform-infra/pipelines-as-code.yaml." },
@@ -1371,6 +1561,32 @@ function policyChecks(gitea: GiteaConfig, target: GiteaTarget, manifest: string)
{ name: "rootless-image", ok: /-rootless$/u.test(gitea.app.image.tag), detail: "The runtime image is Gitea rootless." },
{ name: "actions-not-trigger-authority", ok: gitea.app.actions.enabled, detail: "Gitea may expose its built-in Actions capability, but JD01 CI/CD trigger authority is Pipelines-as-Code, not act_runner." },
{ name: "env-reuse-preserved", ok: gitea.migration.envReusePolicy === "preserve-existing-runtime-env-reuse", detail: "This install does not replace the existing env reuse path or runtime deployment." },
{
name: "durable-inbox-single-writer",
ok: sync.bridge.replicas === 1 && /strategy:\n\s+type: Recreate/u.test(manifest),
detail: "The durable inbox has one bridge replica and Deployment strategy Recreate, so journal writes have one owner during rollout.",
},
{
name: "durable-inbox-pvc",
ok: manifest.includes(`kind: PersistentVolumeClaim\nmetadata:\n name: ${sync.bridge.inbox.claimName}`)
&& manifest.includes(`claimName: ${sync.bridge.inbox.claimName}`)
&& manifest.includes(`mountPath: ${sync.bridge.inbox.path}`),
detail: "The webhook bridge fsync inbox path, PVC claim, capacity and retention are rendered from the owning Gitea YAML.",
},
{
name: "durable-bridge-service-account",
ok: manifest.includes(`kind: ServiceAccount\nmetadata:\n name: ${sync.bridge.serviceAccountName}`)
&& manifest.includes(`serviceAccountName: ${sync.bridge.serviceAccountName}`),
detail: "The bridge ServiceAccount and Deployment binding are rendered together; the bootstrap PaC task continues to use the pre-existing default ServiceAccount.",
},
{
name: "independent-gitops-self-delivery",
ok: delivery.targetId !== target.id || (delivery.enabled
&& existsSync(rootPath(".tekton", "platform-infra-gitea-nc01-pac.yaml"))
&& delivery.bootstrapApplicationPath.startsWith("deploy/gitops/unidesk-host/")
&& !delivery.desiredManifestPath.startsWith("deploy/gitops/unidesk-host/")),
detail: "An independent PaC task publishes a child Argo Application into the existing unidesk-host bootstrap path and keeps bridge desired resources on an independent path.",
},
];
}
@@ -1512,15 +1728,16 @@ function caddyExposureNeeded(gitea: GiteaConfig, target: GiteaTarget): boolean {
async function applyGiteaCaddyBlock(config: UniDeskConfig, gitea: GiteaConfig): Promise<Record<string, unknown>> {
const exposure = gitea.app.publicExposure;
const ingressRetry = gitea.sourceAuthority.webhookSync.ingressRetry;
const webhookHandles = webhookCaddyRoutes(gitea)
.sort((left, right) => right.publicPath.length - left.publicPath.length)
.map((route) => ` handle ${route.publicPath}* {
reverse_proxy 127.0.0.1:${route.remotePort} {
transport http {
response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s
}
}
}`).join("\n\n");
.map((route) => renderGiteaWebhookCaddyHandle(
route,
ingressRetry,
gitea.sourceAuthority.webhookSync.responseBudgetMs,
exposure.pk01.responseHeaderTimeoutSeconds,
))
.join("\n\n");
if (webhookHandles === "") return await applyPk01CaddyBlock(config, "gitea", exposure);
const siteBlock = `${exposure.dns.hostname} {
${webhookHandles}
@@ -1673,10 +1890,9 @@ function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> {
};
}
function mirrorNextCommands(targetId: string): Record<string, string> {
function mirrorSetupNextCommands(targetId: string): Record<string, string> {
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}`,
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}`,
@@ -1684,14 +1900,38 @@ function mirrorNextCommands(targetId: string): Record<string, string> {
};
}
function mirrorReadOnlyNextCommands(targetId: string): Record<string, string> {
return {
status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`,
statusFull: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId} --full`,
webhookStatus: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}`,
webhookStatusFull: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId} --full`,
};
}
function webhookSyncSummary(gitea: GiteaConfig, target: GiteaTarget): Record<string, unknown> {
const sync = targetWebhookSync(gitea, target);
const ingressAttemptBudgetMs = sync.ingressRetry.enabled
? deriveGiteaWebhookAttemptBudgetMs(sync.responseBudgetMs, sync.ingressRetry)
: null;
const ingressResponseHeaderTimeoutMs = sync.ingressRetry.enabled
? deriveGiteaWebhookResponseHeaderTimeoutMs(sync.responseBudgetMs, sync.ingressRetry)
: null;
return {
enabled: sync.enabled,
direction: sync.direction,
publicUrl: githubWebhookPublicUrl(gitea, target),
events: sync.events,
responseBudgetMs: sync.responseBudgetMs,
ingressAttemptBudgetMs,
ingressResponseHeaderTimeoutMs,
ingressRetry: {
...sync.ingressRetry,
method: "POST",
bodyReplay: "request-body-size-limited-and-fully-buffered",
},
bridge: sync.bridge,
gitOpsDelivery: sync.gitOpsDelivery,
frpc: sync.frpc,
valuesPrinted: false,
};
@@ -1865,7 +2105,6 @@ function renderMirrorPlan(result: Record<string, unknown>): RenderedCliResult {
"",
"NEXT",
` bootstrap: ${stringValue(next.bootstrap)}`,
` sync: ${stringValue(next.sync)}`,
` status: ${stringValue(next.status)}`,
"",
"Disclosure: credentials are summarized by presence/fingerprint only.",
@@ -1883,7 +2122,7 @@ function renderMirrorBootstrap(result: Record<string, unknown>): RenderedCliResu
...(repos.length === 0 ? ["-"] : table(["KEY", "OWNER", "REPO", "API_OK"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.sync)}`,
`NEXT ${stringValue(next.webhookStatus)}`,
]);
}
@@ -1927,7 +2166,7 @@ function renderMirrorStatus(result: Record<string, unknown>): RenderedCliResult
...(repos.length === 0 ? ["-"] : table(["KEY", "STATE", "BRANCH", "SNAPSHOT", "SNAPSHOT_REF"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.sync)}`,
`NEXT ${stringValue(next.webhookStatus)}`,
]);
}
@@ -1951,23 +2190,28 @@ function renderMirrorWebhookStatus(result: Record<string, unknown>): RenderedCli
const latestPush = record(repo.latestPushDelivery);
const latest = record(repo.latestDelivery);
const delivery = latestPush.event !== "" ? latestPush : latest;
const bridge = record(repo.lastBridgeEvent);
const inbox = record(repo.durableInboxRecord);
const inboxResult = record(inbox.result);
const inboxState = stringValue(repo.durableInboxState, "missing");
const durableProof = repo.preDurableBootstrap === true
? "pre-durable-bootstrap"
: repo.durableInboxCommitted === true
? `${stringValue(inboxResult.disposition)}:${stringValue(inboxResult.sourceCommit).slice(0, 12)}`
: "-";
return [
stringValue(repo.key),
boolText(repo.hookReady),
stringValue(repo.githubHead).slice(0, 12),
stringValue(repo.branchCommit).slice(0, 12),
stringValue(repo.snapshotCommit).slice(0, 12),
boolText(repo.branchMatchesGitHub),
boolText(repo.authorityMatches),
boolText(repo.bridgeEventStale),
`${stringValue(delivery.event)}:${stringValue(delivery.statusCode)}:${stringValue(delivery.status)}`,
`${stringValue(bridge.event)}:${boolText(bridge.ok)}:${stringValue(bridge.sourceCommit).slice(0, 12)}`,
`${stringValue(repo.deliveryId)}:${stringValue(delivery.statusCode)}:${stringValue(delivery.status)}`,
inboxState,
durableProof,
compactTail(stringValue(repo.error)),
];
});
const repairs = arrayRecords(result.repositories)
.filter((repo) => stringValue(repo.repairCommand, "") !== "")
.map((repo) => [stringValue(repo.key), stringValue(repo.repairCommand)]);
const bridge = record(result.bridge);
const bridgeLogs = record(result.bridgeLogs);
const retry = record(record(record(result.webhook).bridge).retry);
@@ -1978,13 +2222,10 @@ function renderMirrorWebhookStatus(result: Record<string, unknown>): RenderedCli
...table(["TARGET", "BRIDGE", "READY", "RETRY", "URL", "LOGS"], [[stringValue(record(result.target).id), stringValue(bridge.deployment), boolText(bridge.ready), retryText, stringValue(record(result.webhook).publicUrl), `rc=${stringValue(bridgeLogs.exitCode)}`]]),
"",
"GITHUB HOOKS",
...(repos.length === 0 ? ["-"] : table(["KEY", "HOOK", "GH_HEAD", "GITEA", "SNAPSHOT", "MATCH", "STALE", "DELIVERY", "BRIDGE_EVENT", "ERROR"], repos)),
"",
"REPAIR",
...(repairs.length === 0 ? ["-"] : table(["KEY", "COMMAND"], repairs)),
...(repos.length === 0 ? ["-"] : table(["KEY", "HOOK", "GH_HEAD", "GITEA", "SNAPSHOT", "MATCH", "STALE", "DELIVERY", "DURABLE", "PROOF", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${repairs.length === 0 ? stringValue(next.webhookApply) : stringValue(next.repair)}`,
`NEXT ${stringValue(next.webhookStatusFull)}`,
]);
}
@@ -2286,6 +2527,22 @@ function urlField(obj: Record<string, unknown>, key: string, path: string): stri
return value;
}
function gitBranchField(obj: Record<string, unknown>, key: string, path: string): string {
const value = y.stringField(obj, key, path);
if (!/^[A-Za-z0-9._/-]+$/u.test(value) || value.startsWith("/") || value.endsWith("/") || value.includes("..") || value.includes("//")) {
throw new Error(`${configLabel}.${path}.${key} must be a safe Git branch name`);
}
return value;
}
function safeRelativePathField(obj: Record<string, unknown>, key: string, path: string): string {
const value = y.stringField(obj, key, path);
if (value.startsWith("/") || value.endsWith("/") || value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") || !/^[A-Za-z0-9._/-]+$/u.test(value)) {
throw new Error(`${configLabel}.${path}.${key} must be a safe relative path`);
}
return value;
}
function httpsUrlField(obj: Record<string, unknown>, 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`);
+41 -1
View File
@@ -173,12 +173,52 @@ export function parseOpsApplyOptions(args: string[]): OpsApplyOptions {
}
export function readYamlRecord<T = Record<string, unknown>>(path: string, expectedKind?: string): T {
const parsed = Bun.YAML.parse(readFileSync(path, "utf8")) as unknown;
const source = readFileSync(path, "utf8");
assertNoDuplicateYamlMappingKeys(source, repoRelative(path));
const parsed = Bun.YAML.parse(source) as unknown;
const record = asRecord(parsed, path);
if (expectedKind !== undefined && record.kind !== expectedKind) throw new Error(`${repoRelative(path)}.kind must be ${expectedKind}`);
return record as T;
}
export function assertNoDuplicateYamlMappingKeys(source: string, label: string): void {
const scopes: Array<{ indent: number; keys: Map<string, number> }> = [];
let blockScalarIndent: number | null = null;
const lines = source.split(/\r?\n/u);
for (let index = 0; index < lines.length; index += 1) {
const rawLine = lines[index] ?? "";
if (/^\s*$/u.test(rawLine) || /^\s*#/u.test(rawLine) || /^\s*(?:---|\.\.\.)\s*$/u.test(rawLine)) continue;
const indentation = rawLine.match(/^ */u)?.[0].length ?? 0;
if (rawLine.slice(0, indentation).includes("\t")) throw new Error(`${label}:${index + 1} must not indent YAML with tabs`);
if (blockScalarIndent !== null) {
if (indentation > blockScalarIndent) continue;
blockScalarIndent = null;
}
let content = rawLine.slice(indentation);
let keyIndent = indentation;
if (content.startsWith("- ")) {
while (scopes.length > 0 && scopes[scopes.length - 1]!.indent >= indentation + 2) scopes.pop();
content = content.slice(2);
keyIndent = indentation + 2;
} else {
while (scopes.length > 0 && scopes[scopes.length - 1]!.indent > keyIndent) scopes.pop();
}
const match = content.match(/^([A-Za-z0-9_.-]+):(?:\s|$)/u);
if (match === null) continue;
while (scopes.length > 0 && scopes[scopes.length - 1]!.indent > keyIndent) scopes.pop();
let scope = scopes[scopes.length - 1];
if (scope === undefined || scope.indent < keyIndent) {
scope = { indent: keyIndent, keys: new Map<string, number>() };
scopes.push(scope);
}
const key = match[1]!;
const previousLine = scope.keys.get(key);
if (previousLine !== undefined) throw new Error(`${label}:${index + 1} duplicate YAML mapping key ${key} (first declared at line ${previousLine})`);
scope.keys.set(key, index + 1);
if (/^[A-Za-z0-9_.-]+:\s*[|>][-+]?\s*(?:#.*)?$/u.test(content)) blockScalarIndent = indentation;
}
}
export function asRecord(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
@@ -1121,6 +1121,7 @@ function credentialPath(root: string, sourceRef: string): string {
}
function pacPartOf(consumerId: string): string {
if (consumerId.startsWith("platform-infra-")) return "platform-infra";
if (consumerId === "unidesk-host") return "unidesk-host";
if (consumerId.startsWith("sentinel")) return "hwlab-web-probe-sentinel";
if (consumerId.startsWith("hwlab-")) return "hwlab";
+4 -9
View File
@@ -458,15 +458,10 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra kafka offsets --node NC01 --topic hwlab.event.v1",
"bun scripts/cli.ts platform-infra kafka tail --node NC01 --topic hwlab.event.v1 --limit 5",
"bun scripts/cli.ts platform-infra kafka produce --node NC01 --topic hwlab.event.v1 --key <trace-or-session>",
"bun scripts/cli.ts platform-infra gitea plan --target JD01",
"bun scripts/cli.ts platform-infra gitea apply --target JD01 --dry-run",
"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",
"bun scripts/cli.ts platform-infra gitea status --target NC01",
"bun scripts/cli.ts platform-infra gitea validate --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror status --target NC01",
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01",
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer <consumer> --source-commit <sha> --wait",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer <consumer> [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer <consumer> --limit 10 [--json]",
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
import json
import sys
def select_snapshot(branch_commit, snapshot_prefix, refs):
if not branch_commit:
return {
"expectedSnapshotRef": None,
"snapshotRef": None,
"snapshotCommit": None,
"sourceBundleReady": False,
}
expected_ref = f"{snapshot_prefix.rstrip('/')}/{branch_commit}"
snapshot_commit = refs.get(expected_ref)
return {
"expectedSnapshotRef": expected_ref,
"snapshotRef": expected_ref if snapshot_commit else None,
"snapshotCommit": snapshot_commit,
"sourceBundleReady": snapshot_commit == branch_commit,
}
def evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records=None, journal=None):
inbox_records = inbox_records or []
github_head = head.get("sha")
branch_commit = mirror.get("branchCommit")
snapshot_commit = mirror.get("snapshotCommit")
expected_snapshot_ref = f"{repo['snapshot']['prefix'].rstrip('/')}/{github_head}" if github_head else None
snapshot_ref = mirror.get("snapshotRef")
latest_delivery = hook.get("latest")
latest_push_delivery = hook.get("latestPush")
delivery_id = _delivery_id(latest_push_delivery)
delivery_accepted = bool(
isinstance(latest_push_delivery, dict)
and latest_push_delivery.get("statusCode") in (200, 202)
)
correlated_inbox_records = [
record for record in inbox_records
if record.get("repo") == repo["key"]
and record.get("deliveryId") == delivery_id
]
inbox_record = correlated_inbox_records[-1] if correlated_inbox_records else None
pre_durable_bootstrap = _is_pre_durable_bootstrap(latest_push_delivery, inbox_record, journal)
inbox_state = inbox_record.get("state") if inbox_record else ("pre-durable-bootstrap" if pre_durable_bootstrap else None)
inbox_result = inbox_record.get("result") if isinstance(inbox_record, dict) and isinstance(inbox_record.get("result"), dict) else {}
correlated = [
event for event in bridge_events
if event.get("repo") == repo["key"]
and event.get("deliveryId") == delivery_id
and event.get("event") == "github-to-gitea-sync"
and event.get("terminal") is True
]
correlated_event = correlated[-1] if correlated else None
branch_matches_github = bool(github_head and branch_commit == github_head)
snapshot_matches_github = bool(
github_head
and snapshot_commit == github_head
and snapshot_ref == expected_snapshot_ref
)
authority_matches = branch_matches_github and snapshot_matches_github
inbox_correlated = inbox_record is not None
inbox_committed = bool(
inbox_record
and inbox_state == "committed"
and (
inbox_result.get("sourceCommit") == github_head
or (
inbox_result.get("disposition") == "superseded-with-immutable-snapshot"
and inbox_result.get("authorityCommit") == github_head
)
)
)
durable_proof_acceptable = inbox_committed or pre_durable_bootstrap
stale = not (
authority_matches
and delivery_accepted
and durable_proof_acceptable
)
stale_reason = _stale_reason(
hook,
head,
delivery_accepted,
inbox_record,
pre_durable_bootstrap,
github_head,
branch_matches_github,
snapshot_matches_github,
) if stale else None
error = _error(hook, head, stale_reason)
return {
"key": repo["key"],
"repository": repo["upstream"]["repository"],
"branch": repo["upstream"]["branch"],
"hookId": hook.get("hookId"),
"hookReady": hook.get("hookReady") is True,
"active": hook.get("active"),
"status": hook.get("status"),
"githubHead": github_head,
"branchCommit": branch_commit,
"snapshotRef": snapshot_ref,
"expectedSnapshotRef": expected_snapshot_ref,
"snapshotCommit": snapshot_commit,
"branchMatchesGitHub": branch_matches_github,
"snapshotMatchesGitHub": snapshot_matches_github,
"authorityMatches": authority_matches,
"latestDelivery": latest_delivery,
"latestPushDelivery": latest_push_delivery,
"deliveryId": delivery_id,
"correlatedBridgeEvent": correlated_event,
"durableInboxRecord": inbox_record,
"durableInboxState": inbox_state,
"durableInboxCommitted": inbox_committed,
"preDurableBootstrap": pre_durable_bootstrap,
"deliveryAccepted": delivery_accepted,
"bridgeEventStale": stale,
"staleReason": stale_reason,
"error": error,
"valuesPrinted": False,
}
def _delivery_id(delivery):
if not isinstance(delivery, dict):
return ""
return str(delivery.get("deliveryId") or delivery.get("guid") or delivery.get("id") or "")
def _stale_reason(hook, head, delivery_accepted, inbox_record, pre_durable_bootstrap, github_head, branch_matches, snapshot_matches):
if hook.get("hookReady") is not True:
return "github-hook-not-ready"
if head.get("ok") is not True or not github_head:
return "github-head-unavailable"
if not delivery_accepted:
return "latest-push-delivery-failed-or-missing"
if not inbox_record and not pre_durable_bootstrap:
return "no-correlated-durable-inbox-record"
if not pre_durable_bootstrap:
inbox_state = inbox_record.get("state")
if inbox_state != "committed":
return f"durable-inbox-{inbox_state or 'unknown'}-not-committed"
result = inbox_record.get("result") if isinstance(inbox_record.get("result"), dict) else {}
inbox_proves_head = (
result.get("sourceCommit") == github_head
or (
result.get("disposition") == "superseded-with-immutable-snapshot"
and result.get("authorityCommit") == github_head
)
)
if not inbox_proves_head:
return "correlated-inbox-proof-head-mismatch"
if not branch_matches:
return "gitea-branch-behind-github"
if not snapshot_matches:
return "gitea-snapshot-behind-github"
return "authority-proof-incomplete"
def _is_pre_durable_bootstrap(delivery, inbox_record, journal):
if inbox_record is not None or not isinstance(delivery, dict) or not isinstance(journal, dict):
return False
delivered_at = delivery.get("deliveredAt")
created_at = journal.get("createdAt")
if not isinstance(delivered_at, str) or not isinstance(created_at, str):
return False
try:
from datetime import datetime
return datetime.fromisoformat(delivered_at.replace("Z", "+00:00")) < datetime.fromisoformat(created_at.replace("Z", "+00:00"))
except ValueError:
return False
def _error(hook, head, stale_reason):
if hook.get("error"):
return hook.get("error")
if head.get("error"):
return head.get("error")
if stale_reason:
return f"source authority stale: {stale_reason}"
return None
def main():
payload = json.load(sys.stdin)
action = payload.get("action")
if action == "select-snapshot":
result = select_snapshot(payload.get("branchCommit"), payload["snapshotPrefix"], payload.get("refs", {}))
elif action == "evaluate-repository":
result = evaluate_repository(payload["repo"], payload["hook"], payload["head"], payload.get("mirror", {}), payload.get("bridgeEvents", []), payload.get("inboxRecords", []), payload.get("journal"))
else:
raise SystemExit("unsupported action")
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()