fix: 持久化 GitHub 到 Gitea 源码权威
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+4
-7
@@ -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")}`);
|
||||
}
|
||||
@@ -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\"");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user