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

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