diff --git a/.tekton/platform-infra-gitea-nc01-pac.yaml b/.tekton/platform-infra-gitea-nc01-pac.yaml new file mode 100644 index 00000000..4aed1adf --- /dev/null +++ b/.tekton/platform-infra-gitea-nc01-pac.yaml @@ -0,0 +1,56 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + generateName: platform-infra-gitea-nc01- + annotations: + pipelinesascode.tekton.dev/on-event: "[push]" + pipelinesascode.tekton.dev/on-target-branch: "[master]" + pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01'" + labels: + app.kubernetes.io/name: platform-infra-gitea-nc01-pac + app.kubernetes.io/part-of: platform-infra + unidesk.ai/source-commit: "{{revision}}" +spec: + timeouts: + pipeline: 180s + taskRunTemplate: + serviceAccountName: default + podTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + securityContext: + fsGroup: 1000 + pipelineSpec: + tasks: + - name: publish-desired-state + taskSpec: + volumes: + - name: workspace + emptyDir: + sizeLimit: 2Gi + steps: + - name: publish + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + imagePullPolicy: IfNotPresent + workingDir: /workspace + env: + - name: SOURCE_COMMIT + value: "{{revision}}" + - name: SOURCE_SNAPSHOT_PREFIX + value: "{{source_snapshot_prefix}}" + script: | + #!/bin/sh + set -eu + rm -rf source platform-infra-gitea-gitops + git clone --filter=blob:none --no-checkout http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git source + cd source + git fetch --depth=1 --filter=blob:none origin "+$SOURCE_SNAPSHOT_PREFIX/$SOURCE_COMMIT:refs/remotes/origin/platform-infra-gitea-source" + git checkout --detach "$SOURCE_COMMIT" + test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" + exec bun scripts/native/cicd/publish-platform-infra-gitea-gitops.mjs \ + --source-root /workspace/source \ + --source-commit "$SOURCE_COMMIT" \ + --worktree /workspace/platform-infra-gitea-gitops + volumeMounts: + - name: workspace + mountPath: /workspace diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 5f165163..028784d6 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -11,6 +11,8 @@ metadata: - 1550 - 1557 - 1560 + - 1760 + - 1768 defaults: targetId: NC01 @@ -61,9 +63,25 @@ sourceAuthority: webhookSync: enabled: true direction: github-to-gitea + # GitHub Cloud 要求 10 秒内返回 2xx;保留公网与 FRP 传输余量。 + # https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries + responseBudgetMs: 6500 publicPath: /_unidesk/github-to-gitea events: - push + ingressRetry: + # PK01 host reconciliation 尚未纳入 PR-merge 自动控制面;关闭后不得声称已应用 Caddy retry。 + # https://github.com/pikasTech/unidesk/issues/1768 + enabled: false + maxBodyBytes: 1048576 + maxRetries: 1 + tryIntervalMs: 250 + dialTimeoutMs: 500 + writeTimeoutMs: 500 + retryStatusCodes: + - 502 + - 503 + - 504 secret: sourceRef: gitea.github-webhook.env sourceKey: GITHUB_WEBHOOK_SECRET @@ -77,10 +95,45 @@ sourceAuthority: replicas: 1 httpPort: 8080 serviceAccountName: gitea-github-sync + shutdownGraceMs: 5000 + inbox: + claimName: gitea-github-sync-inbox + path: /var/lib/gitea-github-sync + storageSize: 1Gi + maxBytes: 805306368 + committedRetentionSeconds: 604800 + cleanupIntervalMs: 60000 retry: - maxAttempts: 4 - initialDelayMs: 2000 - maxDelayMs: 15000 + maxAttempts: 3 + initialDelayMs: 1000 + maxDelayMs: 30000 + terminalRetryDelayMs: 300000 + attemptTimeoutMs: 120000 + scanIntervalMs: 1000 + gitOpsDelivery: + enabled: true + targetId: NC01 + readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + branch: unidesk-host-gitops + sourceSnapshotPrefix: refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01 + desiredManifestPath: deploy/gitops/platform-infra/gitea-nc01/resources.yaml + bootstrapApplicationPath: deploy/gitops/unidesk-host/platform-infra-gitea-nc01-application.yaml + releaseStatePath: deploy/gitops-state/platform-infra/gitea-nc01.json + application: + name: platform-infra-gitea-nc01 + namespace: argocd + project: default + repoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + targetRevision: unidesk-host-gitops + path: deploy/gitops/platform-infra/gitea-nc01 + destinationNamespace: devops-infra + automated: true + author: + name: UniDesk Platform Infra CI + email: platform-infra-ci@unidesk.local + cas: + maxAttempts: 3 frpc: proxyName: platform-infra-gitea-jd01-github-webhook remotePort: 22081 diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 3ad13012..c8afc757 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -10,6 +10,7 @@ metadata: - 1557 - 1560 - 1649 + - 1760 defaults: targetId: NC01 consumerId: hwlab-nc01-v03 @@ -140,6 +141,16 @@ consumers: path: deploy/gitops/unidesk-host destinationNamespace: unidesk automated: true + - id: platform-infra-gitea-nc01 + repositoryRef: sentinel-nc01-v03 + node: NC01 + lane: platform-infra + namespace: devops-infra + pipeline: platform-infra-gitea-nc01-pac + pipelineRunPrefix: platform-infra-gitea-nc01- + argoNamespace: argocd + argoApplication: platform-infra-gitea-nc01 + closeoutGitOpsMirrorFlush: false - extends: templates.consumers.hwlabV03 variables: NODE: JD01 diff --git a/docs/MDTODO/details/github-gitea-durable-source-authority/R1.1_Task_Report.md b/docs/MDTODO/details/github-gitea-durable-source-authority/R1.1_Task_Report.md new file mode 100644 index 00000000..5dfa73de --- /dev/null +++ b/docs/MDTODO/details/github-gitea-durable-source-authority/R1.1_Task_Report.md @@ -0,0 +1,19 @@ +# R1.1 调查报告 + +## 结论 + +- HWLAB `9c91ea0c5c37...` 的 GitHub push delivery 返回 `502`,bridge 日志没有对应新提交,Gitea branch 与 snapshot 均停在 `e76e4d2...`;没有证据表明该请求已被当前 bridge 持久接收。 +- GitHub [不会自动重送失败的 webhook delivery](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries)。因此修复不能补送 #2492 的历史事件,也不得声称可以恢复;只能等待后续真实、非空且业务必要的 HWLAB source PR merge 产生新 push。 +- 当前 PK01 Caddy webhook route 没有有界 ingress retry。bridge 的 YAML retry 只覆盖收到请求后的 Git fetch/push,无法覆盖请求到达前的 FRP/Caddy 连接失败。 +- bridge 成功日志没有 `deliveryId`/payload `after`,status 只取 repo 最后一条日志;旧成功可遮蔽新 delivery 失败。 +- bridge 使用非原子的 `git push --force` 同时更新 branch 与 snapshot,不能证明两个 ref 全成或全败,也允许重写本应 immutable 的 snapshot。 +- mirror observer 在 branch 缺失时按 snapshot ref 字典序选“latest”,会把期望提交隐藏为任意旧 ref。 +- webhook status 仅在 delivery 为 `200/202` 时计算 stale,导致 HWLAB `MATCH=false`、delivery `502` 却显示 `STALE=false`,并输出违反自动交付边界的 `REPAIR` 手工同步提示。 + +## 冻结合同 + +- ingress 只在能够证明请求体可安全重放时做 YAML 有界 retry;4xx 与业务拒绝不重试。 +- bridge 按 `deliveryId/repo/after` 关联并隔离 repo 状态;重复 delivery 幂等。 +- branch 与 `snapshotPrefix/` 通过一次 `git push --atomic` 收敛;snapshot ref 不允许 force rewrite。 +- status 以 GitHub head 为期望提交精确判断 branch/snapshot;任一不匹配均为 stale,最新失败不得被旧成功覆盖。 +- 不提供 `REPAIR`、sync、flush、webhook-test、人工 PipelineRun 或轮询 fallback。 diff --git a/docs/MDTODO/details/github-gitea-durable-source-authority/R1.2_Task_Report.md b/docs/MDTODO/details/github-gitea-durable-source-authority/R1.2_Task_Report.md new file mode 100644 index 00000000..8148e6e6 --- /dev/null +++ b/docs/MDTODO/details/github-gitea-durable-source-authority/R1.2_Task_Report.md @@ -0,0 +1,34 @@ +# R1.2 实现报告 + +## 交付 + +- Webhook HTTP 路径在验签、规范化字段校验后,仅把 `deliveryId/repo/ref/after/payloadSha` 等最小记录写入持久 inbox;文件 `fsync`、原子 rename 与目录 `fsync` 完成后才返回 `202`。 +- 首次投递的 `202` 表示持久接收为 `accepted`,并明确 `refsCommitted=false`。重复投递的 `202` 返回 journal 当前真实状态;相同 delivery 与相同正文幂等,相同 delivery 但正文或规范字段不同返回 `409`。 +- ingress 使用 YAML `responseBudgetMs=6500` 控制从请求进入到 body 读取、持久接收完成的绝对时限,与 worker 单次执行时限相互独立。时限耗尽返回 typed `503`,但不取消正在进行的持久写入;迟到的成功或失败会继续记录,重试仍按同一 delivery 幂等关联。 +- 重复 delivery 响应返回 journal 的真实状态。`accepted`、`processing` 与 `failed` 都保持 `refsCommitted=false`;只有 `committed` 才携带已持久化的 refs proof。 +- 持久 journal 采用严格 schema、不可变 identity、单调 sequence、崩溃恢复和每仓库 committed watermark。PVC 不可用、journal 损坏或容量耗尽时 readiness 失败并返回 `503`。 +- record 列举、worker 调度与 status 统一按不可变 `acceptedSequence` 排序;即使多笔接收记录具有相同毫秒时间戳,也不会回退到 delivery ID 或文件名顺序。 +- worker 每仓库串行、跨仓库并行;普通与 terminal backoff 都由 YAML 配置。terminal cycle 耗尽后仍自动进入下一 cycle,不要求人工重送。 +- executor 抛错以及返回 `undefined`、`null` 或缺少成功证明的 malformed terminal result,都会持久标记为 typed failure 并进入自动重试,不能被误判为成功。 +- Git 收敛先验证精确 `after`,再用一次 `git push --atomic` 更新带 lease 的 branch 与不可变 snapshot;乱序祖先只创建 snapshot,分叉 fail-closed,提交 journal 前重新读取远端 refs 证明结果。 +- SIGTERM/超时会中止活跃 Git 进程组;未完成 record 保留并在新进程恢复。 + +## 自动 desired delivery + +- [UniDesk #1760](https://github.com/pikasTech/unidesk/issues/1760) 新增独立 `platform-infra-gitea-nc01` PaC PipelineRun。它由既有 `sentinel-nc01-v03` Repository CR 在真实 `master` push 上发现,不依赖 todo-note 镜像构建。 +- PipelineRun 使用既有 `default` ServiceAccount,从 immutable source snapshot 执行独立 publisher,不依赖本次 desired 才会创建的 bridge ServiceAccount。 +- publisher 以 CAS 向 `unidesk-host-gitops` 写入三项:现有 parent path 下的 child Argo Application、独立 platform-infra desired path、source release state。只 stage 自己的三个路径,保留并发 todo/sentinel 文件。 +- source state 只允许同提交幂等或祖先向前;同提交的 stored materialization hash 必须与 exact-source renderer 一致,否则 fail-closed。旧事件标记 superseded,分叉拒绝。push race 重新 clone 最新 head 后重试,成功后再 `ls-remote` 证明目标 ref。 + +## Ingress retry 边界 + +- Caddy 请求体安全重放、4xx 不重试、可重试 502/503/504、硬时限与 body cap 的 renderer 和真实 Caddy 测试已经具备。 +- PK01 host 尚无 PR-merge 自动 reconciler,因此 owning YAML 明确设置 `ingressRetry.enabled=false`,继续渲染现有无重试转发,不宣称该能力已上线。 +- 自动 host reconciliation 独立跟踪在 [UniDesk #1768](https://github.com/pikasTech/unidesk/issues/1768) 与 `docs/MDTODO/pk01-caddy-gitea-webhook-auto-reconciliation.md`;禁止手工 apply 补链。 + +## 测试证据 + +- durable bridge:21 个 Node 集成测试通过,覆盖入口绝对时限与迟到写入、重复投递各状态、同毫秒顺序、executor reject 与 malformed result。 +- independent publisher:首次发布/幂等、并发文件保留与 CAS fresh retry、乱序/分叉、同提交冲突 state 共 4 个测试通过。 +- desired bootstrap contract:既有 Repository、既有 ServiceAccount、child Application 与完整 bridge manifest 共 2 个测试通过。 +- Caddy renderer:单元测试通过;Caddy v2.11 真实重放集成测试另行在完整验收组运行。 diff --git a/docs/MDTODO/details/github-gitea-durable-source-authority/R1.3_Task_Report.md b/docs/MDTODO/details/github-gitea-durable-source-authority/R1.3_Task_Report.md new file mode 100644 index 00000000..7a7708dc --- /dev/null +++ b/docs/MDTODO/details/github-gitea-durable-source-authority/R1.3_Task_Report.md @@ -0,0 +1,27 @@ +# R1.3 状态权威报告 + +## 权威关联 + +- GitHub 最新 push delivery GUID 是状态关联起点;旧成功日志不能替代最新 delivery。 +- GUID 必须精确关联持久 inbox record;只有 `state=committed` 且 record result 证明 GitHub head,才视为 durable committed。 +- 重复 HTTP delivery 必须展示 journal 当前真实状态;只有 `committed` 可以返回 refs proof,不能把已接收、处理中或失败记录包装成已提交。 +- committed 后继续比较实时 Gitea branch 与 `snapshotPrefix/`。任一不匹配均为 `MATCH=false`、`STALE=true`。 +- bridge logs 仅保留为辅助诊断;默认表格改为展示 durable state 和持久 refs proof。 + +## Bootstrap 时序 + +- #1760 自身 merge 必然先由旧 bridge 接收,再通过 PaC/GitOps 部署新 durable bridge,因此不能伪造它在新 journal 中存在 record。 +- journal 在 PVC 首建时持久保存 `createdAt`。只有 delivery 时间明确早于该时间、且 branch/snapshot 已经精确对齐,状态才标记 `pre-durable-bootstrap` 并允许 rollout 继续;字段 `durableInboxCommitted=false` 保持真实。 +- 新 durable contract 的首个端到端证据必须来自下一笔真实业务 PR merge,优先 #1764;禁止空提交、伪 webhook 或手工 redelivery。 + +## 手工补偿移除 + +- automatic source authority 开启时,`mirror sync --confirm` 在任何远端 capture 前 fail-closed。 +- synthetic webhook test 的远端伪 push/ping mutation 已删除;CLI tombstone 在任何 capture 前返回只读失败。 +- 默认顶层帮助、模块帮助、status next 与 repair hints 不再引导 sync/test/repair,只公开 Gitea authority 的只读 status、validate 与 webhook status 入口。 + +## 测试证据 + +- status evaluator:8 个场景通过,包括最新失败不被旧成功覆盖、repo 隔离、非 committed 状态、proof mismatch 与 `pre-durable-bootstrap`。 +- authority guard:4 个场景通过;manual sync 与 synthetic test 均证明 `mutation=false` 且没有 remote capture,顶层帮助只暴露四个只读 Gitea authority 命令。 +- YAML duplicate-key guard 在 Bun YAML 解析前拒绝同一 mapping scope 的重复键,避免静默覆盖配置权威。 diff --git a/docs/MDTODO/github-gitea-durable-source-authority.md b/docs/MDTODO/github-gitea-durable-source-authority.md new file mode 100644 index 00000000..61d2b433 --- /dev/null +++ b/docs/MDTODO/github-gitea-durable-source-authority.md @@ -0,0 +1,25 @@ +# GitHub 到 Gitea 持久源码权威收敛 + +本任务修复 NC01 的 GitHub webhook、Gitea controlled mirror 与 immutable snapshot 自动收敛链。来源与边界: + +- [UniDesk #1760](https://github.com/pikasTech/unidesk/issues/1760) +- [UniDesk #1755](https://github.com/pikasTech/unidesk/issues/1755) +- [HWLAB #2492](https://github.com/pikasTech/HWLAB/pull/2492) + +唯一正式触发是 GitHub PR merge;本任务不得执行人工 mirror sync/flush、直接 Gitea push、webhook-test、人工 PipelineRun、branch-follower 或运行面 patch。 + +## R1 [in_progress] + +在 [UniDesk #1760](https://github.com/pikasTech/unidesk/issues/1760) 下恢复 [UniDesk #1755](https://github.com/pikasTech/unidesk/issues/1755) 规定的 PR merge 自动交付,并覆盖 [HWLAB #2492](https://github.com/pikasTech/HWLAB/pull/2492) 暴露的 502/stale authority,完成任务后将详细报告写入[任务报告](./details/github-gitea-durable-source-authority/R1_Task_Report.md)。 +### R1.1 [completed] + +调查 [UniDesk #1760](https://github.com/pikasTech/unidesk/issues/1760) 的 502、旧成功覆盖、ref observer 与 repo 隔离根因,冻结 delivery/head/ref 真值合同,完成任务后将详细报告写入[任务报告](./details/github-gitea-durable-source-authority/R1.1_Task_Report.md)。 +### R1.2 [completed] + +由 YAML 声明 webhook ingress 与 bridge 有界 retry,按 deliveryId/repo/after 幂等排队并以 atomic branch+snapshot push 收敛,完成任务后将详细报告写入[任务报告](./details/github-gitea-durable-source-authority/R1.2_Task_Report.md)。 +### R1.3 [completed] + +修正 mirror/webhook status:按 GitHub head 精确选择 snapshot,MATCH=false 必须 STALE=true,并移除全部 REPAIR/手工补偿 hint,完成任务后将详细报告写入[任务报告](./details/github-gitea-durable-source-authority/R1.3_Task_Report.md)。 +### R1.4 [in_progress] + +覆盖重复 delivery、跨 repo、部分 ref 已存在、atomic failure、retry exhaustion 与 truthful status,提交 non-draft PR/preflight;运行面验收只由修复 PR merge 后新事件完成,完成任务后将详细报告写入[任务报告](./details/github-gitea-durable-source-authority/R1.4_Task_Report.md)。 \ No newline at end of file diff --git a/docs/MDTODO/pk01-caddy-gitea-webhook-auto-reconciliation.md b/docs/MDTODO/pk01-caddy-gitea-webhook-auto-reconciliation.md new file mode 100644 index 00000000..7cdcb1a7 --- /dev/null +++ b/docs/MDTODO/pk01-caddy-gitea-webhook-auto-reconciliation.md @@ -0,0 +1,15 @@ +# PK01 Caddy Gitea Webhook 自动协调 + + +## R1 + +调查现有 PK01 host/Caddy 自动协调入口并冻结 owning YAML 与 CAS 边界(https://github.com/pikasTech/unidesk/issues/1768),完成任务后将详细报告写入[任务报告](./details/pk01-caddy-gitea-webhook-auto-reconciliation/R1_Task_Report.md)。 +## R2 + +实现独立 PaC desired producer 与 PK01 幂等 reconciler,禁止手工 apply 补链(https://github.com/pikasTech/unidesk/issues/1768),完成任务后将详细报告写入[任务报告](./details/pk01-caddy-gitea-webhook-auto-reconciliation/R2_Task_Report.md)。 +## R3 + +增加配置并发保留、校验/reload 失败回滚和状态关联测试(https://github.com/pikasTech/unidesk/issues/1768),完成任务后将详细报告写入[任务报告](./details/pk01-caddy-gitea-webhook-auto-reconciliation/R3_Task_Report.md)。 +## R4 + +用真实业务 PR merge 验收自动 desired→PK01 observed,随后再启用 ingressRetry(https://github.com/pikasTech/unidesk/issues/1768),完成任务后将详细报告写入[任务报告](./details/pk01-caddy-gitea-webhook-auto-reconciliation/R4_Task_Report.md)。 \ No newline at end of file diff --git a/scripts/native/cicd/publish-platform-infra-gitea-gitops.mjs b/scripts/native/cicd/publish-platform-infra-gitea-gitops.mjs new file mode 100644 index 00000000..906be703 --- /dev/null +++ b/scripts/native/cicd/publish-platform-infra-gitea-gitops.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env bun +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +function option(name) { + const index = process.argv.indexOf(name); + if (index === -1) return null; + const value = process.argv[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function record(value, path) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value; +} + +function required(value, path) { + if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`); + return value; +} + +function fullCommit(value, path) { + const commit = required(value, path); + if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error(`${path} must be a full Git commit SHA`); + return commit; +} + +function safeRelativePath(value, path) { + const result = required(value, path); + if (result.startsWith("/") || result.endsWith("/") || !/^[A-Za-z0-9._/-]+$/u.test(result) || result.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) { + throw new Error(`${path} must be a safe relative path`); + } + return result; +} + +function run(command, args, cwd, allowFailure = false) { + const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0 && !allowFailure) { + const detail = `${result.stderr || result.stdout || "command failed"}`.trim().slice(-3000); + throw new Error(`${command} ${args.join(" ")} failed (${result.status}): ${detail}`); + } + return result; +} + +function digest(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function validateDelivery(value) { + const delivery = record(value, "gitOpsDelivery"); + if (delivery.enabled !== true) throw new Error("gitOpsDelivery.enabled must be true"); + const application = record(delivery.application, "gitOpsDelivery.application"); + const author = record(delivery.author, "gitOpsDelivery.author"); + const cas = record(delivery.cas, "gitOpsDelivery.cas"); + const maxAttempts = cas.maxAttempts; + if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 5) throw new Error("gitOpsDelivery.cas.maxAttempts must be an integer from 1 to 5"); + const parsed = { + enabled: true, + targetId: required(delivery.targetId, "gitOpsDelivery.targetId"), + readUrl: required(delivery.readUrl, "gitOpsDelivery.readUrl"), + writeUrl: required(delivery.writeUrl, "gitOpsDelivery.writeUrl"), + branch: required(delivery.branch, "gitOpsDelivery.branch"), + sourceSnapshotPrefix: required(delivery.sourceSnapshotPrefix, "gitOpsDelivery.sourceSnapshotPrefix").replace(/\/+$/u, ""), + desiredManifestPath: safeRelativePath(delivery.desiredManifestPath, "gitOpsDelivery.desiredManifestPath"), + bootstrapApplicationPath: safeRelativePath(delivery.bootstrapApplicationPath, "gitOpsDelivery.bootstrapApplicationPath"), + releaseStatePath: safeRelativePath(delivery.releaseStatePath, "gitOpsDelivery.releaseStatePath"), + application: { + name: required(application.name, "gitOpsDelivery.application.name"), + namespace: required(application.namespace, "gitOpsDelivery.application.namespace"), + project: required(application.project, "gitOpsDelivery.application.project"), + repoUrl: required(application.repoUrl, "gitOpsDelivery.application.repoUrl"), + targetRevision: required(application.targetRevision, "gitOpsDelivery.application.targetRevision"), + path: safeRelativePath(application.path, "gitOpsDelivery.application.path"), + destinationNamespace: required(application.destinationNamespace, "gitOpsDelivery.application.destinationNamespace"), + automated: application.automated === true, + }, + author: { + name: required(author.name, "gitOpsDelivery.author.name"), + email: required(author.email, "gitOpsDelivery.author.email"), + }, + cas: { maxAttempts }, + }; + if (parsed.application.targetRevision !== parsed.branch) throw new Error("gitOpsDelivery.application.targetRevision must match branch"); + if (parsed.application.repoUrl !== parsed.readUrl) throw new Error("gitOpsDelivery.application.repoUrl must match readUrl"); + return parsed; +} + +export function renderPlatformInfraGiteaApplication(deliveryValue) { + const delivery = validateDelivery(deliveryValue); + const application = { + apiVersion: "argoproj.io/v1alpha1", + kind: "Application", + metadata: { + name: delivery.application.name, + namespace: delivery.application.namespace, + labels: { + "app.kubernetes.io/managed-by": "unidesk", + "app.kubernetes.io/part-of": "platform-infra", + }, + }, + spec: { + project: delivery.application.project, + source: { + repoURL: delivery.application.repoUrl, + targetRevision: delivery.application.targetRevision, + path: delivery.application.path, + }, + destination: { + server: "https://kubernetes.default.svc", + namespace: delivery.application.destinationNamespace, + }, + syncPolicy: delivery.application.automated ? { + automated: { prune: true, selfHeal: true }, + syncOptions: ["CreateNamespace=true"], + } : undefined, + }, + }; + return `${Bun.YAML.stringify(application).trim()}\n`; +} + +function readReleaseState(path, delivery) { + if (!existsSync(path)) return null; + const value = record(JSON.parse(readFileSync(path, "utf8")), path); + if (value.version !== 1 || value.kind !== "PlatformInfraGiteaGitOpsState") throw new Error(`${path} must be a PlatformInfraGiteaGitOpsState v1 record`); + if (value.targetId !== delivery.targetId) throw new Error(`${path}.targetId must match gitOpsDelivery.targetId`); + const result = { + sourceCommit: fullCommit(value.sourceCommit, `${path}.sourceCommit`), + desiredManifestSha256: required(value.desiredManifestSha256, `${path}.desiredManifestSha256`), + bootstrapApplicationSha256: required(value.bootstrapApplicationSha256, `${path}.bootstrapApplicationSha256`), + }; + if (!/^[0-9a-f]{64}$/u.test(result.desiredManifestSha256) || !/^[0-9a-f]{64}$/u.test(result.bootstrapApplicationSha256)) { + throw new Error(`${path} materialization hashes must be full SHA-256 hex values`); + } + return result; +} + +function sourceRelation(sourceRoot, delivery, existingCommit, sourceCommit) { + if (existingCommit === sourceCommit) return "same"; + const refs = [existingCommit, sourceCommit].map((commit, index) => `+${delivery.sourceSnapshotPrefix}/${commit}:refs/unidesk/platform-infra-gitea-source-${index}`); + const fetchArgs = existsSync(resolve(sourceRoot, ".git", "shallow")) + ? ["fetch", "--unshallow", "--filter=blob:none", "origin", ...refs] + : ["fetch", "--filter=blob:none", "origin", ...refs]; + run("git", fetchArgs, sourceRoot); + if (run("git", ["merge-base", "--is-ancestor", existingCommit, sourceCommit], sourceRoot, true).status === 0) return "forward"; + if (run("git", ["merge-base", "--is-ancestor", sourceCommit, existingCommit], sourceRoot, true).status === 0) return "superseded"; + return "diverged"; +} + +function checkoutGitOpsBranch(delivery, sourceRoot, worktree) { + rmSync(worktree, { recursive: true, force: true }); + run("git", ["clone", "--no-checkout", delivery.readUrl, worktree], sourceRoot); + run("git", ["remote", "set-url", "--push", "origin", delivery.writeUrl], worktree); + const fetched = run("git", ["fetch", "origin", `+refs/heads/${delivery.branch}:refs/remotes/origin/${delivery.branch}`], worktree, true).status === 0; + if (fetched) { + run("git", ["checkout", "-B", delivery.branch, `refs/remotes/origin/${delivery.branch}`], worktree); + return run("git", ["rev-parse", `refs/remotes/origin/${delivery.branch}`], worktree).stdout.trim(); + } + run("git", ["checkout", "--orphan", delivery.branch], worktree); + run("git", ["rm", "-rf", "."], worktree, true); + return null; +} + +function writeDesiredFiles(worktree, delivery, desiredManifest, applicationManifest, state) { + const entries = [ + [delivery.desiredManifestPath, desiredManifest], + [delivery.bootstrapApplicationPath, applicationManifest], + [delivery.releaseStatePath, `${JSON.stringify(state, null, 2)}\n`], + ]; + for (const [relativePath, content] of entries) { + const target = resolve(worktree, relativePath); + if (!target.startsWith(`${worktree}/`)) throw new Error(`${relativePath} escaped the GitOps worktree`); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content, { encoding: "utf8", mode: 0o644 }); + } + run("git", ["add", "--", ...entries.map(([relativePath]) => relativePath)], worktree); +} + +export function publishPlatformInfraGiteaGitOps({ + delivery: deliveryValue, + desiredManifest, + sourceRoot, + sourceCommit: sourceCommitValue, + worktree, + beforePush = null, +}) { + const delivery = validateDelivery(deliveryValue); + const sourceCommit = fullCommit(sourceCommitValue, "sourceCommit"); + const resolvedSourceRoot = resolve(sourceRoot); + const resolvedWorktree = resolve(worktree); + const checkedOutCommit = run("git", ["rev-parse", "HEAD"], resolvedSourceRoot).stdout.trim(); + if (checkedOutCommit !== sourceCommit) throw new Error(`source checkout ${checkedOutCommit} does not match requested sourceCommit ${sourceCommit}`); + if (typeof desiredManifest !== "string" || !desiredManifest.includes("kind: Deployment") || !desiredManifest.includes("kind: PersistentVolumeClaim") || !desiredManifest.includes("kind: ServiceAccount")) { + throw new Error("desired manifest must contain the durable bridge Deployment, PersistentVolumeClaim and ServiceAccount"); + } + const applicationManifest = renderPlatformInfraGiteaApplication(delivery); + const state = { + version: 1, + kind: "PlatformInfraGiteaGitOpsState", + targetId: delivery.targetId, + sourceCommit, + desiredManifestSha256: digest(desiredManifest), + bootstrapApplicationSha256: digest(applicationManifest), + }; + let lastPushError = ""; + for (let attempt = 1; attempt <= delivery.cas.maxAttempts; attempt += 1) { + const baseCommit = checkoutGitOpsBranch(delivery, resolvedSourceRoot, resolvedWorktree); + const statePath = resolve(resolvedWorktree, delivery.releaseStatePath); + const existingState = readReleaseState(statePath, delivery); + if (existingState !== null) { + const relation = sourceRelation(resolvedSourceRoot, delivery, existingState.sourceCommit, sourceCommit); + if (relation === "same" && ( + existingState.desiredManifestSha256 !== state.desiredManifestSha256 + || existingState.bootstrapApplicationSha256 !== state.bootstrapApplicationSha256 + )) { + throw new Error(`same source commit ${sourceCommit} has conflicting GitOps materialization state`); + } + if (relation === "superseded") { + return { ok: true, phase: "gitops-publish", status: "superseded", sourceCommit, authoritativeSourceCommit: existingState.sourceCommit, changed: false, pushAttempts: attempt - 1, valuesPrinted: false }; + } + if (relation === "diverged") throw new Error(`source commit ${sourceCommit} diverges from authoritative GitOps source ${existingState.sourceCommit}`); + } + writeDesiredFiles(resolvedWorktree, delivery, desiredManifest, applicationManifest, state); + run("git", ["config", "user.name", delivery.author.name], resolvedWorktree); + run("git", ["config", "user.email", delivery.author.email], resolvedWorktree); + const changed = run("git", ["diff", "--cached", "--quiet"], resolvedWorktree, true).status !== 0; + if (!changed) { + return { ok: true, phase: "gitops-publish", status: "idempotent", sourceCommit, gitOpsCommit: baseCommit, changed: false, pushAttempts: attempt - 1, valuesPrinted: false }; + } + run("git", ["commit", "-m", `platform-infra: deploy gitea bridge ${sourceCommit.slice(0, 12)}`], resolvedWorktree); + const gitOpsCommit = run("git", ["rev-parse", "HEAD"], resolvedWorktree).stdout.trim(); + if (typeof beforePush === "function") beforePush({ attempt, baseCommit, gitOpsCommit, worktree: resolvedWorktree }); + const lease = baseCommit === null + ? `--force-with-lease=refs/heads/${delivery.branch}:` + : `--force-with-lease=refs/heads/${delivery.branch}:${baseCommit}`; + const push = run("git", ["push", "--porcelain", lease, "origin", `HEAD:refs/heads/${delivery.branch}`], resolvedWorktree, true); + if (push.status !== 0) { + lastPushError = `${push.stderr || push.stdout || "CAS push failed"}`.trim().slice(-2000); + continue; + } + const remoteCommit = run("git", ["ls-remote", delivery.writeUrl, `refs/heads/${delivery.branch}`], resolvedWorktree).stdout.trim().split(/\s+/u)[0] ?? ""; + if (remoteCommit !== gitOpsCommit) throw new Error(`post-push GitOps ref proof failed: expected ${gitOpsCommit}, observed ${remoteCommit}`); + return { ok: true, phase: "gitops-publish", status: "published", sourceCommit, gitOpsCommit, changed: true, pushAttempts: attempt, valuesPrinted: false }; + } + throw new Error(`GitOps CAS push failed after ${delivery.cas.maxAttempts} attempts: ${lastPushError || "unknown error"}`); +} + +async function main() { + const sourceRoot = resolve(option("--source-root") ?? process.cwd()); + const sourceCommit = fullCommit(option("--source-commit"), "--source-commit"); + const worktree = resolve(option("--worktree") ?? "/workspace/platform-infra-gitea-gitops"); + const moduleUrl = pathToFileURL(resolve(sourceRoot, "scripts", "src", "platform-infra-gitea.ts")).href; + const module = await import(moduleUrl); + const delivery = module.readGiteaWebhookGitOpsDelivery(); + const desiredManifest = module.renderGiteaWebhookSyncDesiredManifest(delivery.targetId); + const result = publishPlatformInfraGiteaGitOps({ delivery, desiredManifest, sourceRoot, sourceCommit, worktree }); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +if (import.meta.main) await main(); diff --git a/scripts/native/cicd/publish-platform-infra-gitea-gitops.test.ts b/scripts/native/cicd/publish-platform-infra-gitea-gitops.test.ts new file mode 100644 index 00000000..2196bb1a --- /dev/null +++ b/scripts/native/cicd/publish-platform-infra-gitea-gitops.test.ts @@ -0,0 +1,229 @@ +import { afterEach, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { publishPlatformInfraGiteaGitOps } from "./publish-platform-infra-gitea-gitops.mjs"; + +const roots: string[] = []; + +afterEach(() => { + while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +test("independent desired publisher is idempotent and preserves unrelated GitOps files", () => { + const fixture = createFixture(); + checkout(fixture.sourceWork, fixture.commits.second); + const first = publish(fixture, fixture.commits.second); + expect(first).toMatchObject({ ok: true, status: "published", changed: true, pushAttempts: 1 }); + expect(show(fixture.gitOpsBare, fixture.delivery.branch, "deploy/gitops/unidesk-host/todo-note.yaml")).toBe("existing todo\n"); + expect(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.desiredManifestPath)).toContain("kind: PersistentVolumeClaim"); + expect(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.bootstrapApplicationPath)).toContain("name: platform-infra-gitea-nc01"); + + const second = publish(fixture, fixture.commits.second); + expect(second).toMatchObject({ ok: true, status: "idempotent", changed: false, pushAttempts: 0 }); +}); + +test("CAS retries from a fresh head and preserves a concurrent todo-note update", () => { + const fixture = createFixture(); + checkout(fixture.sourceWork, fixture.commits.second); + let raced = false; + const result = publishPlatformInfraGiteaGitOps({ + delivery: fixture.delivery, + desiredManifest: fixture.desiredManifest, + sourceRoot: fixture.sourceWork, + sourceCommit: fixture.commits.second, + worktree: fixture.publisherWorktree, + beforePush: ({ attempt }: { attempt: number }) => { + if (attempt !== 1 || raced) return; + raced = true; + pushConcurrentFile(fixture.root, fixture.gitOpsBare, fixture.delivery.branch); + }, + }); + expect(result).toMatchObject({ ok: true, status: "published", changed: true, pushAttempts: 2 }); + expect(show(fixture.gitOpsBare, fixture.delivery.branch, "deploy/gitops/unidesk-host/concurrent-todo.yaml")).toBe("concurrent todo\n"); + expect(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.releaseStatePath)).toContain(fixture.commits.second); +}); + +test("out-of-order source is superseded and divergent history fails closed", () => { + const fixture = createFixture(); + checkout(fixture.sourceWork, fixture.commits.second); + expect(publish(fixture, fixture.commits.second)).toMatchObject({ status: "published" }); + + checkout(fixture.sourceWork, fixture.commits.first); + expect(publish(fixture, fixture.commits.first)).toMatchObject({ + ok: true, + status: "superseded", + authoritativeSourceCommit: fixture.commits.second, + changed: false, + }); + expect(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.releaseStatePath)).toContain(fixture.commits.second); + + checkout(fixture.sourceWork, fixture.commits.diverged); + expect(() => publish(fixture, fixture.commits.diverged)).toThrow(/diverges from authoritative GitOps source/); + expect(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.releaseStatePath)).toContain(fixture.commits.second); +}); + +test("same source commit with conflicting stored materialization hashes fails closed", () => { + const fixture = createFixture(); + checkout(fixture.sourceWork, fixture.commits.second); + expect(publish(fixture, fixture.commits.second)).toMatchObject({ status: "published" }); + mutateGitOpsState(fixture.root, fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.releaseStatePath, (state) => ({ + ...state, + desiredManifestSha256: "f".repeat(64), + })); + expect(() => publish(fixture, fixture.commits.second)).toThrow(/same source commit .* conflicting GitOps materialization state/); + const state = JSON.parse(show(fixture.gitOpsBare, fixture.delivery.branch, fixture.delivery.releaseStatePath)); + expect(state.desiredManifestSha256).toBe("f".repeat(64)); +}); + +function createFixture() { + const root = mkdtempSync(join(tmpdir(), "unidesk-platform-infra-gitea-publish-")); + roots.push(root); + const sourceWork = join(root, "source-work"); + const sourceBare = join(root, "source.git"); + const gitOpsSeed = join(root, "gitops-seed"); + const gitOpsBare = join(root, "gitops.git"); + mkdirSync(sourceWork); + git(["init", "-b", "master"], sourceWork); + identity(sourceWork); + write(sourceWork, "source.txt", "first\n"); + git(["add", "source.txt"], sourceWork); + git(["commit", "-m", "first"], sourceWork); + const first = git(["rev-parse", "HEAD"], sourceWork).stdout.trim(); + write(sourceWork, "source.txt", "second\n"); + git(["commit", "-am", "second"], sourceWork); + const second = git(["rev-parse", "HEAD"], sourceWork).stdout.trim(); + git(["checkout", "-b", "diverged", first], sourceWork); + write(sourceWork, "source.txt", "diverged\n"); + git(["commit", "-am", "diverged"], sourceWork); + const diverged = git(["rev-parse", "HEAD"], sourceWork).stdout.trim(); + git(["checkout", "master"], sourceWork); + git(["init", "--bare", sourceBare], root); + git(["remote", "add", "origin", fileUrl(sourceBare)], sourceWork); + git(["push", "origin", "master"], sourceWork); + const prefix = "refs/unidesk/snapshots/test-unidesk-master"; + for (const commit of [first, second, diverged]) git(["push", "origin", `${commit}:${prefix}/${commit}`], sourceWork); + + mkdirSync(gitOpsSeed); + git(["init", "-b", "unidesk-host-gitops"], gitOpsSeed); + identity(gitOpsSeed); + write(gitOpsSeed, "deploy/gitops/unidesk-host/todo-note.yaml", "existing todo\n"); + git(["add", "."], gitOpsSeed); + git(["commit", "-m", "seed gitops"], gitOpsSeed); + git(["init", "--bare", gitOpsBare], root); + git(["remote", "add", "origin", fileUrl(gitOpsBare)], gitOpsSeed); + git(["push", "origin", "unidesk-host-gitops"], gitOpsSeed); + + const delivery = { + enabled: true, + targetId: "NC01", + readUrl: fileUrl(gitOpsBare), + writeUrl: fileUrl(gitOpsBare), + branch: "unidesk-host-gitops", + sourceSnapshotPrefix: prefix, + desiredManifestPath: "deploy/gitops/platform-infra/gitea-nc01/resources.yaml", + bootstrapApplicationPath: "deploy/gitops/unidesk-host/platform-infra-gitea-nc01-application.yaml", + releaseStatePath: "deploy/gitops-state/platform-infra/gitea-nc01.json", + application: { + name: "platform-infra-gitea-nc01", + namespace: "argocd", + project: "default", + repoUrl: fileUrl(gitOpsBare), + targetRevision: "unidesk-host-gitops", + path: "deploy/gitops/platform-infra/gitea-nc01", + destinationNamespace: "devops-infra", + automated: true, + }, + author: { name: "UniDesk Test", email: "unidesk-test@example.invalid" }, + cas: { maxAttempts: 3 }, + }; + return { + root, + sourceWork, + gitOpsBare, + publisherWorktree: join(root, "publisher-worktree"), + commits: { first, second, diverged }, + delivery, + desiredManifest: `--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: bridge +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: inbox +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bridge +`, + }; +} + +function publish(fixture: ReturnType, sourceCommit: string) { + return publishPlatformInfraGiteaGitOps({ + delivery: fixture.delivery, + desiredManifest: fixture.desiredManifest, + sourceRoot: fixture.sourceWork, + sourceCommit, + worktree: fixture.publisherWorktree, + }); +} + +function pushConcurrentFile(root: string, remote: string, branch: string) { + const worktree = join(root, "concurrent-gitops"); + rmSync(worktree, { recursive: true, force: true }); + git(["clone", "--branch", branch, fileUrl(remote), worktree], root); + identity(worktree); + write(worktree, "deploy/gitops/unidesk-host/concurrent-todo.yaml", "concurrent todo\n"); + git(["add", "."], worktree); + git(["commit", "-m", "concurrent todo update"], worktree); + git(["push", "origin", branch], worktree); +} + +function mutateGitOpsState(root: string, remote: string, branch: string, relativePath: string, mutate: (state: Record) => Record) { + const worktree = join(root, "mutate-gitops-state"); + rmSync(worktree, { recursive: true, force: true }); + git(["clone", "--branch", branch, fileUrl(remote), worktree], root); + identity(worktree); + const path = join(worktree, relativePath); + writeFileSync(path, `${JSON.stringify(mutate(JSON.parse(readFileSync(path, "utf8"))), null, 2)}\n`); + git(["add", "--", relativePath], worktree); + git(["commit", "-m", "inject conflicting materialization state"], worktree); + git(["push", "origin", branch], worktree); +} + +function checkout(worktree: string, commit: string) { + git(["checkout", "--detach", commit], worktree); +} + +function show(remote: string, branch: string, path: string): string { + const result = git(["--git-dir", remote, "show", `${branch}:${path}`], dirname(remote)); + return result.stdout; +} + +function write(root: string, relativePath: string, content: string) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + expect(readFileSync(path, "utf8")).toBe(content); +} + +function identity(root: string) { + git(["config", "user.name", "UniDesk Test"], root); + git(["config", "user.email", "unidesk-test@example.invalid"], root); +} + +function fileUrl(path: string): string { + return `file://${path}`; +} + +function git(args: string[], cwd: string) { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + return result; +} diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 6f5d8ead..04f50f14 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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.", }; diff --git a/scripts/src/platform-infra-gitea-authority-guard.test.ts b/scripts/src/platform-infra-gitea-authority-guard.test.ts new file mode 100644 index 00000000..0ca2393d --- /dev/null +++ b/scripts/src/platform-infra-gitea-authority-guard.test.ts @@ -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 } { + 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) }; +} diff --git a/scripts/src/platform-infra-gitea-caddy-retry.test.ts b/scripts/src/platform-infra-gitea-caddy-retry.test.ts new file mode 100644 index 00000000..795e1880 --- /dev/null +++ b/scripts/src/platform-infra-gitea-caddy-retry.test.ts @@ -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> = []; + +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>(); + 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 { + 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 { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); +} + +async function close(server: Server): Promise { + if (!server.listening) return; + await new Promise((resolve) => server.close(() => resolve())); +} + +async function waitForCaddy(port: number, stderr: Buffer[]): Promise { + 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")}`); +} diff --git a/scripts/src/platform-infra-gitea-caddy.ts b/scripts/src/platform-infra-gitea-caddy.ts new file mode 100644 index 00000000..4950c058 --- /dev/null +++ b/scripts/src/platform-infra-gitea-caddy.ts @@ -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, +): 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, +): 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, +): 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 + } + } + }`; +} diff --git a/scripts/src/platform-infra-gitea-github-sync-server.mjs b/scripts/src/platform-infra-gitea-github-sync-server.mjs index 76e9fcd9..a9a5d16a 100644 --- a/scripts/src/platform-infra-gitea-github-sync-server.mjs +++ b/scripts/src/platform-infra-gitea-github-sync-server.mjs @@ -1,171 +1,1275 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; -import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdir, open, readFile, readdir, rename, stat, unlink } from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; -const port = Number.parseInt(process.env.UNIDESK_GITEA_WEBHOOK_PORT || "8080", 10); -const path = process.env.UNIDESK_GITEA_WEBHOOK_PATH || "/_unidesk/github-to-gitea"; -const repos = JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8")); -const githubToken = requiredEnv("GITHUB_TOKEN"); -const giteaUsername = requiredEnv("GITEA_USERNAME"); -const giteaPassword = requiredEnv("GITEA_PASSWORD"); -const webhookSecret = requiredEnv("GITHUB_WEBHOOK_SECRET"); -const giteaBaseUrl = requiredEnv("UNIDESK_GITEA_SERVICE_BASE_URL").replace(/\/+$/u, ""); -const retryMaxAttempts = positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_MAX_ATTEMPTS"); -const retryInitialDelayMs = positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_INITIAL_DELAY_MS"); -const retryMaxDelayMs = positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_MAX_DELAY_MS"); -const githubAuthHeader = `Authorization: Basic ${Buffer.from(`x-access-token:${githubToken}`).toString("base64")}`; -const giteaAuthHeader = `Authorization: Basic ${Buffer.from(`${giteaUsername}:${giteaPassword}`).toString("base64")}`; - -const syncStates = new Map(); - -createServer(async (req, res) => { - const startedAt = Date.now(); - try { - const url = new URL(req.url || "/", "http://gitea-github-sync.local"); - if (req.method === "GET" && url.pathname === "/healthz") { - writeJson(res, 200, { ok: true, repos: repos.length, valuesPrinted: false }); - return; +export function createGithubSyncHandler(config) { + const controller = config.controller ?? createDurableSyncController(config); + if (!Number.isInteger(config.responseBudgetMs) || config.responseBudgetMs < 1) throw new Error("responseBudgetMs must be a positive integer"); + return async (req, res) => { + const responseDeadlineMs = Date.now() + config.responseBudgetMs; + const requestContext = { deliveryId: null, repository: null, ref: null, requestedCommit: null }; + try { + const url = new URL(req.url || "/", "http://gitea-github-sync.local"); + if (req.method === "GET" && url.pathname === "/healthz") { + const readiness = controller.readiness(); + writeJson(res, readiness.ready ? 200 : 503, { ok: readiness.ready, repos: config.repos.length, durableInbox: readiness, valuesPrinted: false }); + return; + } + if (req.method === "GET" && url.pathname === "/status") { + const status = await controller.status(); + writeJson(res, status.ready ? 200 : 503, { ok: status.ready, ...status, valuesPrinted: false }); + return; + } + if (req.method !== "POST" || url.pathname !== config.path) { + writeJson(res, 404, { ok: false, error: "not-found", valuesPrinted: false }); + return; + } + const deliveryId = singleHeader(req.headers["x-github-delivery"]); + requestContext.deliveryId = deliveryId || null; + const body = await waitWithinResponseBudget( + readBody(req, config.maxBodyBytes), + responseDeadlineMs, + "request-body-response-deadline-exhausted", + ); + verifySignature(req.headers["x-hub-signature-256"], body, config.webhookSecret); + const event = String(req.headers["x-github-event"] || ""); + if (!deliveryId) throw new HttpError(400, "missing-github-delivery-id"); + if (event === "ping") { + writeJson(res, 200, { ok: true, event, deliveryId, disposition: "pong", valuesPrinted: false }); + return; + } + if (event !== "push") { + writeJson(res, 202, { ok: true, event, deliveryId, disposition: "ignored-event", valuesPrinted: false }); + return; + } + const payload = parsePayload(body); + const repository = String(payload?.repository?.full_name || ""); + const ref = String(payload?.ref || ""); + const requestedCommit = String(payload?.after || ""); + Object.assign(requestContext, { repository, ref, requestedCommit }); + const repo = config.repos.find((item) => item?.upstream?.repository === repository && ref === `refs/heads/${item?.upstream?.branch}`); + if (!repo || /^0+$/u.test(requestedCommit)) { + writeJson(res, 202, { ok: true, event, deliveryId, repository, ref, disposition: "ignored-repo-ref-or-delete", valuesPrinted: false }); + return; + } + if (!/^[0-9a-f]{40,64}$/u.test(requestedCommit)) throw new HttpError(400, "invalid-push-after"); + const delivery = { + deliveryId, + repository, + ref, + requestedCommit, + payloadSha: createHash("sha256").update(body).digest("hex"), + receivedAt: new Date().toISOString(), + }; + const acceptPromise = Promise.resolve().then(() => controller.accept(repo, delivery)); + let result; + try { + result = await waitWithinResponseBudget( + acceptPromise, + responseDeadlineMs, + "durable-acceptance-response-deadline-exhausted", + ); + } catch (error) { + if (error instanceof HttpError && error.message === "durable-acceptance-response-deadline-exhausted") { + void acceptPromise.then( + (lateResult) => safeLog(config.log, deliveryLog("github-to-gitea-delivery-accepted-after-response-deadline", repo, delivery, { + ok: true, + state: lateResult.state, + duplicate: lateResult.duplicate, + })), + (lateError) => safeLog(config.log, deliveryLog("github-to-gitea-delivery-persist-failed-after-response-deadline", repo, delivery, { + ok: false, + errorType: "durable-acceptance-late-failure", + error: redact(String(lateError?.message || lateError)), + })), + ); + } + throw error; + } + const refsCommitted = result.state === "committed"; + writeJson(res, 202, { + ok: true, + event, + deliveryId, + repository, + ref, + repo: repo.key, + requestedCommit, + state: result.state, + disposition: result.duplicate ? "duplicate-durable-inbox-record" : "durable-inbox-accepted", + refsCommitted, + ...(refsCommitted && result.result ? { result: result.result } : {}), + valuesPrinted: false, + }); + } catch (error) { + const status = error instanceof HttpError ? error.status : 503; + safeLog(config.log, { + event: "github-to-gitea-request-rejected", + ...requestContext, + ok: false, + status, + error: redact(String(error?.message || error)), + valuesPrinted: false, + }); + writeJson(res, status, { ok: false, error: error instanceof HttpError ? error.message : "internal-sync-error", valuesPrinted: false }); } - if (req.method !== "POST" || url.pathname !== path) { - writeJson(res, 404, { ok: false, error: "not-found", valuesPrinted: false }); - return; - } - const body = await readBody(req); - verifySignature(req.headers["x-hub-signature-256"], body); - const event = String(req.headers["x-github-event"] || ""); - if (event === "ping") { - writeJson(res, 200, { ok: true, event, disposition: "pong", valuesPrinted: false }); - return; - } - if (event !== "push") { - writeJson(res, 202, { ok: true, event, disposition: "ignored-event", valuesPrinted: false }); - return; - } - const payload = JSON.parse(body.toString("utf8") || "{}"); - const repository = payload?.repository?.full_name; - const ref = payload?.ref; - const repo = repos.find((item) => item?.upstream?.repository === repository && ref === `refs/heads/${item?.upstream?.branch}`); - if (!repo) { - writeJson(res, 202, { ok: true, event, repository, ref, disposition: "ignored-repo-or-ref", valuesPrinted: false }); - return; - } - const state = syncState(repo.key); - if (state.running) { - state.pending = true; - log({ event: "github-to-gitea-sync-queued", repo: repo.key, repository, ref, disposition: "pending-rerun", valuesPrinted: false }); - writeJson(res, 202, { ok: true, event, repository, ref, disposition: "queued-after-running", repo: repo.key, valuesPrinted: false }); - return; - } - state.running = true; - state.pending = false; - writeJson(res, 202, { ok: true, event, repository, ref, repo: repo.key, disposition: "accepted", valuesPrinted: false }); - setImmediate(() => { - void runSyncLoop(repo, state, startedAt); - }); - } catch (error) { - log({ event: "github-to-gitea-sync-error", ok: false, error: String(error?.message || error), valuesPrinted: false }); - writeJson(res, 500, { ok: false, error: String(error?.message || error), valuesPrinted: false }); - } -}).listen(port, "0.0.0.0", () => { - log({ event: "github-to-gitea-sync-listening", port, path, repos: repos.map((repo) => repo.key), valuesPrinted: false }); -}); - -function syncState(key) { - let state = syncStates.get(key); - if (!state) { - state = { running: false, pending: false }; - syncStates.set(key, state); - } - return state; + }; } -async function runSyncLoop(repo, state, startedAt) { - let attempt = 0; - try { - do { - state.pending = false; - attempt += 1; - for (let syncAttempt = 1; syncAttempt <= retryMaxAttempts; syncAttempt += 1) { - const sync = syncRepository(repo); - const willRetry = !sync.ok && syncAttempt < retryMaxAttempts; - log({ - event: "github-to-gitea-sync", - repo: repo.key, - ok: sync.ok, - sourceCommit: sync.sourceCommit, - snapshotRef: sync.snapshotRef, - error: sync.error ? redact(sync.error) : null, - attempt, - syncAttempt, - maxSyncAttempts: retryMaxAttempts, - willRetry, - pendingRerun: state.pending, - elapsedMs: Date.now() - startedAt, +export function createDurableInboxStore(options) { + const inboxDirectory = options.path; + const maxBytes = options.maxBytes; + const now = options.now ?? Date.now; + let totalBytes = 0; + let nextAcceptedSequence = 1; + let initialized = false; + let metadata = null; + let mutationTail = Promise.resolve(); + + function withMutationLock(task) { + const result = mutationTail.then(task); + mutationTail = result.catch(() => undefined); + return result; + } + + async function initialize() { + await mkdir(inboxDirectory, { recursive: true, mode: 0o700 }); + const probePath = join(inboxDirectory, `.readiness-${randomUUID()}`); + await durableWrite(probePath, '{"ok":true}\n'); + await unlink(probePath); + await syncDirectory(inboxDirectory); + const metadataPath = join(inboxDirectory, ".journal-metadata"); + try { + metadata = validateJournalMetadata(JSON.parse(await readFile(metadataPath, "utf8"))); + } catch (error) { + if (error?.code !== "ENOENT") throw new DurableInboxError(503, "durable-inbox-metadata-invalid"); + metadata = validateJournalMetadata({ + version: 1, + kind: "GiteaGithubSyncDurableInbox", + createdAt: new Date(now()).toISOString(), + }); + await durableWrite(metadataPath, serializeRecord(metadata)); + } + totalBytes = 0; + nextAcceptedSequence = 1; + for (const entry of await readdir(inboxDirectory, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const path = join(inboxDirectory, entry.name); + const record = await readRecordPath(path); + nextAcceptedSequence = Math.max(nextAcceptedSequence, record.acceptedSequence + 1); + totalBytes += (await stat(path)).size; + } + initialized = true; + return { totalBytes, maxBytes }; + } + + async function accept(repo, delivery) { + return await withMutationLock(async () => { + assertInitialized(); + const path = recordPath(inboxDirectory, delivery.deliveryId); + const existing = await readRecordPath(path, true); + const fingerprint = deliveryFingerprint(repo, delivery); + if (existing) { + if (existing.fingerprint !== fingerprint) throw new DurableInboxError(409, "github-delivery-id-payload-conflict"); + return { record: existing, duplicate: true }; + } + const now = delivery.receivedAt || new Date().toISOString(); + const record = { + version: 1, + deliveryId: delivery.deliveryId, + fingerprint, + repoKey: repo.key, + repository: delivery.repository, + ref: delivery.ref, + requestedCommit: delivery.requestedCommit, + payloadSha: delivery.payloadSha, + receivedAt: now, + acceptedAt: now, + acceptedSequence: nextAcceptedSequence, + updatedAt: now, + state: "accepted", + totalAttempts: 0, + cycleAttempt: 0, + nextAttemptAt: now, + result: null, + lastError: null, + }; + const serialized = serializeRecord(validateInboxRecord(record)); + if (totalBytes + Buffer.byteLength(serialized) > maxBytes) { + throw new DurableInboxError(503, "durable-inbox-capacity-exhausted"); + } + await durableWrite(path, serialized); + totalBytes += Buffer.byteLength(serialized); + nextAcceptedSequence += 1; + return { record, duplicate: false }; + }); + } + + async function get(deliveryId) { + assertInitialized(); + return await readRecordPath(recordPath(inboxDirectory, deliveryId), true); + } + + async function list() { + assertInitialized(); + const records = []; + for (const entry of await readdir(inboxDirectory, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + records.push(await readRecordPath(join(inboxDirectory, entry.name))); + } + return sortInboxRecordsByAcceptedSequence(records); + } + + async function update(deliveryId, updater) { + return await withMutationLock(async () => { + assertInitialized(); + const path = recordPath(inboxDirectory, deliveryId); + const previous = await readRecordPath(path); + const previousSize = (await stat(path)).size; + const next = updater(structuredClone(previous)); + const identityFields = ["version", "deliveryId", "fingerprint", "repoKey", "repository", "ref", "requestedCommit", "payloadSha", "receivedAt", "acceptedAt", "acceptedSequence"]; + if (!next || identityFields.some((field) => next[field] !== previous[field])) { + throw new DurableInboxError(503, "durable-inbox-invalid-record-update"); + } + const serialized = serializeRecord(validateInboxRecord(next)); + await durableWrite(path, serialized); + totalBytes += Buffer.byteLength(serialized) - previousSize; + return next; + }); + } + + async function recoverProcessing(now) { + const records = await list(); + for (const record of records) { + if (record.state !== "processing") continue; + await update(record.deliveryId, (current) => ({ + ...current, + state: "accepted", + updatedAt: now, + nextAttemptAt: now, + recoveredAfterRestart: true, + lastError: { errorType: "worker-restarted-during-processing", retryable: true }, + })); + } + } + + async function cleanupCommitted(cutoffMs) { + return await withMutationLock(async () => { + assertInitialized(); + let removed = 0; + const entries = (await readdir(inboxDirectory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")); + const records = []; + for (const entry of entries) { + const path = join(inboxDirectory, entry.name); + records.push({ entry, path, record: await readRecordPath(path) }); + } + const latestCommittedByRepo = new Map(); + for (const item of records) { + if (item.record.state !== "committed") continue; + const current = latestCommittedByRepo.get(item.record.repoKey); + if (!current || item.record.acceptedSequence > current.record.acceptedSequence) { + latestCommittedByRepo.set(item.record.repoKey, item); + } + } + for (const item of records) { + const { path, record } = item; + if (record.state !== "committed" || latestCommittedByRepo.get(record.repoKey) === item || Date.parse(record.committedAt) > cutoffMs) continue; + const size = (await stat(path)).size; + await unlink(path); + totalBytes = Math.max(0, totalBytes - size); + removed += 1; + } + if (removed > 0) await syncDirectory(inboxDirectory); + return removed; + }); + } + + function capacityAvailable() { + return initialized && totalBytes < maxBytes; + } + + function usage() { + return { totalBytes, maxBytes, capacityAvailable: capacityAvailable() }; + } + + function journalMetadata() { + assertInitialized(); + return structuredClone(metadata); + } + + function assertInitialized() { + if (!initialized) throw new DurableInboxError(503, "durable-inbox-unavailable"); + } + + return { initialize, accept, get, list, update, recoverProcessing, cleanupCommitted, capacityAvailable, usage, journalMetadata }; +} + +export function createDurableSyncController(options) { + const now = options.now ?? Date.now; + const store = options.store ?? createDurableInboxStore(options.inbox); + const retry = options.workerRetry; + const workerAbort = new AbortController(); + const executor = options.executor ?? createSyncCoordinator({ + ...options, + signal: workerAbort.signal, + responseBudgetMs: retry.attemptTimeoutMs, + retryMaxAttempts: 1, + retryInitialDelayMs: retry.initialDelayMs, + retryMaxDelayMs: retry.maxDelayMs, + }); + const repoByKey = new Map(options.repos.map((repo) => [repo.key, repo])); + const repoTails = new Map(); + const activeDeliveries = new Set(); + const inFlight = new Set(); + let storageReady = false; + let storageError = "durable-inbox-starting"; + let capacityExhausted = false; + let stopped = true; + let scanTimer = null; + let scanRunning = false; + let lastCleanupAt = 0; + + async function start() { + if (!stopped) return; + stopped = false; + try { + await store.initialize(); + await store.recoverProcessing(new Date(now()).toISOString()); + storageReady = true; + storageError = null; + capacityExhausted = false; + safeLog(options.log, { event: "github-to-gitea-inbox-ready", ...store.usage(), valuesPrinted: false }); + wake(0); + } catch (error) { + storageReady = false; + storageError = typedStorageError(error); + safeLog(options.log, { event: "github-to-gitea-inbox-unavailable", ok: false, errorType: storageError, error: redact(String(error?.message || error)), valuesPrinted: false }); + wake(retry.scanIntervalMs); + } + } + + async function accept(repo, delivery) { + if (!storageReady) throw new HttpError(503, storageError || "durable-inbox-unavailable"); + try { + const accepted = await store.accept(repo, delivery); + capacityExhausted = false; + safeLog(options.log, deliveryLog("github-to-gitea-delivery-accepted", repo, delivery, { + ok: true, + state: accepted.record.state, + duplicate: accepted.duplicate, + })); + wake(0); + return { + state: accepted.record.state, + duplicate: accepted.duplicate, + result: accepted.record.state === "committed" && accepted.record.result + ? publicSyncResult(accepted.record.result) + : null, + }; + } catch (error) { + if (error instanceof DurableInboxError && error.status === 409) throw new HttpError(409, error.message); + if (!(error instanceof DurableInboxError && error.message === "durable-inbox-capacity-exhausted")) { + storageReady = false; + storageError = typedStorageError(error); + } else { + capacityExhausted = true; + } + safeLog(options.log, deliveryLog("github-to-gitea-delivery-persist-failed", repo, delivery, { + ok: false, + state: "rejected", + errorType: error instanceof DurableInboxError ? error.message : "durable-inbox-write-failed", + error: redact(String(error?.message || error)), + })); + throw new HttpError(503, error instanceof DurableInboxError ? error.message : "durable-inbox-write-failed"); + } + } + + function readiness() { + const usage = store.usage(); + return { + ready: storageReady && usage.capacityAvailable && !capacityExhausted, + storageReady, + capacityAvailable: usage.capacityAvailable && !capacityExhausted, + totalBytes: usage.totalBytes, + maxBytes: usage.maxBytes, + errorType: storageReady ? (usage.capacityAvailable && !capacityExhausted ? null : "durable-inbox-capacity-exhausted") : storageError, + }; + } + + async function status() { + let records = []; + if (storageReady) { + try { + records = await store.list(); + } catch (error) { + storageReady = false; + storageError = typedStorageError(error); + } + } + const counts = { accepted: 0, processing: 0, committed: 0, failed: 0 }; + for (const record of records) { + if (Object.hasOwn(counts, record.state)) counts[record.state] += 1; + } + return { + ...readiness(), + journal: storageReady ? store.journalMetadata() : null, + counts, + deliveries: statusRecords(records).map(publicInboxRecord), + }; + } + + function wake(delayMs) { + if (stopped) return; + if (scanTimer !== null) clearTimeout(scanTimer); + scanTimer = setTimeout(() => void scan(), Math.max(0, delayMs)); + } + + async function scan() { + if (stopped || scanRunning) return; + scanRunning = true; + try { + if (!storageReady) { + await store.initialize(); + await store.recoverProcessing(new Date(now()).toISOString()); + storageReady = true; + storageError = null; + } + if (now() - lastCleanupAt >= options.inbox.cleanupIntervalMs) { + const removed = await store.cleanupCommitted(now() - options.inbox.committedRetentionMs); + lastCleanupAt = now(); + if (removed > 0) { + capacityExhausted = false; + safeLog(options.log, { event: "github-to-gitea-inbox-retention", removed, ...store.usage(), valuesPrinted: false }); + } + } + const currentTime = now(); + for (const record of sortInboxRecordsByAcceptedSequence(await store.list())) { + if (!["accepted", "failed"].includes(record.state)) continue; + if (Date.parse(record.nextAttemptAt || record.receivedAt) > currentTime) continue; + schedule(record); + } + } catch (error) { + storageReady = false; + storageError = typedStorageError(error); + safeLog(options.log, { event: "github-to-gitea-inbox-scan-failed", ok: false, errorType: storageError, error: redact(String(error?.message || error)), valuesPrinted: false }); + } finally { + scanRunning = false; + wake(retry.scanIntervalMs); + } + } + + function schedule(record) { + if (activeDeliveries.has(record.deliveryId) || stopped) return; + activeDeliveries.add(record.deliveryId); + const previous = repoTails.get(record.repoKey) ?? Promise.resolve(); + const task = previous.catch(() => undefined).then(() => processRecord(record.deliveryId)) + .catch((error) => { + if (error instanceof DurableInboxError) { + storageReady = false; + storageError = typedStorageError(error); + } + safeLog(options.log, { + event: "github-to-gitea-worker-exception", + deliveryId: record.deliveryId, + repo: record.repoKey, + ok: false, + errorType: "durable-worker-exception", + error: redact(String(error?.message || error)), valuesPrinted: false, }); - if (sync.ok || !willRetry) break; - await sleep(retryDelayMs(syncAttempt)); - } - } while (state.pending); - } catch (error) { - log({ event: "github-to-gitea-sync-error", repo: repo.key, ok: false, error: redact(String(error?.message || error)), attempt, elapsedMs: Date.now() - startedAt, valuesPrinted: false }); - } finally { - state.running = false; + }) + .finally(() => { + activeDeliveries.delete(record.deliveryId); + inFlight.delete(task); + wake(0); + }); + repoTails.set(record.repoKey, task.catch(() => undefined)); + inFlight.add(task); } + + async function processRecord(deliveryId) { + let record = await store.get(deliveryId); + if (!record || record.state === "committed") return; + const repo = repoByKey.get(record.repoKey); + if (!repo) { + await markFailed(record, { ok: false, retryable: false, errorType: "repository-config-missing" }); + return; + } + const attemptStartedAt = new Date(now()).toISOString(); + record = await store.update(deliveryId, (current) => ({ + ...current, + state: "processing", + updatedAt: attemptStartedAt, + processingAt: attemptStartedAt, + totalAttempts: current.totalAttempts + 1, + cycleAttempt: current.cycleAttempt + 1, + })); + const delivery = recordDelivery(record); + safeLog(options.log, deliveryLog("github-to-gitea-delivery-processing", repo, delivery, { + ok: true, + state: "processing", + totalAttempts: record.totalAttempts, + cycleAttempt: record.cycleAttempt, + })); + let result; + try { + result = validateExecutorTerminalResult(await executor.execute(repo, delivery)); + } catch (error) { + await markFailed(record, { + ok: false, + retryable: true, + errorType: workerAbort.signal.aborted || stopped + ? "source-sync-executor-aborted" + : error instanceof ExecutorTerminalResultError + ? "source-sync-executor-invalid-result" + : "source-sync-executor-threw", + error: redact(String(error?.message || error)), + }); + return; + } + if (result.ok) { + const committedAt = new Date(now()).toISOString(); + await store.update(deliveryId, (current) => ({ + ...current, + state: "committed", + updatedAt: committedAt, + committedAt, + nextAttemptAt: null, + result: publicSyncResult(result), + lastError: null, + })); + safeLog(options.log, deliveryLog("github-to-gitea-delivery-committed", repo, delivery, { + ok: true, + state: "committed", + sourceCommit: result.sourceCommit, + authorityCommit: result.authorityCommit, + snapshotRef: result.snapshotRef, + disposition: result.disposition, + totalAttempts: record.totalAttempts, + })); + return; + } + await markFailed(record, result); + } + + async function markFailed(record, result) { + const repo = repoByKey.get(record.repoKey) ?? { key: record.repoKey }; + const terminal = result.retryable === false || record.cycleAttempt >= retry.maxAttempts; + const delayMs = terminal + ? retry.terminalRetryDelayMs + : retryDelayMs(record.cycleAttempt, retry.initialDelayMs, retry.maxDelayMs); + const failedAt = new Date(now()).toISOString(); + const nextAttemptAt = new Date(now() + delayMs).toISOString(); + await store.update(record.deliveryId, (current) => ({ + ...current, + state: "failed", + updatedAt: failedAt, + failedAt, + nextAttemptAt, + cycleAttempt: terminal ? 0 : current.cycleAttempt, + result: null, + lastError: { + errorType: result.errorType || "source-sync-failed", + retryable: result.retryable !== false, + terminal, + }, + })); + safeLog(options.log, deliveryLog("github-to-gitea-delivery-failed", repo, recordDelivery(record), { + ok: false, + state: "failed", + errorType: result.errorType || "source-sync-failed", + error: result.error ? redact(result.error) : null, + terminal, + automaticRetryScheduled: true, + nextAttemptAt, + totalAttempts: record.totalAttempts, + cycleAttempt: record.cycleAttempt, + })); + } + + async function stop() { + if (stopped && storageError === "durable-inbox-stopping") return; + stopped = true; + storageReady = false; + storageError = "durable-inbox-stopping"; + if (scanTimer !== null) clearTimeout(scanTimer); + workerAbort.abort(); + const settling = Promise.allSettled([...inFlight]); + await Promise.race([settling, sleep(options.shutdownGraceMs)]); + } + + return { start, stop, accept, readiness, status, scan }; } -function retryDelayMs(syncAttempt) { - return Math.min(retryMaxDelayMs, retryInitialDelayMs * (2 ** Math.max(0, syncAttempt - 1))); +export function createSyncCoordinator(options) { + const states = new Map(); + const deliveryOwners = new Map(); + const sync = options.sync ?? ((repo, delivery, deadline) => syncRepository(repo, delivery, { ...options, ...deadline })); + const wait = options.sleep ?? sleep; + const now = options.now ?? Date.now; + + function stateFor(key) { + let state = states.get(key); + if (!state) { + state = { tail: Promise.resolve(), active: null, lastSuccess: null }; + states.set(key, state); + } + return state; + } + + function execute(repo, delivery) { + const requestStartedAt = now(); + const deadlineAt = requestStartedAt + options.responseBudgetMs; + const fingerprint = deliveryFingerprint(repo, delivery); + const owner = deliveryOwners.get(delivery.deliveryId); + if (owner && owner.fingerprint !== fingerprint) { + throw new HttpError(409, "github-delivery-id-payload-conflict"); + } + if (owner?.promise) { + safeLog(options.log, deliveryLog("github-to-gitea-delivery-duplicate", repo, delivery, { disposition: "join-in-flight" })); + return owner.promise.then((result) => ({ ...result, duplicate: true })); + } + if (owner && owner.fingerprint === fingerprint && owner.result?.ok === true) { + safeLog(options.log, deliveryLog("github-to-gitea-delivery-duplicate", repo, delivery, { disposition: "durable-refs-already-committed" })); + return Promise.resolve({ ...owner.result, duplicate: true }); + } + const state = stateFor(repo.key); + if (state.lastSuccess?.fingerprint === fingerprint) { + safeLog(options.log, deliveryLog("github-to-gitea-delivery-duplicate", repo, delivery, { disposition: "durable-refs-already-committed" })); + return Promise.resolve({ ...state.lastSuccess.result, duplicate: true }); + } + const promise = state.tail.then(async () => { + state.active = { delivery, fingerprint }; + const startedAt = requestStartedAt; + let terminal = null; + for (let syncAttempt = 1; syncAttempt <= options.retryMaxAttempts; syncAttempt += 1) { + if (now() >= deadlineAt) { + terminal = { ok: false, retryable: true, errorType: "response-deadline-exhausted" }; + safeLog(options.log, deliveryLog("github-to-gitea-sync", repo, delivery, { + ok: false, + errorType: terminal.errorType, + syncAttempt, + maxSyncAttempts: options.retryMaxAttempts, + willRetry: false, + terminal: true, + elapsedMs: now() - startedAt, + })); + break; + } + let result; + try { + result = await sync(repo, delivery, { deadlineAt, now, signal: options.signal }); + } catch (error) { + result = { ok: false, retryable: true, errorType: "sync-exception", error: String(error?.message || error) }; + } + if (now() >= deadlineAt) { + result = { + ok: false, + retryable: true, + errorType: "response-deadline-exhausted", + sourceCommit: result.sourceCommit ?? null, + authorityCommit: result.authorityCommit ?? null, + snapshotRef: result.snapshotRef ?? null, + committedBeforeDeadlineUnknown: result.ok === true, + }; + } + const retryable = result.retryable !== false; + const willRetry = !result.ok && retryable && syncAttempt < options.retryMaxAttempts; + terminal = result; + safeLog(options.log, deliveryLog("github-to-gitea-sync", repo, delivery, { + ok: result.ok, + sourceCommit: result.sourceCommit ?? null, + authorityCommit: result.authorityCommit ?? null, + snapshotRef: result.snapshotRef ?? null, + disposition: result.disposition ?? null, + errorType: result.errorType ?? null, + error: result.error ? redact(result.error) : null, + syncAttempt, + maxSyncAttempts: options.retryMaxAttempts, + willRetry, + terminal: !willRetry, + elapsedMs: now() - startedAt, + })); + if (!willRetry) break; + try { + const remainingMs = Math.max(0, deadlineAt - now()); + await wait(Math.min(remainingMs, retryDelayMs(syncAttempt, options.retryInitialDelayMs, options.retryMaxDelayMs))); + } catch (error) { + safeLog(options.log, deliveryLog("github-to-gitea-retry-delay-error", repo, delivery, { error: redact(String(error?.message || error)) })); + } + } + if (terminal?.ok) state.lastSuccess = { fingerprint, result: terminal }; + const previousDeliveryId = state.lastDeliveryId; + state.lastDeliveryId = delivery.deliveryId; + deliveryOwners.set(delivery.deliveryId, { fingerprint, promise: null, result: terminal }); + if (previousDeliveryId && previousDeliveryId !== delivery.deliveryId) deliveryOwners.delete(previousDeliveryId); + return terminal ?? { ok: false, errorType: "sync-produced-no-result", retryable: true }; + }); + const settled = promise.finally(() => { + state.active = null; + const current = deliveryOwners.get(delivery.deliveryId); + if (current?.promise === settled) deliveryOwners.set(delivery.deliveryId, { fingerprint, promise: null, result: null }); + }); + state.tail = settled.catch(() => undefined); + deliveryOwners.set(delivery.deliveryId, { fingerprint, promise: settled }); + return settled; + } + + return { execute, stateFor }; } -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function syncRepository(repo) { +export async function syncRepository(repo, delivery, runtime) { const work = mkdtempSync(join(tmpdir(), `gitea-gh-sync-${repo.key}-`)); + let sourceCommit = null; + let snapshotRef = null; + let authorityCommit = null; + const execute = (args) => runWithinDeadline(runtime, args); + const executeResult = (args) => runResultWithinDeadline(runtime, args); try { - run(["git", "init", "--bare", work]); - run(["git", "-C", work, "-c", `http.extraHeader=${githubAuthHeader}`, "fetch", "--prune", repo.upstream.cloneUrl, `+refs/heads/${repo.upstream.branch}:refs/remotes/github/${repo.upstream.branch}`]); - const sourceCommit = run(["git", "-C", work, "rev-parse", `refs/remotes/github/${repo.upstream.branch}`]).stdout.trim(); - const snapshotRef = `${repo.snapshot.prefix.replace(/\/+$/u, "")}/${sourceCommit}`; - const giteaUrl = giteaBaseUrl + new URL(repo.gitea.readUrl).pathname; - run(["git", "-C", work, "-c", `http.extraHeader=${giteaAuthHeader}`, "push", "--force", giteaUrl, `${sourceCommit}:refs/heads/${repo.upstream.branch}`, `${sourceCommit}:${snapshotRef}`]); - return { ok: true, sourceCommit, snapshotRef }; + await execute(["git", "init", "--bare", work]); + const snapshotRefForRequest = `${repo.snapshot.prefix.replace(/\/+$/u, "")}/${delivery.requestedCommit}`; + const giteaUrl = runtime.giteaBaseUrl + new URL(repo.gitea.readUrl).pathname; + const branchRef = `refs/heads/${repo.upstream.branch}`; + const initialRemoteRefs = parseLsRemote((await execute([ + "git", "-C", work, "-c", `http.extraHeader=${runtime.giteaAuthHeader}`, + "ls-remote", giteaUrl, branchRef, snapshotRefForRequest, + ])).stdout); + const currentCommit = initialRemoteRefs.get(branchRef) ?? null; + const snapshotCommit = initialRemoteRefs.get(snapshotRefForRequest) ?? null; + if (currentCommit) { + await execute(["git", "-C", work, "-c", `http.extraHeader=${runtime.giteaAuthHeader}`, "fetch", "--no-tags", giteaUrl, `+${branchRef}:refs/remotes/gitea/${repo.upstream.branch}`]); + } + if (snapshotCommit && snapshotCommit !== currentCommit) { + await execute(["git", "-C", work, "-c", `http.extraHeader=${runtime.giteaAuthHeader}`, "fetch", "--no-tags", giteaUrl, `+${snapshotRefForRequest}:refs/remotes/gitea/requested-snapshot`]); + } + let requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]); + if (requestedObject.status !== 0) { + const branchFetch = await executeResult([ + "git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeader}`, + "fetch", "--no-tags", repo.upstream.cloneUrl, + `+refs/heads/${repo.upstream.branch}:refs/remotes/github/${repo.upstream.branch}`, + ]); + requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]); + if (requestedObject.status !== 0) { + const exactFetch = await executeResult(["git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeader}`, "fetch", "--no-tags", repo.upstream.cloneUrl, `${delivery.requestedCommit}:refs/remotes/github/delivery`]); + if (exactFetch.status !== 0) { + return { + ok: false, + retryable: true, + errorType: "requested-commit-temporarily-unavailable", + error: `${branchFetch.stderr}\n${exactFetch.stderr}`, + }; + } + requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]); + } + } + if (requestedObject.status !== 0) return { ok: false, retryable: false, errorType: "requested-commit-not-a-commit" }; + sourceCommit = (await execute(["git", "-C", work, "rev-parse", `${delivery.requestedCommit}^{commit}`])).stdout.trim(); + if (sourceCommit !== delivery.requestedCommit) { + return { + ok: false, + retryable: false, + errorType: "source-commit-mismatch", + sourceCommit, + error: `fetched source commit ${sourceCommit} does not equal webhook after ${delivery.requestedCommit}`, + }; + } + snapshotRef = snapshotRefForRequest; + if (snapshotCommit && snapshotCommit !== sourceCommit) { + return { ok: false, retryable: false, errorType: "immutable-snapshot-conflict", sourceCommit, authorityCommit: currentCommit, snapshotRef }; + } + let branchDisposition = "created"; + let updateBranch = true; + if (currentCommit) { + const currentIsAncestor = await ancestry(executeResult, work, currentCommit, sourceCommit); + const sourceIsAncestor = await ancestry(executeResult, work, sourceCommit, currentCommit); + if (currentCommit === sourceCommit) { + branchDisposition = "current"; + updateBranch = false; + } else if (currentIsAncestor === true) { + branchDisposition = "fast-forwarded"; + } else if (sourceIsAncestor === true) { + branchDisposition = "superseded"; + updateBranch = false; + } else if (currentIsAncestor === null || sourceIsAncestor === null) { + return { ok: false, retryable: true, errorType: "ancestry-proof-failed", sourceCommit, authorityCommit: currentCommit, snapshotRef }; + } else { + return { ok: false, retryable: false, errorType: "source-history-diverged", sourceCommit, authorityCommit: currentCommit, snapshotRef }; + } + } + const refspecs = []; + const pushArgs = ["git", "-C", work, "-c", `http.extraHeader=${runtime.giteaAuthHeader}`, "push", "--atomic"]; + if (updateBranch) { + pushArgs.push(`--force-with-lease=${branchRef}:${currentCommit ?? ""}`); + refspecs.push(`${sourceCommit}:${branchRef}`); + } + if (!snapshotCommit) refspecs.push(`${sourceCommit}:${snapshotRef}`); + if (refspecs.length > 0) { + const pushed = await executeResult([...pushArgs, giteaUrl, ...refspecs]); + if (pushed.status !== 0) { + return { ok: false, retryable: true, errorType: "atomic-push-race-or-failure", sourceCommit, authorityCommit: currentCommit, snapshotRef, error: pushed.stderr }; + } + } + authorityCommit = branchDisposition === "superseded" ? currentCommit : sourceCommit; + const proofRefs = parseLsRemote((await execute([ + "git", "-C", work, "-c", `http.extraHeader=${runtime.giteaAuthHeader}`, + "ls-remote", giteaUrl, branchRef, snapshotRef, + ])).stdout); + const provenAuthorityCommit = proofRefs.get(branchRef) ?? null; + const provenSnapshotCommit = proofRefs.get(snapshotRef) ?? null; + if (provenAuthorityCommit !== authorityCommit || provenSnapshotCommit !== sourceCommit) { + return { + ok: false, + retryable: true, + errorType: "post-push-authority-proof-failed", + sourceCommit, + authorityCommit: provenAuthorityCommit, + snapshotRef, + }; + } + return { + ok: true, + sourceCommit, + authorityCommit, + snapshotRef, + disposition: branchDisposition === "superseded" ? "superseded-with-immutable-snapshot" : "atomic-source-authority-committed", + }; } catch (error) { - return { ok: false, error: String(error?.message || error) }; + return { ok: false, retryable: true, errorType: "git-source-sync-failed", sourceCommit, authorityCommit, snapshotRef, error: String(error?.message || error) }; } finally { rmSync(work, { recursive: true, force: true }); } } -function run(args) { - const result = spawnSync(args[0], args.slice(1), { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 120000, - env: { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_GLOBAL: "/dev/null", - }, - }); - if (result.error || result.status !== 0) { - const stderr = String(result.stderr || result.error?.message || "").slice(-1600); - throw new Error(`${args[0]} failed status=${result.status ?? "error"} stderr=${stderr}`); +export async function run(args, timeoutMs = 120000) { + const result = await runResult(args, timeoutMs); + if (result.status !== 0) { + throw new Error(`${args[0]} failed status=${result.status ?? "error"} stderr=${result.stderr}`); } - return { stdout: result.stdout || "", stderr: result.stderr || "" }; + return { stdout: result.stdout, stderr: result.stderr }; } -function verifySignature(signatureHeader, body) { - const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader; - if (!signature || !signature.startsWith("sha256=")) throw new Error("missing-github-signature"); +export function runResult(args, timeoutMs = 120000, signal = null) { + const boundedTimeoutMs = Math.max(1, Math.floor(timeoutMs)); + if (signal?.aborted) return Promise.resolve({ status: null, stdout: "", stderr: "command aborted before spawn" }); + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let timedOut = false; + let aborted = false; + let settled = false; + let deadlineTimer = null; + let forceKillTimer = null; + let abortCommand = () => {}; + const child = spawn(args[0], args.slice(1), { + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + }, + }); + const finish = (status, error = "") => { + if (settled) return; + settled = true; + if (deadlineTimer !== null) clearTimeout(deadlineTimer); + if (forceKillTimer !== null) clearTimeout(forceKillTimer); + signal?.removeEventListener("abort", abortCommand); + resolve({ + status, + stdout, + stderr: appendTail(stderr, error, 1600), + }); + }; + child.stdout.on("data", (chunk) => { + stdout = appendTail(stdout, String(chunk), 65536); + }); + child.stderr.on("data", (chunk) => { + stderr = appendTail(stderr, String(chunk), 1600); + }); + child.once("error", (error) => finish(null, String(error?.message || error))); + child.once("close", (status, signal) => { + const terminalError = aborted + ? `command aborted; signal=${signal || "none"}` + : timedOut + ? `command deadline exceeded after ${boundedTimeoutMs}ms; signal=${signal || "none"}` + : ""; + finish(aborted || timedOut ? null : status, terminalError); + }); + abortCommand = () => { + aborted = true; + signalProcessTree(child, "SIGTERM"); + forceKillTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), 50); + }; + signal?.addEventListener("abort", abortCommand, { once: true }); + if (signal?.aborted) abortCommand(); + deadlineTimer = setTimeout(() => { + timedOut = true; + signalProcessTree(child, "SIGTERM"); + forceKillTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), 50); + }, boundedTimeoutMs); + }); +} + +export function verifySignature(signatureHeader, body, webhookSecret) { + const signature = singleHeader(signatureHeader); + if (!signature || !signature.startsWith("sha256=")) throw new HttpError(401, "missing-github-signature"); const expected = `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`; - if (!timingSafeEqualString(signature, expected)) throw new Error("invalid-github-signature"); + if (!timingSafeEqualString(signature, expected)) throw new HttpError(401, "invalid-github-signature"); +} + +export function retryDelayMs(syncAttempt, initialDelayMs, maxDelayMs) { + return Math.min(maxDelayMs, initialDelayMs * (2 ** Math.max(0, syncAttempt - 1))); +} + +export function loadRuntimeConfig() { + const githubToken = requiredEnv("GITHUB_TOKEN"); + const giteaUsername = requiredEnv("GITEA_USERNAME"); + const giteaPassword = requiredEnv("GITEA_PASSWORD"); + return { + port: Number.parseInt(process.env.UNIDESK_GITEA_WEBHOOK_PORT || "8080", 10), + path: process.env.UNIDESK_GITEA_WEBHOOK_PATH || "/_unidesk/github-to-gitea", + responseBudgetMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RESPONSE_BUDGET_MS"), + repos: JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8")), + webhookSecret: requiredEnv("GITHUB_WEBHOOK_SECRET"), + giteaBaseUrl: requiredEnv("UNIDESK_GITEA_SERVICE_BASE_URL").replace(/\/+$/u, ""), + inbox: { + path: requiredEnv("UNIDESK_GITEA_WEBHOOK_INBOX_PATH"), + maxBytes: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_INBOX_MAX_BYTES"), + committedRetentionMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_COMMITTED_RETENTION_SECONDS") * 1000, + cleanupIntervalMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_CLEANUP_INTERVAL_MS"), + }, + workerRetry: { + maxAttempts: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_MAX_ATTEMPTS"), + initialDelayMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_INITIAL_DELAY_MS"), + maxDelayMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_MAX_DELAY_MS"), + terminalRetryDelayMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_TERMINAL_DELAY_MS"), + attemptTimeoutMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_WORKER_ATTEMPT_TIMEOUT_MS"), + scanIntervalMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_WORKER_SCAN_INTERVAL_MS"), + }, + shutdownGraceMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS"), + maxBodyBytes: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_MAX_BODY_BYTES"), + githubAuthHeader: `Authorization: Basic ${Buffer.from(`x-access-token:${githubToken}`).toString("base64")}`, + giteaAuthHeader: `Authorization: Basic ${Buffer.from(`${giteaUsername}:${giteaPassword}`).toString("base64")}`, + log, + run, + runResult, + }; +} + +export function startServer(config = loadRuntimeConfig()) { + const controller = config.controller ?? createDurableSyncController(config); + const server = createServer(createGithubSyncHandler({ ...config, controller })); + void controller.start(); + server.listen(config.port, "0.0.0.0", () => { + safeLog(config.log, { event: "github-to-gitea-sync-listening", port: config.port, path: config.path, repos: config.repos.map((repo) => repo.key), valuesPrinted: false }); + }); + const shutdown = async () => { + server.close(); + await controller.stop(); + }; + server.once("close", () => void controller.stop()); + process.once("SIGTERM", shutdown); + process.once("SIGINT", shutdown); + return server; +} + +class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +class DurableInboxError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +class ExecutorTerminalResultError extends Error {} + +function validateExecutorTerminalResult(result) { + if (!result || typeof result !== "object" || typeof result.ok !== "boolean") { + throw new ExecutorTerminalResultError("executor terminal result must be an object with boolean ok"); + } + if (result.ok === false) { + if (typeof result.retryable !== "boolean" || !/^[a-z0-9][a-z0-9-]{0,127}$/u.test(result.errorType || "")) { + throw new ExecutorTerminalResultError("failed executor terminal result must include retryable and typed errorType"); + } + return result; + } + if (!/^[0-9a-f]{40,64}$/u.test(result.sourceCommit || "") + || !/^[0-9a-f]{40,64}$/u.test(result.authorityCommit || "") + || !/^refs\/[A-Za-z0-9._/-]+$/u.test(result.snapshotRef || "") + || !["atomic-source-authority-committed", "superseded-with-immutable-snapshot"].includes(result.disposition)) { + throw new ExecutorTerminalResultError("successful executor terminal result must include source, authority, snapshot and disposition proof"); + } + return result; +} + +function deliveryFingerprint(repo, delivery) { + return createHash("sha256") + .update([repo.key, delivery.deliveryId, delivery.repository, delivery.ref, delivery.requestedCommit, delivery.payloadSha].join("\0")) + .digest("hex"); +} + +function nonRetryableStatus(errorType) { + return [ + "github-delivery-id-payload-conflict", + "source-commit-mismatch", + "immutable-snapshot-conflict", + "source-history-diverged", + ].includes(errorType) ? 409 : 422; +} + +function safeLog(logger, payload) { + try { + logger(payload); + } catch { + // Logging must never change delivery success or retry semantics. + } +} + +function parseLsRemote(output) { + const refs = new Map(); + for (const line of String(output || "").split("\n")) { + const [commit, ref] = line.trim().split(/\s+/u); + if (commit && ref) refs.set(ref, commit); + } + return refs; +} + +async function ancestry(executeResult, work, ancestor, descendant) { + const result = await executeResult(["git", "-C", work, "merge-base", "--is-ancestor", ancestor, descendant]); + if (result.status === 0) return true; + if (result.status === 1) return false; + return null; +} + +async function runWithinDeadline(runtime, args) { + const result = await runResultWithinDeadline(runtime, args); + if (result.status !== 0) throw new Error(`${args[0]} failed status=${result.status ?? "deadline"} stderr=${result.stderr}`); + return { stdout: result.stdout, stderr: result.stderr }; +} + +async function runResultWithinDeadline(runtime, args) { + const remainingMs = Math.floor(runtime.deadlineAt - runtime.now()); + if (remainingMs < 1) return { status: null, stdout: "", stderr: "response deadline exhausted before command" }; + if (runtime.signal?.aborted) return { status: null, stdout: "", stderr: "command aborted before spawn" }; + return await runtime.runResult(args, remainingMs, runtime.signal); +} + +function appendTail(previous, next, limit) { + return `${previous}${next}`.slice(-limit); +} + +function signalProcessTree(child, signal) { + if (process.platform !== "win32" && child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // Fall back to the direct child when the process group already exited. + } + } + child.kill(signal); +} + +function recordPath(directory, deliveryId) { + return join(directory, `${createHash("sha256").update(String(deliveryId)).digest("hex")}.json`); +} + +function serializeRecord(record) { + return `${JSON.stringify(record)}\n`; +} + +async function readRecordPath(path, missingIsNull = false) { + try { + const parsed = JSON.parse(await readFile(path, "utf8")); + return validateInboxRecord(parsed); + } catch (error) { + if (missingIsNull && error?.code === "ENOENT") return null; + if (error instanceof DurableInboxError) throw error; + throw new DurableInboxError(503, `durable-inbox-read-failed:${error?.code || "unknown"}`); + } +} + +async function durableWrite(path, content) { + const temporaryPath = `${path}.tmp-${randomUUID()}`; + let handle = null; + try { + handle = await open(temporaryPath, "wx", 0o600); + await handle.writeFile(content, "utf8"); + await handle.sync(); + await handle.close(); + handle = null; + await rename(temporaryPath, path); + await syncDirectory(join(path, "..")); + } catch (error) { + if (handle) await handle.close().catch(() => undefined); + await unlink(temporaryPath).catch(() => undefined); + if (error instanceof DurableInboxError) throw error; + throw new DurableInboxError(503, `durable-inbox-write-failed:${error?.code || "unknown"}`); + } +} + +async function syncDirectory(directory) { + const handle = await open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +function typedStorageError(error) { + if (error instanceof DurableInboxError) return error.message.split(":", 1)[0]; + return "durable-inbox-unavailable"; +} + +function recordDelivery(record) { + return { + deliveryId: record.deliveryId, + repository: record.repository, + ref: record.ref, + requestedCommit: record.requestedCommit, + payloadSha: record.payloadSha, + receivedAt: record.receivedAt, + }; +} + +function publicSyncResult(result) { + return { + sourceCommit: result.sourceCommit ?? null, + authorityCommit: result.authorityCommit ?? null, + snapshotRef: result.snapshotRef ?? null, + disposition: result.disposition ?? null, + }; +} + +function publicInboxRecord(record) { + return { + deliveryId: record.deliveryId, + repo: record.repoKey, + repository: record.repository, + ref: record.ref, + requestedCommit: record.requestedCommit, + payloadSha: record.payloadSha, + receivedAt: record.receivedAt, + state: record.state, + acceptedSequence: record.acceptedSequence, + totalAttempts: record.totalAttempts, + cycleAttempt: record.cycleAttempt, + nextAttemptAt: record.nextAttemptAt, + updatedAt: record.updatedAt, + result: record.result, + lastError: record.lastError, + }; +} + +function statusRecords(records) { + const selected = new Map(); + for (const record of records.slice(-100)) selected.set(record.deliveryId, record); + const latestByRepo = new Map(); + const latestCommittedByRepo = new Map(); + for (const record of records) { + const current = latestByRepo.get(record.repoKey); + if (!current || record.acceptedSequence > current.acceptedSequence) latestByRepo.set(record.repoKey, record); + if (record.state === "committed") { + const committed = latestCommittedByRepo.get(record.repoKey); + if (!committed || record.acceptedSequence > committed.acceptedSequence) latestCommittedByRepo.set(record.repoKey, record); + } + } + for (const record of latestByRepo.values()) selected.set(record.deliveryId, record); + for (const record of latestCommittedByRepo.values()) selected.set(record.deliveryId, record); + return sortInboxRecordsByAcceptedSequence([...selected.values()]); +} + +export function sortInboxRecordsByAcceptedSequence(records) { + return [...records].sort((left, right) => left.acceptedSequence - right.acceptedSequence); +} + +function validateInboxRecord(record) { + const invalid = () => { throw new DurableInboxError(503, "durable-inbox-record-invalid"); }; + if (!record || record.version !== 1) invalid(); + if (typeof record.deliveryId !== "string" || record.deliveryId.length < 1 || record.deliveryId.length > 160) invalid(); + if (!/^[a-zA-Z0-9._-]+$/u.test(record.repoKey || "")) invalid(); + if (!/^[^/\s]+\/[^/\s]+$/u.test(record.repository || "")) invalid(); + if (!/^refs\/heads\/[^\s]+$/u.test(record.ref || "")) invalid(); + if (!/^[0-9a-f]{40,64}$/u.test(record.requestedCommit || "")) invalid(); + if (!/^[0-9a-f]{64}$/u.test(record.payloadSha || "")) invalid(); + if (!/^[0-9a-f]{64}$/u.test(record.fingerprint || "")) invalid(); + if (!["accepted", "processing", "committed", "failed"].includes(record.state)) invalid(); + if (!isTimestamp(record.receivedAt) || !isTimestamp(record.acceptedAt) || !isTimestamp(record.updatedAt)) invalid(); + if (!Number.isInteger(record.acceptedSequence) || record.acceptedSequence < 1) invalid(); + if (!Number.isInteger(record.totalAttempts) || record.totalAttempts < 0 || !Number.isInteger(record.cycleAttempt) || record.cycleAttempt < 0) invalid(); + if (record.nextAttemptAt !== null && !isTimestamp(record.nextAttemptAt)) invalid(); + if (record.lastError !== null) { + if (typeof record.lastError !== "object" || !/^[a-z0-9][a-z0-9-]{0,127}$/u.test(record.lastError.errorType || "")) invalid(); + if (typeof record.lastError.retryable !== "boolean") invalid(); + if (record.lastError.terminal !== undefined && typeof record.lastError.terminal !== "boolean") invalid(); + } + const expectedFingerprint = createHash("sha256") + .update([record.repoKey, record.deliveryId, record.repository, record.ref, record.requestedCommit, record.payloadSha].join("\0")) + .digest("hex"); + if (!timingSafeEqualString(record.fingerprint, expectedFingerprint)) invalid(); + if (record.state === "accepted" && (!isTimestamp(record.nextAttemptAt) || record.result !== null)) invalid(); + if (record.state === "processing" && (!isTimestamp(record.processingAt) || record.result !== null)) invalid(); + if (record.state === "failed" && (!isTimestamp(record.failedAt) || !isTimestamp(record.nextAttemptAt) || record.lastError === null || record.result !== null)) invalid(); + if (record.state === "committed") { + if (!isTimestamp(record.committedAt) || record.nextAttemptAt !== null || record.lastError !== null || !record.result || typeof record.result !== "object") invalid(); + if (!/^[0-9a-f]{40,64}$/u.test(record.result.sourceCommit || "") || !/^[0-9a-f]{40,64}$/u.test(record.result.authorityCommit || "")) invalid(); + if (record.result.sourceCommit !== record.requestedCommit) invalid(); + if (!/^refs\/[a-zA-Z0-9._/-]+$/u.test(record.result.snapshotRef || "") || !record.result.snapshotRef.endsWith(`/${record.requestedCommit}`)) invalid(); + if (!["atomic-source-authority-committed", "superseded-with-immutable-snapshot"].includes(record.result.disposition)) invalid(); + } + return record; +} + +function validateJournalMetadata(metadata) { + if (!metadata || metadata.version !== 1 || metadata.kind !== "GiteaGithubSyncDurableInbox" || !isTimestamp(metadata.createdAt)) { + throw new DurableInboxError(503, "durable-inbox-metadata-invalid"); + } + return metadata; +} + +function isTimestamp(value) { + return typeof value === "string" && Number.isFinite(Date.parse(value)); +} + +function deliveryLog(event, repo, delivery, extra = {}) { + return { + event, + repo: repo.key, + deliveryId: delivery.deliveryId, + repository: delivery.repository, + ref: delivery.ref, + requestedCommit: delivery.requestedCommit, + ...extra, + valuesPrinted: false, + }; +} + +function parsePayload(body) { + try { + return JSON.parse(body.toString("utf8") || "{}"); + } catch { + throw new HttpError(400, "invalid-json-payload"); + } +} + +function singleHeader(value) { + return String(Array.isArray(value) ? value[0] : value || "").trim(); } function timingSafeEqualString(a, b) { @@ -174,20 +1278,64 @@ function timingSafeEqualString(a, b) { return left.length === right.length && timingSafeEqual(left, right); } -function readBody(req) { +function readBody(req, maxBodyBytes) { return new Promise((resolve, reject) => { const chunks = []; - req.on("data", (chunk) => chunks.push(chunk)); - req.on("end", () => resolve(Buffer.concat(chunks))); + let bytes = 0; + let rejected = false; + req.on("data", (chunk) => { + bytes += chunk.length; + if (!rejected && bytes > maxBodyBytes) { + rejected = true; + reject(new HttpError(413, "request-body-too-large")); + return; + } + if (!rejected) chunks.push(chunk); + }); + req.on("end", () => { + if (!rejected) resolve(Buffer.concat(chunks)); + }); req.on("error", reject); }); } +export function waitWithinResponseBudget(promise, deadlineMs, errorType) { + const remainingMs = deadlineMs - Date.now(); + if (remainingMs <= 0) return Promise.reject(new HttpError(503, errorType)); + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new HttpError(503, errorType)); + }, remainingMs); + Promise.resolve(promise).then( + (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }, + (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }, + ); + }); +} + function writeJson(res, status, payload) { + if (res.headersSent || res.destroyed) return; res.writeHead(status, { "Content-Type": "application/json" }); res.end(`${JSON.stringify(payload)}\n`); } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function log(payload) { console.log(JSON.stringify(payload)); } @@ -210,3 +1358,5 @@ function positiveIntegerEnv(name) { if (!Number.isFinite(value) || value < 1) throw new Error(`invalid positive integer env ${name}`); return value; } + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) startServer(); diff --git a/scripts/src/platform-infra-gitea-github-sync-server.test.mjs b/scripts/src/platform-infra-gitea-github-sync-server.test.mjs new file mode 100644 index 00000000..e8782319 --- /dev/null +++ b/scripts/src/platform-infra-gitea-github-sync-server.test.mjs @@ -0,0 +1,1013 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash, createHmac } from "node:crypto"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + createGithubSyncHandler, + createDurableInboxStore, + createDurableSyncController, + createSyncCoordinator, + run, + runResult, + syncRepository, +} from "./platform-infra-gitea-github-sync-server.mjs"; + +const zeroDelay = async () => {}; + +test("HTTP 202 waits for durable acceptance but does not claim refs are committed", async () => { + const repo = fakeRepo("one"); + const logs = []; + let release; + const gate = new Promise((resolve) => { release = resolve; }); + let mode = "accept"; + const controller = { + readiness: () => ({ ready: true }), + status: async () => ({ ready: true, counts: {}, deliveries: [] }), + async accept() { + if (mode === "accept") { + await gate; + return { state: "accepted", duplicate: false }; + } + throw new Error("Authorization: Basic c2VjcmV0"); + }, + }; + const server = createServer(createGithubSyncHandler({ + path: "/hook", + responseBudgetMs: 1000, + maxBodyBytes: 4096, + webhookSecret: "test-secret", + repos: [repo], + controller, + log: (event) => logs.push(event), + })); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + try { + const pending = postWebhook(port, repo, "http-success", "a".repeat(40), "test-secret"); + const beforeCommit = await Promise.race([pending.then(() => "responded"), new Promise((resolve) => setTimeout(() => resolve("waiting"), 30))]); + assert.equal(beforeCommit, "waiting"); + release(); + const accepted = await pending; + assert.equal(accepted.status, 202); + assert.deepEqual(await accepted.json(), { + ok: true, + event: "push", + deliveryId: "http-success", + repository: repo.upstream.repository, + ref: "refs/heads/main", + repo: repo.key, + requestedCommit: "a".repeat(40), + state: "accepted", + disposition: "durable-inbox-accepted", + refsCommitted: false, + valuesPrinted: false, + }); + + mode = "throw"; + const unexpected = await postWebhook(port, repo, "http-throw", "d".repeat(40), "test-secret"); + assert.equal(unexpected.status, 503); + assert.equal((await unexpected.json()).error, "internal-sync-error"); + const rejected = logs.at(-1); + assert.deepEqual( + [rejected.deliveryId, rejected.repository, rejected.ref, rejected.requestedCommit], + ["http-throw", repo.upstream.repository, "refs/heads/main", "d".repeat(40)], + ); + assert.match(rejected.error, //); + } finally { + server.close(); + } +}); + +test("duplicate HTTP 202 reports the durable accepted, processing, failed or committed state truthfully", async () => { + const repo = fakeRepo("duplicate-state"); + const committedResult = { + sourceCommit: "c".repeat(40), + authorityCommit: "c".repeat(40), + snapshotRef: `refs/snapshots/source/${"c".repeat(40)}`, + disposition: "atomic-source-authority-committed", + }; + let durable = { state: "accepted", duplicate: true, result: null }; + const controller = { + readiness: () => ({ ready: true }), + status: async () => ({ ready: true, counts: {}, deliveries: [] }), + async accept() { return durable; }, + }; + const server = createServer(createGithubSyncHandler({ + path: "/hook", + responseBudgetMs: 1000, + maxBodyBytes: 4096, + webhookSecret: "test-secret", + repos: [repo], + controller, + log: () => {}, + })); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + try { + for (const state of ["accepted", "processing", "failed", "committed"]) { + durable = { state, duplicate: true, result: state === "committed" ? committedResult : null }; + const response = await postWebhook(port, repo, `duplicate-${state}`, "c".repeat(40), "test-secret"); + assert.equal(response.status, 202); + const body = await response.json(); + assert.equal(body.state, state); + assert.equal(body.disposition, "duplicate-durable-inbox-record"); + assert.equal(body.refsCommitted, state === "committed"); + if (state === "committed") assert.deepEqual(body.result, committedResult); + else assert.equal(Object.hasOwn(body, "result"), false); + } + } finally { + server.close(); + } +}); + +test("durable acceptance response budget returns typed 503 while a late write continues and remains idempotent", async () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-acceptance-budget-")); + const repo = fakeRepo("acceptance-budget"); + let releaseFirstAccept; + const firstAcceptGate = new Promise((resolve) => { releaseFirstAccept = resolve; }); + let releaseSync; + const syncGate = new Promise((resolve) => { releaseSync = resolve; }); + const logs = []; + const durableController = createDurableSyncController(durableControllerOptions(root, [repo], { + sync: async (_repo, delivery) => { + await syncGate; + return { + ok: true, + sourceCommit: delivery.requestedCommit, + authorityCommit: delivery.requestedCommit, + snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, + disposition: "atomic-source-authority-committed", + }; + }, + })); + let acceptCalls = 0; + const delayedController = { + readiness: () => durableController.readiness(), + status: () => durableController.status(), + async accept(repoValue, delivery) { + acceptCalls += 1; + if (acceptCalls === 1) await firstAcceptGate; + return await durableController.accept(repoValue, delivery); + }, + }; + const server = createServer(createGithubSyncHandler({ + path: "/hook", + responseBudgetMs: 40, + maxBodyBytes: 4096, + webhookSecret: "test-secret", + repos: [repo], + controller: delayedController, + log: (event) => logs.push(event), + })); + try { + await durableController.start(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + const commit = "7".repeat(40); + const startedAt = performance.now(); + const timedOut = await postWebhook(port, repo, "late-durable-write", commit, "test-secret", { marker: "late" }); + const elapsedMs = performance.now() - startedAt; + assert.equal(timedOut.status, 503); + assert.equal((await timedOut.json()).error, "durable-acceptance-response-deadline-exhausted"); + assert.ok(elapsedMs < 200, `durable acceptance deadline responded after ${elapsedMs}ms`); + assert.equal((await durableController.status()).deliveries.length, 0); + + releaseFirstAccept(); + await waitFor(async () => (await durableController.status()).deliveries.length === 1); + const duplicate = await postWebhook(port, repo, "late-durable-write", commit, "test-secret", { marker: "late" }); + assert.equal(duplicate.status, 202); + const duplicateBody = await duplicate.json(); + assert.equal(duplicateBody.disposition, "duplicate-durable-inbox-record"); + assert.equal((await durableController.status()).deliveries.length, 1); + assert.equal(logs.some((event) => event.event === "github-to-gitea-delivery-accepted-after-response-deadline"), true); + + releaseSync(); + await waitFor(async () => (await durableController.status()).counts.committed === 1); + const committedDuplicate = await postWebhook(port, repo, "late-durable-write", commit, "test-secret", { marker: "late" }); + const committedBody = await committedDuplicate.json(); + assert.equal(committedBody.state, "committed"); + assert.equal(committedBody.refsCommitted, true); + assert.equal(committedBody.result.sourceCommit, commit); + } finally { + server.close(); + await durableController.stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("durable inbox fsync acceptance is body-sensitive, fast, and later becomes committed", async () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-inbox-http-")); + const repo = fakeRepo("durable"); + let releaseSync; + const syncGate = new Promise((resolve) => { releaseSync = resolve; }); + let syncStarted = false; + const controller = createDurableSyncController(durableControllerOptions(root, [repo], { + sync: async (_repo, delivery) => { + syncStarted = true; + await syncGate; + return { + ok: true, + sourceCommit: delivery.requestedCommit, + authorityCommit: delivery.requestedCommit, + snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, + disposition: "atomic-source-authority-committed", + }; + }, + })); + const server = createServer(createGithubSyncHandler({ + path: "/hook", + responseBudgetMs: 1000, + maxBodyBytes: 4096, + webhookSecret: "test-secret", + repos: [repo], + controller, + log: () => {}, + })); + try { + await controller.start(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + const commit = "a".repeat(40); + const startedAt = performance.now(); + const accepted = await postWebhook(port, repo, "durable-delivery", commit, "test-secret", { marker: "one" }); + const elapsedMs = performance.now() - startedAt; + assert.equal(accepted.status, 202); + assert.ok(elapsedMs < 300, `fsync acceptance took ${elapsedMs}ms`); + assert.equal((await accepted.json()).refsCommitted, false); + await waitFor(() => syncStarted); + + const duplicate = await postWebhook(port, repo, "durable-delivery", commit, "test-secret", { marker: "one" }); + assert.equal(duplicate.status, 202); + assert.match((await duplicate.json()).disposition, /duplicate-durable-inbox-record/); + + const bodyConflict = await postWebhook(port, repo, "durable-delivery", commit, "test-secret", { marker: "two" }); + assert.equal(bodyConflict.status, 409); + + releaseSync(); + await waitFor(async () => (await controller.status()).deliveries.some((record) => record.deliveryId === "durable-delivery" && record.state === "committed")); + const committed = (await controller.status()).deliveries.find((record) => record.deliveryId === "durable-delivery"); + assert.equal(committed.result.sourceCommit, commit); + assert.equal(committed.result.authorityCommit, commit); + assert.equal(committed.result.snapshotRef, `refs/snapshots/source/${commit}`); + } finally { + server.close(); + await controller.stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("startup rejects corrupt journal records and recovers a crash-left processing record", async () => { + const corruptRoot = mkdtempSync(join(tmpdir(), "unidesk-gitea-inbox-corrupt-")); + const corruptInbox = join(corruptRoot, "inbox"); + const journalCreatedAt = Date.parse("2026-07-11T07:00:00.000Z"); + const corruptStore = createDurableInboxStore({ path: corruptInbox, maxBytes: 1024 * 1024, now: () => journalCreatedAt }); + await corruptStore.initialize(); + assert.equal(corruptStore.journalMetadata().createdAt, new Date(journalCreatedAt).toISOString()); + const restartedStore = createDurableInboxStore({ path: corruptInbox, maxBytes: 1024 * 1024, now: () => journalCreatedAt + 60_000 }); + await restartedStore.initialize(); + assert.equal(restartedStore.journalMetadata().createdAt, new Date(journalCreatedAt).toISOString()); + writeFileSync(join(corruptInbox, `${"a".repeat(64)}.json`), JSON.stringify({ version: 1, deliveryId: "corrupt", fingerprint: "b".repeat(64), state: "accepted" })); + const corruptController = createDurableSyncController(durableControllerOptions(corruptRoot, [fakeRepo("corrupt")])); + try { + await corruptController.start(); + assert.equal(corruptController.readiness().ready, false); + assert.equal(corruptController.readiness().errorType, "durable-inbox-record-invalid"); + } finally { + await corruptController.stop(); + rmSync(corruptRoot, { recursive: true, force: true }); + } + + const recoveryRoot = mkdtempSync(join(tmpdir(), "unidesk-gitea-inbox-recovery-")); + const repo = fakeRepo("recovery"); + const store = createDurableInboxStore({ path: join(recoveryRoot, "inbox"), maxBytes: 1024 * 1024 }); + try { + await store.initialize(); + const delivery = fakeDelivery("processing-before-crash", "b".repeat(40), repo); + await store.accept(repo, delivery); + await assert.rejects( + () => store.update(delivery.deliveryId, (record) => ({ ...record, acceptedSequence: record.acceptedSequence + 1 })), + /invalid-record-update/, + ); + await store.update(delivery.deliveryId, (record) => ({ + ...record, + state: "processing", + processingAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + totalAttempts: 1, + cycleAttempt: 1, + })); + const controller = createDurableSyncController(durableControllerOptions(recoveryRoot, [repo], { + sync: async () => ({ + ok: true, + sourceCommit: delivery.requestedCommit, + authorityCommit: delivery.requestedCommit, + snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, + disposition: "atomic-source-authority-committed", + }), + })); + await controller.start(); + await waitFor(async () => (await controller.status()).deliveries.some((record) => record.deliveryId === delivery.deliveryId && record.state === "committed")); + await controller.stop(); + } finally { + rmSync(recoveryRoot, { recursive: true, force: true }); + } +}); + +test("unavailable or full durable inbox is not ready and fails closed with 503", async () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-inbox-unavailable-")); + const blockingFile = join(root, "not-a-directory"); + writeFileSync(blockingFile, "blocked"); + const repo = fakeRepo("capacity"); + const unavailable = createDurableSyncController(durableControllerOptions(root, [repo], { + inbox: { path: join(blockingFile, "inbox"), maxBytes: 1024 }, + })); + await unavailable.start(); + assert.equal(unavailable.readiness().ready, false); + await assert.rejects(() => unavailable.accept(repo, fakeDelivery("unavailable", "a".repeat(40), repo)), /durable-inbox/); + await unavailable.stop(); + + const full = createDurableSyncController(durableControllerOptions(root, [repo], { + inbox: { path: join(root, "full-inbox"), maxBytes: 1 }, + })); + try { + await full.start(); + await assert.rejects(() => full.accept(repo, fakeDelivery("full", "b".repeat(40), repo)), /capacity-exhausted/); + assert.equal(full.readiness().ready, false); + assert.equal(full.readiness().errorType, "durable-inbox-capacity-exhausted"); + } finally { + await full.stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("retention keeps each repository latest committed watermark and status keeps every repo latest record", async () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-inbox-retention-")); + const store = createDurableInboxStore({ path: join(root, "inbox"), maxBytes: 4 * 1024 * 1024 }); + try { + await store.initialize(); + const old = new Date(Date.now() - 10_000).toISOString(); + for (let index = 0; index < 105; index += 1) { + const repo = fakeRepo(index < 2 ? "quiet" : `noise-${index}`); + const delivery = { ...fakeDelivery(`retained-${index}`, index.toString(16).padStart(40, "0"), repo), receivedAt: old }; + await store.accept(repo, delivery); + await store.update(delivery.deliveryId, (record) => ({ + ...record, + state: "committed", + updatedAt: old, + committedAt: old, + nextAttemptAt: null, + result: { + sourceCommit: delivery.requestedCommit, + authorityCommit: delivery.requestedCommit, + snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, + disposition: "atomic-source-authority-committed", + }, + })); + } + const removed = await store.cleanupCommitted(Date.now()); + assert.equal(removed, 1); + const retained = await store.list(); + assert.equal(retained.filter((record) => record.repoKey === "quiet").length, 1); + assert.equal(retained.find((record) => record.repoKey === "quiet").deliveryId, "retained-1"); + + const quietRepo = fakeRepo("quiet"); + const failedDelivery = fakeDelivery("quiet-failed", "f".repeat(40), quietRepo); + await store.accept(quietRepo, failedDelivery); + await store.update(failedDelivery.deliveryId, (record) => ({ + ...record, + state: "failed", + updatedAt: new Date().toISOString(), + failedAt: new Date().toISOString(), + nextAttemptAt: "2099-01-01T00:00:00.000Z", + lastError: { errorType: "test-failure", retryable: true, terminal: true }, + })); + + const controller = createDurableSyncController(durableControllerOptions(root, [...retained.map((record) => fakeRepo(record.repoKey)), quietRepo], { store })); + await controller.start(); + const status = await controller.status(); + assert.ok(status.deliveries.some((record) => record.repo === "quiet" && record.deliveryId === "retained-1")); + assert.ok(status.deliveries.some((record) => record.repo === "quiet" && record.deliveryId === "quiet-failed")); + await controller.stop(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("same-millisecond records are listed and scheduled by immutable acceptedSequence", async () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-same-millisecond-order-")); + const repo = fakeRepo("same-millisecond"); + const store = createDurableInboxStore({ path: join(root, "inbox"), maxBytes: 1024 * 1024 }); + const receivedAt = "2026-07-11T08:00:00.000Z"; + const first = { ...fakeDelivery("same-ms-first", "1".repeat(40), repo), receivedAt }; + const second = { ...fakeDelivery("same-ms-second", "2".repeat(40), repo), receivedAt }; + const executionOrder = []; + let controller; + try { + await store.initialize(); + await store.accept(repo, first); + await store.accept(repo, second); + const listed = await store.list(); + assert.deepEqual(listed.map((record) => [record.deliveryId, record.acceptedSequence]), [ + [first.deliveryId, 1], + [second.deliveryId, 2], + ]); + controller = createDurableSyncController(durableControllerOptions(root, [repo], { + sync: async (_repo, delivery) => { + executionOrder.push(delivery.deliveryId); + return { + ok: true, + sourceCommit: delivery.requestedCommit, + authorityCommit: delivery.requestedCommit, + snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, + disposition: "atomic-source-authority-committed", + }; + }, + })); + await controller.start(); + await waitFor(async () => (await controller.status()).counts.committed === 2); + assert.deepEqual(executionOrder, [first.deliveryId, second.deliveryId]); + } finally { + if (controller) await controller.stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("concurrent duplicate delivery shares one promise and payload conflict fails closed", async () => { + let release; + const gate = new Promise((resolve) => { release = resolve; }); + let calls = 0; + const coordinator = createSyncCoordinator({ + responseBudgetMs: 1000, + retryMaxAttempts: 2, + retryInitialDelayMs: 1, + retryMaxDelayMs: 1, + sleep: zeroDelay, + log: () => {}, + sync: async (_repo, delivery) => { + calls += 1; + await gate; + return { ok: true, sourceCommit: delivery.requestedCommit, authorityCommit: delivery.requestedCommit, snapshotRef: `refs/snapshots/${delivery.requestedCommit}`, disposition: "atomic-source-authority-committed" }; + }, + }); + const repo = fakeRepo("one"); + const delivery = fakeDelivery("delivery-1", "a".repeat(40), repo); + const first = coordinator.execute(repo, delivery); + const duplicate = coordinator.execute(repo, { ...delivery }); + assert.throws( + () => coordinator.execute(repo, { ...delivery, requestedCommit: "b".repeat(40) }), + /github-delivery-id-payload-conflict/, + ); + release(); + const [firstResult, duplicateResult] = await Promise.all([first, duplicate]); + assert.equal(calls, 1); + assert.equal(firstResult.ok, true); + assert.equal(duplicateResult.duplicate, true); +}); + +test("different repositories are isolated while each repository stays serialized", async () => { + let releaseOne; + const gateOne = new Promise((resolve) => { releaseOne = resolve; }); + const order = []; + const coordinator = createSyncCoordinator({ + responseBudgetMs: 1000, + retryMaxAttempts: 1, + retryInitialDelayMs: 1, + retryMaxDelayMs: 1, + sleep: zeroDelay, + log: () => {}, + sync: async (repo, delivery) => { + order.push(`start:${repo.key}:${delivery.deliveryId}`); + if (repo.key === "one") await gateOne; + order.push(`end:${repo.key}:${delivery.deliveryId}`); + return { ok: true, sourceCommit: delivery.requestedCommit, authorityCommit: delivery.requestedCommit, snapshotRef: `refs/snapshots/${delivery.requestedCommit}`, disposition: "atomic-source-authority-committed" }; + }, + }); + const repoOne = fakeRepo("one"); + const repoTwo = fakeRepo("two"); + const oneA = coordinator.execute(repoOne, fakeDelivery("one-a", "a".repeat(40), repoOne)); + const oneB = coordinator.execute(repoOne, fakeDelivery("one-b", "b".repeat(40), repoOne)); + const two = coordinator.execute(repoTwo, fakeDelivery("two-a", "c".repeat(40), repoTwo)); + await two; + assert.deepEqual(order.slice(0, 3), ["start:one:one-a", "start:two:two-a", "end:two:two-a"]); + releaseOne(); + await Promise.all([oneA, oneB]); + assert.ok(order.indexOf("start:one:one-b") > order.indexOf("end:one:one-a")); +}); + +test("retry exhaustion is terminal and observable", async () => { + const logs = []; + let calls = 0; + const coordinator = createSyncCoordinator({ + responseBudgetMs: 1000, + retryMaxAttempts: 3, + retryInitialDelayMs: 1, + retryMaxDelayMs: 1, + sleep: zeroDelay, + log: (event) => logs.push(event), + sync: async () => { + calls += 1; + return { ok: false, retryable: true, errorType: "git-source-sync-failed" }; + }, + }); + const repo = fakeRepo("one"); + const result = await coordinator.execute(repo, fakeDelivery("delivery-fail", "a".repeat(40), repo)); + assert.equal(result.ok, false); + assert.equal(calls, 3); + assert.deepEqual(logs.filter((event) => event.event === "github-to-gitea-sync").map((event) => [event.syncAttempt, event.willRetry, event.terminal]), [ + [1, true, false], + [2, true, false], + [3, false, true], + ]); +}); + +test("durable worker automatically starts a new cycle after terminal retry exhaustion", async () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-terminal-retry-")); + const repo = fakeRepo("terminal-retry"); + const logs = []; + let calls = 0; + const controller = createDurableSyncController(durableControllerOptions(root, [repo], { + log: (event) => logs.push(event), + workerRetry: { + maxAttempts: 2, + initialDelayMs: 1, + maxDelayMs: 1, + terminalRetryDelayMs: 5, + scanIntervalMs: 1, + }, + sync: async (_repo, delivery) => { + calls += 1; + if (calls < 3) return { ok: false, retryable: true, errorType: "transient-source-sync-failed" }; + return { + ok: true, + sourceCommit: delivery.requestedCommit, + authorityCommit: delivery.requestedCommit, + snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, + disposition: "atomic-source-authority-committed", + }; + }, + })); + try { + await controller.start(); + const delivery = fakeDelivery("terminal-retry-delivery", "a".repeat(40), repo); + await controller.accept(repo, delivery); + await waitFor(async () => (await controller.status()).deliveries.some((record) => record.deliveryId === delivery.deliveryId && record.state === "committed")); + const committed = (await controller.status()).deliveries.find((record) => record.deliveryId === delivery.deliveryId); + assert.equal(calls, 3); + assert.equal(committed.totalAttempts, 3); + assert.equal(logs.some((event) => event.event === "github-to-gitea-delivery-failed" && event.terminal === true && event.automaticRetryScheduled === true), true); + } finally { + await controller.stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("unexpected executor rejection is durably failed and automatically retries without restart", async () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-executor-rejection-")); + const repo = fakeRepo("executor-rejection"); + const logs = []; + let calls = 0; + const controller = createDurableSyncController(durableControllerOptions(root, [repo], { + log: (event) => logs.push(event), + workerRetry: { initialDelayMs: 1, maxDelayMs: 1, scanIntervalMs: 1 }, + executor: { + async execute(_repo, delivery) { + calls += 1; + if (calls === 1) throw new Error("unexpected executor rejection"); + return { + ok: true, + sourceCommit: delivery.requestedCommit, + authorityCommit: delivery.requestedCommit, + snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, + disposition: "atomic-source-authority-committed", + }; + }, + }, + })); + try { + await controller.start(); + const delivery = fakeDelivery("executor-rejection-delivery", "8".repeat(40), repo); + await controller.accept(repo, delivery); + await waitFor(async () => (await controller.status()).counts.committed === 1); + const committed = (await controller.status()).deliveries[0]; + assert.equal(calls, 2); + assert.equal(committed.state, "committed"); + assert.equal(committed.totalAttempts, 2); + assert.equal(logs.some((event) => event.event === "github-to-gitea-delivery-failed" && event.errorType === "source-sync-executor-threw" && event.automaticRetryScheduled === true), true); + } finally { + await controller.stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("undefined and incomplete successful executor results are durably failed before retry", async () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-malformed-executor-result-")); + const repo = fakeRepo("malformed-executor-result"); + const logs = []; + let calls = 0; + const controller = createDurableSyncController(durableControllerOptions(root, [repo], { + log: (event) => logs.push(event), + workerRetry: { maxAttempts: 3, initialDelayMs: 1, maxDelayMs: 1, scanIntervalMs: 1 }, + executor: { + async execute(_repo, delivery) { + calls += 1; + if (calls === 1) return undefined; + if (calls === 2) return { ok: true }; + return { + ok: true, + sourceCommit: delivery.requestedCommit, + authorityCommit: delivery.requestedCommit, + snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, + disposition: "atomic-source-authority-committed", + }; + }, + }, + })); + try { + await controller.start(); + const delivery = fakeDelivery("malformed-executor-result-delivery", "9".repeat(40), repo); + await controller.accept(repo, delivery); + await waitFor(async () => (await controller.status()).counts.committed === 1); + const committed = (await controller.status()).deliveries[0]; + assert.equal(calls, 3); + assert.equal(committed.totalAttempts, 3); + assert.equal(logs.filter((event) => event.event === "github-to-gitea-delivery-failed" && event.errorType === "source-sync-executor-invalid-result").length, 2); + } finally { + await controller.stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("slow sync cannot return success after the absolute response deadline", async () => { + const coordinator = createSyncCoordinator({ + responseBudgetMs: 20, + retryMaxAttempts: 1, + retryInitialDelayMs: 1, + retryMaxDelayMs: 1, + sleep: zeroDelay, + log: () => {}, + sync: async (_repo, delivery) => { + await new Promise((resolve) => setTimeout(resolve, 35)); + return { ok: true, sourceCommit: delivery.requestedCommit, authorityCommit: delivery.requestedCommit, snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, disposition: "atomic-source-authority-committed" }; + }, + }); + const repo = fakeRepo("deadline"); + const result = await coordinator.execute(repo, fakeDelivery("slow", "e".repeat(40), repo)); + assert.equal(result.ok, false); + assert.equal(result.retryable, true); + assert.equal(result.errorType, "response-deadline-exhausted"); + assert.equal(result.committedBeforeDeadlineUnknown, true); +}); + +test("git subprocesses fail closed before launch when the shared deadline is exhausted", async () => { + let commands = 0; + const repo = { + ...fakeRepo("expired"), + upstream: { ...fakeRepo("expired").upstream, cloneUrl: "/does/not/matter" }, + gitea: { readUrl: "file:///does/not/matter" }, + }; + const result = await syncRepository(repo, fakeDelivery("expired", "f".repeat(40), repo), { + deadlineAt: 10, + now: () => 10, + runResult: () => { + commands += 1; + return { status: 0, stdout: "", stderr: "" }; + }, + githubAuthHeader: "", + giteaAuthHeader: "", + giteaBaseUrl: "file://", + }); + assert.equal(result.ok, false); + assert.equal(result.retryable, true); + assert.equal(commands, 0); + assert.match(result.error, /response deadline exhausted/); +}); + +test("async process runner handles spawn errors and kills the whole subprocess group at deadline", async () => { + const missing = await runResult(["unidesk-command-that-does-not-exist"], 100); + assert.equal(missing.status, null); + assert.match(missing.stderr, /ENOENT/); + + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-process-tree-")); + const marker = join(root, "child-survived"); + try { + const timedOut = await runResult(["sh", "-c", '(sleep 0.25; : > "$1") & wait', "sh", marker], 40); + assert.equal(timedOut.status, null); + assert.match(timedOut.stderr, /deadline exceeded/); + await new Promise((resolve) => setTimeout(resolve, 350)); + assert.equal(existsSync(marker), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("controller shutdown aborts the active process tree and the durable record resumes after restart", async () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-controller-shutdown-")); + const startedMarker = join(root, "worker-started"); + const survivorMarker = join(root, "child-survived"); + const repo = fakeRepo("shutdown"); + const delivery = fakeDelivery("shutdown-delivery", "c".repeat(40), repo); + const controller = createDurableSyncController(durableControllerOptions(root, [repo], { + shutdownGraceMs: 500, + sync: async (_repo, _delivery, context) => { + const result = await runResult([ + "sh", "-c", ': > "$1"; (sleep 0.25; : > "$2") & wait', "sh", startedMarker, survivorMarker, + ], 10_000, context.signal); + return { ok: false, retryable: true, errorType: result.stderr.includes("aborted") ? "worker-aborted" : "worker-exited" }; + }, + })); + try { + await controller.start(); + await controller.accept(repo, delivery); + await waitFor(() => existsSync(startedMarker)); + await controller.stop(); + await new Promise((resolve) => setTimeout(resolve, 320)); + assert.equal(existsSync(survivorMarker), false); + + const resumed = createDurableSyncController(durableControllerOptions(root, [repo], { + sync: async () => ({ + ok: true, + sourceCommit: delivery.requestedCommit, + authorityCommit: delivery.requestedCommit, + snapshotRef: `refs/snapshots/source/${delivery.requestedCommit}`, + disposition: "atomic-source-authority-committed", + }), + })); + await resumed.start(); + await waitFor(async () => (await resumed.status()).deliveries.some((record) => record.deliveryId === delivery.deliveryId && record.state === "committed")); + await resumed.stop(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("atomic mirror handles partial refs, immutable snapshots, out-of-order delivery and lease race", async () => { + const fixture = gitFixture(); + try { + const { repo, runtime, commits, gitea } = fixture; + const first = await syncRepository(repo, fakeDelivery("a", commits.a, repo), runtime); + assert.equal(first.ok, true); + assert.equal(remoteRef(gitea, "refs/heads/main"), commits.a); + assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.a}`), commits.a); + + git("--git-dir", gitea, "update-ref", `refs/snapshots/source/${commits.a}`, "-d"); + const partial = await syncRepository(repo, fakeDelivery("a-partial", commits.a, repo), runtime); + assert.equal(partial.ok, true); + assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.a}`), commits.a); + + const forward = await syncRepository(repo, fakeDelivery("b", commits.b, repo), runtime); + assert.equal(forward.ok, true); + assert.equal(remoteRef(gitea, "refs/heads/main"), commits.b); + assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.b}`), commits.b); + + const old = await syncRepository(repo, fakeDelivery("a-late", commits.a, repo), runtime); + assert.equal(old.ok, true); + assert.equal(old.disposition, "superseded-with-immutable-snapshot"); + assert.equal(remoteRef(gitea, "refs/heads/main"), commits.b); + + const sameSnapshot = await syncRepository(repo, fakeDelivery("b-again", commits.b, repo), runtime); + assert.equal(sameSnapshot.ok, true); + assert.equal(remoteRef(gitea, "refs/heads/main"), commits.b); + + git("--git-dir", gitea, "update-ref", `refs/snapshots/source/${commits.c}`, commits.a); + const conflict = await syncRepository(repo, fakeDelivery("c-conflict", commits.c, repo), runtime); + assert.equal(conflict.ok, false); + assert.equal(conflict.errorType, "immutable-snapshot-conflict"); + assert.equal(remoteRef(gitea, "refs/heads/main"), commits.b); + git("--git-dir", gitea, "update-ref", `refs/snapshots/source/${commits.c}`, "-d"); + git("--git-dir", gitea, "fetch", "-q", fixture.source, `${commits.d}:refs/hidden/race-d`); + + let raced = false; + const racingRuntime = { + ...runtime, + async runResult(args) { + if (!raced && args.includes("push") && args.includes("--atomic")) { + raced = true; + git("--git-dir", gitea, "update-ref", "refs/heads/main", commits.d); + } + return await runResult(args); + }, + }; + const leaseRace = await syncRepository(repo, fakeDelivery("c-race", commits.c, repo), racingRuntime); + assert.equal(leaseRace.ok, false); + assert.equal(leaseRace.errorType, "atomic-push-race-or-failure"); + assert.equal(remoteRef(gitea, "refs/heads/main"), commits.d); + assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.c}`), null); + + const retry = await syncRepository(repo, fakeDelivery("c-retry", commits.c, repo), runtime); + assert.equal(retry.ok, true); + assert.equal(retry.disposition, "superseded-with-immutable-snapshot"); + assert.equal(remoteRef(gitea, "refs/heads/main"), commits.d); + assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.c}`), commits.c); + + const unavailable = await syncRepository(repo, fakeDelivery("force-push-unavailable", `${"0".repeat(39)}1`, repo), runtime); + assert.equal(unavailable.ok, false); + assert.equal(unavailable.retryable, true); + assert.equal(unavailable.errorType, "requested-commit-temporarily-unavailable"); + } finally { + fixture.cleanup(); + } +}); + +test("production async Git runner preserves cross-repository concurrency", async () => { + const slow = gitFixture("slow"); + const fast = gitFixture("fast"); + const marker = join(slow.root, "pre-receive-entered"); + writeFileSync(join(slow.gitea, "hooks", "pre-receive"), `#!/bin/sh\n: > "${marker}"\nsleep 1\nexit 0\n`, { mode: 0o755 }); + const coordinator = createSyncCoordinator({ + responseBudgetMs: 5000, + retryMaxAttempts: 1, + retryInitialDelayMs: 1, + retryMaxDelayMs: 1, + log: () => {}, + run, + runResult, + githubAuthHeader: "", + giteaAuthHeader: "", + giteaBaseUrl: "file://", + }); + try { + const slowResult = coordinator.execute(slow.repo, fakeDelivery("slow-a", slow.commits.a, slow.repo)); + await waitFor(() => existsSync(marker)); + const fastResult = coordinator.execute(fast.repo, fakeDelivery("fast-a", fast.commits.a, fast.repo)); + const first = await Promise.race([ + slowResult.then(() => "slow"), + fastResult.then(() => "fast"), + ]); + assert.equal(first, "fast"); + assert.equal((await fastResult).ok, true); + assert.equal((await slowResult).ok, true); + } finally { + slow.cleanup(); + fast.cleanup(); + } +}); + +test("atomic push rejection leaves branch and snapshot unchanged", async () => { + const fixture = gitFixture(); + try { + const { repo, runtime, commits, gitea } = fixture; + assert.equal((await syncRepository(repo, fakeDelivery("a", commits.a, repo), runtime)).ok, true); + const hook = join(gitea, "hooks", "pre-receive"); + writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + const rejected = await syncRepository(repo, fakeDelivery("b", commits.b, repo), runtime); + assert.equal(rejected.ok, false); + assert.equal(rejected.errorType, "atomic-push-race-or-failure"); + assert.equal(remoteRef(gitea, "refs/heads/main"), commits.a); + assert.equal(remoteRef(gitea, `refs/snapshots/source/${commits.b}`), null); + } finally { + fixture.cleanup(); + } +}); + +function gitFixture(key = "fixture") { + const root = mkdtempSync(join(tmpdir(), "unidesk-gitea-sync-test-")); + const work = join(root, "work"); + const source = join(root, "source.git"); + const gitea = join(root, "gitea.git"); + git("init", "-q", "-b", "main", work); + git("-C", work, "config", "user.name", "UniDesk Test"); + git("-C", work, "config", "user.email", "test@unidesk.local"); + const commits = {}; + for (const name of ["a", "b", "c", "d"]) { + writeFileSync(join(work, "source.txt"), `${name}\n`); + git("-C", work, "add", "source.txt"); + git("-C", work, "commit", "-q", "-m", name); + commits[name] = git("-C", work, "rev-parse", "HEAD").trim(); + } + git("clone", "-q", "--bare", work, source); + git("init", "-q", "--bare", gitea); + const repo = { + key, + upstream: { repository: `pikasTech/${key}`, branch: "main", cloneUrl: source }, + gitea: { readUrl: `file://${gitea}` }, + snapshot: { prefix: "refs/snapshots/source" }, + }; + return { + root, + work, + source, + gitea, + commits, + repo, + runtime: { + run, + runResult, + githubAuthHeader: "", + giteaAuthHeader: "", + giteaBaseUrl: "file://", + deadlineAt: Date.now() + 60000, + now: Date.now, + }, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +function fakeRepo(key) { + return { + key, + upstream: { repository: `pikasTech/${key}`, branch: "main" }, + snapshot: { prefix: "refs/snapshots/source" }, + }; +} + +function fakeDelivery(deliveryId, requestedCommit, repo) { + return { + deliveryId, + repository: repo.upstream.repository, + ref: `refs/heads/${repo.upstream.branch}`, + requestedCommit, + payloadSha: createHash("sha256").update(`${deliveryId}:${requestedCommit}`).digest("hex"), + receivedAt: new Date().toISOString(), + }; +} + +function remoteRef(repo, ref) { + try { + return git("--git-dir", repo, "rev-parse", "--verify", ref).trim(); + } catch { + return null; + } +} + +async function postWebhook(port, repo, deliveryId, requestedCommit, secret, extra = {}) { + const body = JSON.stringify({ + ref: `refs/heads/${repo.upstream.branch}`, + after: requestedCommit, + repository: { full_name: repo.upstream.repository }, + ...extra, + }); + const signature = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; + return await fetch(`http://127.0.0.1:${port}/hook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-event": "push", + "x-github-delivery": deliveryId, + "x-hub-signature-256": signature, + }, + body, + }); +} + +function durableControllerOptions(root, repos, overrides = {}) { + return { + repos, + inbox: { + path: join(root, "inbox"), + maxBytes: 4 * 1024 * 1024, + committedRetentionMs: 60_000, + cleanupIntervalMs: 60_000, + ...overrides.inbox, + }, + workerRetry: { + maxAttempts: 2, + initialDelayMs: 10, + maxDelayMs: 20, + terminalRetryDelayMs: 50, + attemptTimeoutMs: 2000, + scanIntervalMs: 10, + ...overrides.workerRetry, + }, + shutdownGraceMs: 50, + githubAuthHeader: "", + giteaAuthHeader: "", + giteaBaseUrl: "file://", + run, + runResult, + log: () => {}, + ...overrides, + inbox: { + path: join(root, "inbox"), + maxBytes: 4 * 1024 * 1024, + committedRetentionMs: 60_000, + cleanupIntervalMs: 60_000, + ...overrides.inbox, + }, + workerRetry: { + maxAttempts: 2, + initialDelayMs: 10, + maxDelayMs: 20, + terminalRetryDelayMs: 50, + attemptTimeoutMs: 2000, + scanIntervalMs: 10, + ...overrides.workerRetry, + }, + }; +} + +async function waitFor(predicate, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("condition not reached before timeout"); +} + +function git(...args) { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} diff --git a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts new file mode 100644 index 00000000..e40b80c1 --- /dev/null +++ b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts @@ -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\""); +}); diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index acb6b02e..4f2e39a6 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -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 diff --git a/scripts/src/platform-infra-gitea-status-evaluator.test.ts b/scripts/src/platform-infra-gitea-status-evaluator.test.ts new file mode 100644 index 00000000..a499d2d6 --- /dev/null +++ b/scripts/src/platform-infra-gitea-status-evaluator.test.ts @@ -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>; + inboxRecords?: Array>; + deliveredAt?: string; + journalCreatedAt?: string; +}): Record { + 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 { + return { + deliveryId, + repo: "fixture", + state: "committed", + result: { + sourceCommit, + authorityCommit, + snapshotRef: `refs/snapshots/source/${sourceCommit}`, + disposition, + }, + }; +} + +function terminalEvent(deliveryId: string, sourceCommit: string): Record { + 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): Record { + 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); +} diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index f4ae0e4f..47441cf5 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -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 { 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, path: string): G } function parseWebhookSync(record: Record, 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, 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, path: string): GiteaW }; } +function parseWebhookGitOpsDelivery(record: Record, 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, 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(); const webhookPorts = new Set(); @@ -788,13 +912,8 @@ async function mirrorCommand(config: UniDeskConfig, args: string[]): Promise --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 [--repo ] [--confirm]", + usage: "platform-infra gitea mirror webhook status --target [--repo ] [--full|--raw]", }; } @@ -836,7 +955,7 @@ function mirrorPlan(options: CommonOptions): Record { 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> { 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> { 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 "); - 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 = { @@ -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 = { @@ -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> { + 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> { 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 { }; } -function mirrorNextCommands(targetId: string): Record { +function mirrorSetupNextCommands(targetId: string): Record { return { bootstrap: `bun scripts/cli.ts platform-infra gitea mirror bootstrap --target ${targetId} --confirm`, - sync: `bun scripts/cli.ts platform-infra gitea mirror sync --target ${targetId} --confirm`, status: `bun scripts/cli.ts platform-infra gitea mirror status --target ${targetId}`, 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 { }; } +function mirrorReadOnlyNextCommands(targetId: string): Record { + 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 { 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): 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): 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): 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): 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): 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, key: string, path: string): stri return value; } +function gitBranchField(obj: Record, 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, 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, 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`); diff --git a/scripts/src/platform-infra-ops-library.ts b/scripts/src/platform-infra-ops-library.ts index b721da04..eb69f114 100644 --- a/scripts/src/platform-infra-ops-library.ts +++ b/scripts/src/platform-infra-ops-library.ts @@ -173,12 +173,52 @@ export function parseOpsApplyOptions(args: string[]): OpsApplyOptions { } export function readYamlRecord>(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 }> = []; + 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() }; + 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 { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); return value as Record; diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 5293cd28..22716774 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -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"; diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index e8a2151a..177d5b71 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -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 ", - "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 --source-commit --wait", "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer [--json|--full|--raw]", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer --limit 10 [--json]", diff --git a/scripts/src/platform_infra_gitea_status_evaluator.py b/scripts/src/platform_infra_gitea_status_evaluator.py new file mode 100644 index 00000000..b82ba33b --- /dev/null +++ b/scripts/src/platform_infra_gitea_status_evaluator.py @@ -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()