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