Merge pull request #1593 from pikasTech/fix/nc01-target-gitea-webhook

修复 NC01 Gitea PaC webhook 触发链路
This commit is contained in:
Lyon
2026-07-09 02:26:12 +08:00
committed by GitHub
17 changed files with 601 additions and 85 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ bun scripts/cli.ts cicd branch-follower debug-step --follower web-probe-sentinel
- PR monitor 与自动合并: [references/pr-monitor.md](references/pr-monitor.md)。
- Tekton/Argo、node-scoped runtime lane、D601 infra bootstrap: [references/control-plane.md](references/control-plane.md)。
- HWLAB/AgentRun git-mirror source authority 与 flush: [references/git-mirror.md](references/git-mirror.md)。
- JD01 Gitea mirror + Pipelines-as-Code 正式架构、三 consumer 覆盖矩阵和旧入口处置: [references/gitea-pac.md](references/gitea-pac.md)。
- JD01/NC01 Gitea mirror + Pipelines-as-Code 正式架构、三 consumer 覆盖矩阵、target 级 webhook path 和旧入口处置: [references/gitea-pac.md](references/gitea-pac.md)。
- AgentRun/HWLAB env reuse 机制、证据解释和 PaC status/history 观察口径: [references/env-reuse.md](references/env-reuse.md)。
- Secret、observability、platform-infra、CI tools image、PipelineRun 清理和 rollout 补记: [references/platform-ops.md](references/platform-ops.md)。
- AgentRun YAML-only lane、v0.1 兼容入口和 AgentRun git-mirror: [references/agentrun.md](references/agentrun.md)。
@@ -14,6 +14,7 @@ GitHub remains the upstream write authority. A delivery should be triggered by m
- PaC controller, Repository CRs, consumer parameters, Tekton pipeline names and Argo applications: `config/platform-infra/pipelines-as-code.yaml`.
- Historical snapshot prefixes containing `gitea-actions` are retained only for existing refs. They do not mean Gitea Actions is the active trigger architecture.
- Node-level status for these consumers is `bun scripts/cli.ts cicd status --node <NODE>`. Consumer drill-down is `platform-infra pipelines-as-code status|history|closeout --target <NODE> --consumer <id>`.
- Multi-node PaC consumers that share a GitHub upstream must not share a webhook bridge URL. Each target node declares its own GitHub webhook public path and FRP remote port in `config/platform-infra/gitea.yaml#targets[].webhookSync`, then `platform-infra gitea apply --target <NODE> --confirm` renders the node-local bridge, FRPC proxy and PK01 Caddy path.
## Coverage Matrix
+3 -3
View File
@@ -1,6 +1,6 @@
# UniDesk Agent Index
UniDesk 是一个以主 server 为统一入口的分布式工作平台。本文件只做自动加载的顶级索引;长期规则、详细流程和判定标准必须放到 skill 或 `docs/reference/*.md`
UniDesk 以主 server 为统一入口。本文件只做自动加载的顶级索引;长期规则、流程和判定标准必须放到 skill 或 `docs/reference/*.md`
## P0: 文件体积与脚本分流
@@ -11,9 +11,9 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台。本文
- P0: 发现固定主/目标 worktree 落后 remote 时,必须立刻先 `git stash push -u` 保存脏改(如有,含 untracked),再 `git pull --ff-only` 快进到最新 remote,然后 `git stash apply` 并按语义合并;主工作区恢复出的并行改动必须先直接提交,再继续后续任务;禁止用 reset、drop 或覆盖式 checkout 丢弃并行改动。
## P0: backend-core 运行面冻结
## P0: NC01 k8s server 固定运行面
- P0: backend-core 运行面恢复 healthy 后,除非用户明确要求,不得再触碰 backend-core 运行面;禁止主动执行 `server rebuild backend-core``server restart backend-core`、手工重建/重启/替换 `unidesk-backend-core` 容器或等价生产变更
- P0: NC01 用 k8s 部署 UniDesk server/backend-core 是固定原则;恢复、验证和 `trans` 必须以 `config/unidesk-host-k8s.yaml` 与 k8s `unidesk` namespace 为准,禁止把 Docker Compose/`unidesk-backend-core` 容器当 NC01 运行面;healthy 后非明确要求不得重建/重启/替换
## P0: AGENTS.md 体积与分流
+5
View File
@@ -252,6 +252,11 @@ targets:
storageClassName: local-path
publicExposure:
enabled: false
webhookSync:
publicPath: /_unidesk/github-to-gitea/nc01
frpc:
proxyName: platform-infra-gitea-nc01-github-webhook
remotePort: 22082
app:
name: gitea
+39 -1
View File
@@ -26,6 +26,23 @@ server:
host: 127.0.0.1
port: 18792
sources:
pk01-nc01-vpn-server-hysteria:
sourceType: vpn-server-hysteria-existing
serverRef: /root/vpn-server
benchmarkRef: local-vpn-server
implementationRef: /root/vpn-server
sourceConfigRef: /root/vpn-server/output/client.yaml
expectedServer: 152-53-229-148.nip.io:443
client:
mode: external-existing-hysteria
configPath: /etc/host-proxy/hysteria-client.yaml
serviceName: host-proxy-hysteria.service
httpListenHost: 127.0.0.1
httpListenPort: 10809
socks5ListenHost: 127.0.0.1
socks5ListenPort: 10808
proxyUrl: http://127.0.0.1:10809
externalProbeUrl: https://www.gstatic.com/generate_204
nc01-vpn-server-shadowsocks:
extends: templates.sources.singBoxHostClient
benchmarkRef: local-vpn-server
@@ -33,6 +50,24 @@ sources:
extends: templates.sources.singBoxHostClient
benchmarkRef: "pikasTech/unidesk#1110"
targets:
PK01:
extends: templates.targets.hostProxyClient
route: PK01
sourceRef: sources.pk01-nc01-vpn-server-hysteria
env:
httpProxy: http://127.0.0.1:10809
httpsProxy: http://127.0.0.1:10809
allProxy: http://127.0.0.1:10809
noProxyRefs:
common: noProxy.common
extras: noProxy.nodeExtras.PK01
trans:
hostProxyEnv:
enabled: false
apply:
reloadSystemd: false
restartDocker: false
restartK3s: false
NC01:
extends: templates.targets.hostProxyClient
route: NC01
@@ -133,8 +168,11 @@ noProxy:
- hyueapi.com
- .hyueapi.com
nodeExtras:
PK01:
- 152.53.229.148
- 152-53-229-148.nip.io
NC01:
- 152.53.229.148
JD01:
- 74.48.78.17
- 82.156.23.220
- 82.156.23.220
@@ -94,15 +94,16 @@ manualAccounts:
kind: proxy
provider: target-egress-proxy
description: Bind a protected manual account to the selected target's YAML-declared egress proxy.
pk01-local-egress-proxy:
pk01-host-proxy:
enabled: true
kind: proxy
provider: fixed-http-proxy
description: Bind a protected manual account to PK01 local provider-gateway egress.
description: Bind protected/manual PK01 Sub2API accounts to the PK01 host proxy HTTP endpoint.
hostProxyRef: config/platform-infra/host-proxy.yaml#targets.PK01
fixedProxy:
protocol: http
host: 127.0.0.1
port: 18789
port: 10809
unified-pool-group:
enabled: true
kind: group
@@ -113,7 +114,7 @@ manualAccounts:
reason: Manually configured in Sub2API as protected; keep outside pool auto-management and sentinel control.
proxyBinding:
enabled: true
source: pk01-local-egress-proxy
source: pk01-host-proxy
proxyName: platform-infra-sub2api-pk01-local-egress-proxy
groupBinding:
enabled: true
+1 -1
View File
@@ -98,7 +98,7 @@ bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01
`status` 只读观察 YAML 选中 lane 的 source authority、对应 PipelineRun、GitOps latest、Argo Application、runtime workload、manager source commit 和 git mirror/Gitea 摘要,并报告 Argo revision 是否对齐该 lane 的 GitOps latest。未迁移的 legacy `v0.2` lane source authority 只来自 k8s git-mirror snapshot:受控 sync 先为 branch tip 创建 `refs/unidesk/snapshots/agentrun-yaml-lane/<branch>/<commit>``status` / `trigger-current` / build 只读取该 snapshot 和 `sourceStageRef`,不得把 host source workspace、本地 fetch/pull、可变 branch ref 或 Pipeline 直连 GitHub 当 authoritative source。默认输出是 compact commander 视图:`target` 只保留 node/lane/source/runtime/CI/GitOps/git-mirror/database 摘要,关键结论在 `summary``alignment`,成功 probe 的 stdout/stderr tail、完整 YAML target、原始 `source``runtime``gitMirror` payload 默认省略;需要完整展开时使用返回的 `disclosure.fullCommand` 或显式加 `--full`,需要原始调试视图时加 `--raw``status` 额外支持 `--pipeline-run <name>``--source-commit <sha>` 定点查询;`--pipeline-run` 会读取 PipelineRun `revision` 参数作为 pinned source commit,并在 `alignment.branchDrift` / `summary.branchDrift` 中同时披露当前 snapshot tip、目标 source commit、PipelineRun source commit、是否已被当前 snapshot supersede 以及 `triggerLatest` 下一步。`status` 会向 stderr 输出 `agentrun.control-plane.status.progress` 阶段事件,覆盖 `source``runtime``git-mirror`,避免长时间聚合时无可见进展。legacy `trigger-current` 会先执行 k8s git-mirror sync 并以 snapshot commit 创建 commit-pinned PipelineRun;同名 PipelineRun 正在运行或已经成功时必须拒绝重复触发,只允许在失败态或不存在时创建。该命令只提交 CI/CD 工作,不等待完整 PipelineRun 或 rollout 完成,后续用 `job status``status --pipeline-run <name>` 轮询。`refresh` 只对 YAML 声明的 Argo Application 执行 hard refresh,用于 GitOps promotion 已完成但 Argo 仍停留旧 revision 时的受控同步入口;它不直接 patch runtime workload。
JD01/NC01 `agentrun-<node>-v02` PaC 迁移 lane 的正式触发链路是:GitHub PR 合并到 `pikasTech/agentrun@v0.2` -> GitHub webhook bridge 同步到 Gitea controlled mirror 和 immutable snapshot refs -> Gitea repository push webhook 触发 Pipelines-as-Code -> Tekton PipelineRun -> GitOps/Argo -> k8s runtime。该 lane 不再使用 branch-follower、Gitea Actions、act_runner 或自维护 `trigger-current` 作为正式 CI 触发器;对应 source authority、repo visibility、public exposure 和 mirror credentials 属于 `config/platform-infra/gitea.yaml`PaC controller/Repository/webhook/Tekton 参数属于 `config/platform-infra/pipelines-as-code.yaml`。Gitea 的公开 Web UI 是 `https://gitea.pikapython.com`,但 CI、Argo 和 runtime 内部读取必须使用 `gitea-http.devops-infra.svc.cluster.local:3000` 的 ClusterIP URL,避免公网回环和 legacy git-mirror commit 缺失。
JD01/NC01 `agentrun-<node>-v02` PaC 迁移 lane 的正式触发链路是:GitHub PR 合并到 `pikasTech/agentrun@v0.2` -> GitHub webhook bridge 同步到 Gitea controlled mirror 和 immutable snapshot refs -> Gitea repository push webhook 触发 Pipelines-as-Code -> Tekton PipelineRun -> GitOps/Argo -> k8s runtime。该 lane 不再使用 branch-follower、Gitea Actions、act_runner 或自维护 `trigger-current` 作为正式 CI 触发器;对应 source authority、repo visibility、public exposure 和 mirror credentials 属于 `config/platform-infra/gitea.yaml`PaC controller/Repository/webhook/Tekton 参数属于 `config/platform-infra/pipelines-as-code.yaml`多节点共享同一 GitHub upstream 时,每个目标 node 的 webhook bridge 必须使用 target 级 YAML path 和 FRP remote port,不得复用另一节点的 GitHub hook URL 或 bridge。Gitea 的公开 Web UI 是 `https://gitea.pikapython.com`,但 CI、Argo 和 runtime 内部读取必须使用 `gitea-http.devops-infra.svc.cluster.local:3000` 的 ClusterIP URL,避免公网回环和 legacy git-mirror commit 缺失。
AgentRun PaC lane 的 closeout 以 `bun scripts/cli.ts cicd status --node <NODE>``bun scripts/cli.ts platform-infra pipelines-as-code status --target <NODE> --consumer agentrun-<node>-v02` 为首选状态入口。默认输出必须能直接看到 webhook 是否存在、最新 PipelineRun/TaskRun duration、image status、env identity、digest、GitOps commit、Argo revision/health、manager Deployment readiness 和 runtime source/env annotation。env reuse 的通过证据是 `IMAGE_STATUS=reused`、同一 env identity 和稳定 digest;首次 cache miss 可以作为冷启动事实记录,但不能替代后续 env-reuse 秒级收口。若 GitHub PR 已合并但没有新 PipelineRun,应先排查 `platform-infra gitea mirror webhook status|test``platform-infra pipelines-as-code history`,不得改用 `trigger-current`、直接创建 PipelineRun、手工 push Gitea 或裸 `kubectl` 作为正式补触发。
+33 -2
View File
@@ -14,6 +14,33 @@ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
SH
```
When the PK01 provider-gateway channel is offline, operators may use the ignored break-glass SSH source `.env/PK01_ssh.txt`. The file format is two lines: line 1 is the SSH target such as `ubuntu@<host>`, line 2 is the password. Do not print the second line, commit it, copy it into issue comments, or pass it through commands that echo typed input. Prefer an ephemeral `SSH_ASKPASS` wrapper so OpenSSH can handle `keyboard-interactive` or password prompts without exposing the value:
```bash
tmp=$(mktemp -d /tmp/pk01-askpass.XXXXXX)
chmod 700 "$tmp"
cat >"$tmp/askpass.sh" <<'SH'
#!/bin/sh
printf '%s\n' "$PK01_SSH_PASS"
SH
chmod 700 "$tmp/askpass.sh"
PK01_SSH_PASS="$(sed -n '2p' .env/PK01_ssh.txt)" \
SSH_ASKPASS="$tmp/askpass.sh" \
SSH_ASKPASS_REQUIRE=force \
DISPLAY=none \
setsid -w ssh \
-o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=8 \
-o PreferredAuthentications=keyboard-interactive,password,publickey \
"$(sed -n '1p' .env/PK01_ssh.txt)" \
'hostname; date -Is'
rm -rf "$tmp"
```
This fallback is for bounded host diagnostics or node-local recovery only. After restoring `unidesk-provider-gateway-pk01`, return to `trans PK01 ...` and `platform-infra ... --target PK01` as the operating truth.
Before closing an operation, verify both the provider channel and host workload state:
```bash
@@ -36,7 +63,9 @@ PK01 currently uses a direct Docker provider-gateway deployment rather than a fu
| Env file | `/home/ubuntu/.unidesk/state/provider-pk01/provider.env` | Contains provider token and must not be printed, copied into docs, or committed. |
| Host SSH key | `/home/ubuntu/.unidesk/host-ssh-pk01/id_ed25519` | Mounted read-only at `/run/host-ssh`; public key is authorized for `ubuntu`. |
| Logs | `/home/ubuntu/.unidesk/logs/provider-pk01` | Node-local runtime logs, not a Git source of truth. |
| Egress proxy | `127.0.0.1:18789` | Loopback only; never expose as a public endpoint. |
| Host SSH target | `root@host.docker.internal:22` | `trans PK01 ...` is expected to land as root for host/systemd operations. |
| Provider-gateway egress proxy | `127.0.0.1:18789` | Provider maintenance tunnel only; do not bind Sub2API upstream accounts to this endpoint. |
| Host proxy HTTP endpoint | `127.0.0.1:10809` | PK01 host proxy endpoint for Sub2API account proxy binding, declared by `config/platform-infra/host-proxy.yaml#targets.PK01`. |
Long-term provider-gateway upgrades should converge to the standard `provider.upgrade mode=schedule` flow described in `docs/reference/provider-gateway.md`. If PK01 is still on the direct Docker bootstrap path, do not rebuild the gateway synchronously through the gateway's own `trans PK01` session. Use a detached node-local job or first move PK01 to the standard attach/upgrade bundle.
@@ -68,7 +97,9 @@ If one public service fails while other services still work, restore the missing
In this mode, public ports `80` and `443` belong to Caddy. The existing `pikanode` container must be bound to a loopback HTTP port and used only as the apex PikaPython/PikaNode upstream. Public platform routes must be read from their owning YAML. For the current Sub2API PK01 host-Docker target, `api.pikapython.com` uses `publicExposure.mode=pk01-local` and API traffic follows `client -> PK01 Caddy -> 127.0.0.1:<YAML local upstream port> -> PK01 host-Docker Sub2API`. For k3s external-active targets, the route may instead be `client -> PK01 Caddy -> PK01 frps remote port -> target frpc -> target Sub2API`. Neither path may pass through pikanode or a master-server reverse proxy.
Caddy binary installation is also YAML-controlled. If `publicExposure.pk01.caddyDownloadProxyUrl` is set, PK01 Caddy downloads must use that proxy URL; the PK01 loopback provider egress proxy is the preferred source. A slow or failing Caddy download should first be treated as missing proxy use, not as a reason to keep retrying a naked GitHub release download.
Caddy binary installation is also YAML-controlled. If `publicExposure.pk01.caddyDownloadProxyUrl` is set, PK01 Caddy downloads must use that proxy URL. Sub2API account-level proxy binding must use the PK01 host proxy HTTP endpoint, not the provider-gateway egress proxy.
PK01's account-level host proxy is a status-only Hysteria client connected to the NC01-local `../vpn-server` deployment. The durable declaration is `config/platform-infra/host-proxy.yaml#targets.PK01`, and protected/manual Sub2API account bindings should reference it through `config/platform-infra/sub2api-codex-pool.yaml` `manualAccounts.bindingSources.*.hostProxyRef`. Do not replace it with `127.0.0.1:18789`; verify it with `bun scripts/cli.ts platform-infra egress-proxy host status --target PK01` and then validate the Sub2API account/user request path.
The public certificate depends on DNS. The `api.pikapython.com` record must resolve to the YAML-declared PK01 public address before Caddy can complete ACME issuance. If the DNS record is absent or stale, local probes such as PK01 loopback app health checks, loopback Caddy `--resolve` checks, or target-specific FRP remote-port checks can prove parts of the data path, but final `https://api.pikapython.com` validation remains blocked until DNS is corrected.
+1 -1
View File
@@ -15,7 +15,7 @@
- Gitea mirror and Pipelines-as-Code are platform-infra CI source/trigger services operated by UniDesk. Their durable configuration lives in `config/platform-infra/gitea.yaml` and `config/platform-infra/pipelines-as-code.yaml`; do not hide repo URLs, mirror repo names, webhook settings, public exposure, FRP/Caddy ports, token sourceRefs or PaC Repository params in helper constants.
- The canonical Gitea entrypoints are `bun scripts/cli.ts platform-infra gitea plan|apply|status|validate|mirror --target <node>`, `bun scripts/cli.ts platform-infra gitea mirror plan|bootstrap|sync|status --target <node>`, and `bun scripts/cli.ts platform-infra gitea mirror webhook apply|status|test --target <node>`. Mirror bootstrap/sync must repair declared repo/org visibility such as `publicRead: true`; create-time defaults alone are not enough for long-lived repos.
- Gitea mirror webhook sync is GitHub -> Gitea only. GitHub remains the upstream write authority; the webhook bridge may update the YAML-declared Gitea branch and immutable snapshot refs, but it must not write Gitea changes back to GitHub or add a second polling path.
- The Gitea public Web UI and GitHub webhook endpoint may share the YAML-declared HTTPS hostname only through explicit Caddy path routing. The webhook path must route to its own YAML-declared FRP proxy/remote port, while ordinary Web UI traffic continues to route to the Gitea Web UI proxy. Internal k8s consumers still use the ClusterIP service URL, not the public hostname.
- The Gitea public Web UI and GitHub webhook endpoint may share the YAML-declared HTTPS hostname only through explicit Caddy path routing. Each target node that needs GitHub -> Gitea sync must have its own YAML-declared webhook path and FRP proxy/remote port; multiple nodes must not reuse one GitHub hook URL or one bridge. Ordinary Web UI traffic continues to route to the Gitea Web UI proxy. Internal k8s consumers still use the ClusterIP service URL, not the public hostname.
- Applying Gitea public exposure or webhook sync must roll the affected connector workloads after Secret or ConfigMap changes. A successful Secret apply is not sufficient evidence that `frpc` loaded a new proxy; closeout should use `platform-infra gitea apply` output plus `platform-infra gitea mirror webhook status|test` rather than assuming unchanged connector Pods picked up mounted config.
- The canonical PaC entrypoints are `bun scripts/cli.ts platform-infra pipelines-as-code plan|apply|status|history|webhook-test --target <node>`. PaC status is the operator-facing closeout surface for migrated CI lanes and must expose webhook count, latest PipelineRun/TaskRun duration, image status, env identity, digest, GitOps commit, Argo revision and runtime provenance without requiring raw `kubectl`, `tkn` or Gitea UI inspection.
- PaC history is the trigger/timing audit surface for Gitea/PaC-managed lanes. It must query Gitea Repository CR and Tekton PipelineRun/TaskRun live objects on the target node, aggregate there, return Beijing-time display by YAML timezone, expose a detail id for drill-down, and report read errors explicitly; a large namespace or unreadable target object must never be rendered as a successful empty table.
+7 -1
View File
@@ -281,7 +281,7 @@ function agentRunBuildPublishTask(spec: AgentRunLaneSpec): Record<string, unknow
name: "publish-gitops",
image: "$(params.tools-image)",
env: [
{ name: "GITEA_TOKEN", valueFrom: { secretKeyRef: { name: "pac-gitea-agentrun-jd01-v02", key: "token", optional: true } } },
{ name: "GITEA_TOKEN", valueFrom: { secretKeyRef: { name: agentRunPacGiteaSecretName(spec), key: "token", optional: true } } },
],
script: agentRunTektonGitopsPublishScript(spec),
},
@@ -773,6 +773,12 @@ function agentRunLabels(spec: AgentRunLaneSpec): Record<string, string> {
};
}
function agentRunPacGiteaSecretName(spec: AgentRunLaneSpec): string {
const consumerId = spec.ci.pipelineRunPrefix.replace(/-ci$/u, "");
if (consumerId === spec.ci.pipelineRunPrefix) throw new Error(`config/agentrun.yaml controlPlane.lanes.${spec.lane}.ci.pipelineRunPrefix must end with -ci`);
return `pac-gitea-${consumerId}`;
}
function yaml(value: unknown): string {
return `${Bun.YAML.stringify(value).trim()}\n`;
}
@@ -54,13 +54,17 @@ createServer(async (req, res) => {
return;
}
running.add(repo.key);
try {
const sync = syncRepository(repo);
log({ event: "github-to-gitea-sync", repo: repo.key, ok: sync.ok, sourceCommit: sync.sourceCommit, snapshotRef: sync.snapshotRef, error: sync.error ? redact(sync.error) : null, elapsedMs: Date.now() - startedAt, valuesPrinted: false });
writeJson(res, sync.ok ? 200 : 500, { ok: sync.ok, event, repository, ref, repo: repo.key, ...sync, valuesPrinted: false });
} finally {
running.delete(repo.key);
}
writeJson(res, 202, { ok: true, event, repository, ref, repo: repo.key, disposition: "accepted", valuesPrinted: false });
setImmediate(() => {
try {
const sync = syncRepository(repo);
log({ event: "github-to-gitea-sync", repo: repo.key, ok: sync.ok, sourceCommit: sync.sourceCommit, snapshotRef: sync.snapshotRef, error: sync.error ? redact(sync.error) : null, elapsedMs: Date.now() - startedAt, valuesPrinted: false });
} catch (error) {
log({ event: "github-to-gitea-sync-error", repo: repo.key, ok: false, error: redact(String(error?.message || error)), elapsedMs: Date.now() - startedAt, valuesPrinted: false });
} finally {
running.delete(repo.key);
}
});
} catch (error) {
log({ event: "github-to-gitea-sync-error", ok: false, error: String(error?.message || error), valuesPrinted: false });
writeJson(res, 500, { ok: false, error: String(error?.message || error), valuesPrinted: false });
+117 -4
View File
@@ -703,13 +703,28 @@ run_mirror_webhook_status() {
github_api_env
bridge_ready=$(kubectl -n "$UNIDESK_GITEA_NAMESPACE" get deploy "$UNIDESK_GITEA_WEBHOOK_DEPLOYMENT" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0")
service_exists=$(kubectl -n "$UNIDESK_GITEA_NAMESPACE" get service "$UNIDESK_GITEA_WEBHOOK_SERVICE" -o name 2>/dev/null || true)
python3 - "$tmp/repos.json" "$bridge_ready" "$service_exists" <<'PY'
import json, os, sys, urllib.error, urllib.request
run_mirror_status >"$tmp/mirror-status.json" 2>"$tmp/mirror-status.err"
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'
import json, os, sys, urllib.error, urllib.parse, urllib.request
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]
token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"]
url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"]
def text(path, limit=1600):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
def request(api_path):
req = urllib.request.Request("https://api.github.com" + api_path, method="GET")
req.add_header("Authorization", "Bearer " + token)
@@ -720,9 +735,60 @@ def request(api_path):
return {"ok": True, "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]}
def github_head(repository, branch):
result = request(f"/repos/{repository}/git/ref/heads/{urllib.parse.quote(branch, safe='')}")
try:
payload = json.loads(result.get("body") or "{}")
except Exception:
payload = {}
return {"ok": result.get("ok"), "status": result.get("status"), "sha": payload.get("object", {}).get("sha"), "error": None if result.get("ok") else result.get("body", "")[:300]}
def hook_deliveries(repository, hook_id):
if not hook_id:
return {"ok": False, "status": None, "latest": None, "latestPush": None, "error": "hook not found"}
result = request(f"/repos/{repository}/hooks/{hook_id}/deliveries?per_page=8")
try:
rows = json.loads(result.get("body") or "[]")
except Exception:
rows = []
def compact(item):
if not isinstance(item, dict):
return None
return {
"id": item.get("id"),
"event": item.get("event"),
"status": item.get("status"),
"statusCode": item.get("status_code"),
"deliveredAt": item.get("delivered_at"),
"duration": item.get("duration"),
}
latest = compact(rows[0]) if rows else None
latest_push = next((compact(item) for item in rows if isinstance(item, dict) and item.get("event") == "push"), None)
return {"ok": result.get("ok"), "status": result.get("status"), "latest": latest, "latestPush": latest_push, "error": None if result.get("ok") else result.get("body", "")[:300]}
try:
mirror_status = json.load(open(mirror_status_path, encoding="utf-8"))
except Exception:
mirror_status = {}
mirror_by_key = {item.get("key"): item for item in mirror_status.get("repositories", []) if isinstance(item, dict)}
bridge_events = []
for line in text(bridge_log_path, 12000).splitlines():
try:
event = json.loads(line)
except Exception:
continue
if isinstance(event, dict) and str(event.get("event", "")).startswith("github-to-gitea"):
bridge_events.append({
"event": event.get("event"),
"repo": event.get("repo"),
"ok": event.get("ok"),
"sourceCommit": event.get("sourceCommit"),
"snapshotRef": event.get("snapshotRef"),
"error": event.get("error"),
"elapsedMs": event.get("elapsedMs"),
})
rows = []
for repo in repos:
repository = repo["upstream"]["repository"]
branch = repo["upstream"]["branch"]
result = request(f"/repos/{repository}/hooks")
hooks = []
try:
@@ -730,9 +796,56 @@ for repo in repos:
except Exception:
hooks = []
match = next((item for item in hooks if (item.get("config") or {}).get("url") == url), None)
rows.append({"key": repo["key"], "repository": repository, "hookId": match.get("id") if match else None, "hookReady": bool(match and match.get("active") is True), "active": match.get("active") if match else None, "status": result.get("status"), "error": None if result.get("ok") else result.get("body", "")[:500], "valuesPrinted": False})
hook_id = match.get("id") if match else None
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
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 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,
"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": delivery.get("latestPush"),
"lastBridgeEvent": last_bridge_event,
"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}
payload = {"ok": bridge["ready"] and service_exists and all(row["hookReady"] for row in rows), "bridge": bridge, "repositories": rows, "url": url, "valuesPrinted": False}
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),
"bridge": bridge,
"repositories": rows,
"url": url,
"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)},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
+141 -37
View File
@@ -34,6 +34,15 @@ interface GiteaTarget {
createNamespace: boolean;
storageClassName: string;
publicExposureEnabled: boolean;
webhookSync: GiteaTargetWebhookSync | null;
}
interface GiteaTargetWebhookSync {
publicPath: string;
frpc: {
proxyName: string;
remotePort: number;
};
}
interface GiteaConfig {
@@ -576,6 +585,10 @@ function parseTarget(record: Record<string, unknown>, index: number): GiteaTarge
if (publicExposureRaw !== undefined && (typeof publicExposureRaw !== "object" || publicExposureRaw === null || Array.isArray(publicExposureRaw))) {
throw new Error(`${configLabel}.${path}.publicExposure must be an object`);
}
const webhookSyncRaw = record.webhookSync;
if (webhookSyncRaw !== undefined && (typeof webhookSyncRaw !== "object" || webhookSyncRaw === null || Array.isArray(webhookSyncRaw))) {
throw new Error(`${configLabel}.${path}.webhookSync must be an object`);
}
const publicExposure = publicExposureRaw as Record<string, unknown> | undefined;
return {
id: y.stringField(record, "id", path),
@@ -586,6 +599,18 @@ function parseTarget(record: Record<string, unknown>, index: number): GiteaTarge
createNamespace: y.booleanField(record, "createNamespace", path),
storageClassName: y.stringField(record, "storageClassName", path),
publicExposureEnabled: publicExposure === undefined ? true : y.booleanField(publicExposure, "enabled", `${path}.publicExposure`),
webhookSync: webhookSyncRaw === undefined ? null : parseTargetWebhookSync(webhookSyncRaw as Record<string, unknown>, `${path}.webhookSync`),
};
}
function parseTargetWebhookSync(record: Record<string, unknown>, path: string): GiteaTargetWebhookSync {
const frpc = y.objectField(record, "frpc", path);
return {
publicPath: y.apiPathField(record, "publicPath", path),
frpc: {
proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`),
remotePort: y.portField(frpc, "remotePort", `${path}.frpc`),
},
};
}
@@ -605,6 +630,15 @@ function validateConfig(gitea: GiteaConfig): void {
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 webhookPaths = new Set<string>();
const webhookPorts = new Set<number>();
for (const route of webhookCaddyRoutes(gitea)) {
if (webhookPaths.has(route.publicPath)) throw new Error(`${configLabel} webhook publicPath must be unique: ${route.publicPath}`);
if (webhookPorts.has(route.remotePort)) throw new Error(`${configLabel} webhook frpc.remotePort must be unique: ${route.remotePort}`);
if (route.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel} webhook frpc.remotePort must differ from app public exposure port: ${route.remotePort}`);
webhookPaths.add(route.publicPath);
webhookPorts.add(route.remotePort);
}
const repoKeys = new Set<string>();
for (const repo of gitea.sourceAuthority.repositories) {
if (repoKeys.has(repo.key)) throw new Error(`${configLabel}.sourceAuthority.repositories contains duplicate key ${repo.key}`);
@@ -653,7 +687,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun ? ensureMirrorSecrets(gitea, true, true) : undefined;
const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets }));
const parsed = parseJsonOutput(result.stdout);
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && publicExposureEnabled(gitea, target)
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && caddyExposureNeeded(gitea, target)
? await applyGiteaCaddyBlock(config, gitea)
: { ok: true, skipped: true, reason: options.dryRun ? "dry-run" : "apply-not-ready" };
return {
@@ -665,7 +699,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
config: compactConfigSummary(gitea, target),
policy,
publicExposure: publicExposureSummary(gitea),
secrets: { frpc: frpcSecretSummary(frpcSecret), webhookSync: secrets === undefined ? { enabled: false } : webhookSecretSummary(gitea, secrets) },
secrets: { frpc: frpcSecretSummary(frpcSecret), webhookSync: secrets === undefined ? { enabled: false } : webhookSecretSummary(gitea, target, secrets) },
pk01Caddy: caddy,
remote: parsed ?? compactCapture(result, { full: true }),
next: nextCommands(target.id),
@@ -882,7 +916,7 @@ async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions)
action: "platform-infra-gitea-mirror-webhook-apply",
mutation: true,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea),
webhook: webhookSyncSummary(gitea, target),
repositories: arrayRecords(record(parsed).repositories),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
@@ -902,9 +936,11 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions
action: "platform-infra-gitea-mirror-webhook-status",
mutation: false,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea),
webhook: webhookSyncSummary(gitea, target),
repositories,
bridge: record(parsed).bridge,
bridgeLogs: record(parsed).bridgeLogs,
mirrorStatus: record(parsed).mirrorStatus,
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
};
@@ -924,7 +960,7 @@ async function mirrorWebhookTest(config: UniDeskConfig, options: MirrorOptions):
action: "platform-infra-gitea-mirror-webhook-test",
mutation: true,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea),
webhook: webhookSyncSummary(gitea, target),
repositories: arrayRecords(record(parsed).repositories),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorNextCommands(target.id),
@@ -1095,7 +1131,7 @@ ${renderGithubSyncManifest(gitea, target)}`;
}
function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): string {
const sync = gitea.sourceAuthority.webhookSync;
const sync = targetWebhookSync(gitea, target);
if (!sync.enabled) return "";
const labels = ` app.kubernetes.io/name: ${sync.bridge.deploymentName}
app.kubernetes.io/component: github-to-gitea-sync
@@ -1252,6 +1288,8 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
}
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 {
const frpcExposure = targetFrpcExposure(gitea, target);
const sync = targetWebhookSync(gitea, target);
const env: Record<string, string> = {
UNIDESK_GITEA_ACTION: action,
UNIDESK_GITEA_TARGET_ID: target.id,
@@ -1274,16 +1312,16 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
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(","),
UNIDESK_GITEA_FRPC_ENABLED: publicExposureEnabled(gitea, target) ? "1" : "0",
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: gitea.app.publicExposure.frpc.deploymentName,
UNIDESK_GITEA_FRPC_ENABLED: frpcExposure === null ? "0" : "1",
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: frpcExposure?.frpc.deploymentName ?? gitea.app.publicExposure.frpc.deploymentName,
UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED: gitea.sourceAuthority.webhookSync.enabled ? "1" : "0",
UNIDESK_GITEA_WEBHOOK_PUBLIC_URL: githubWebhookPublicUrl(gitea),
UNIDESK_GITEA_WEBHOOK_PATH: gitea.sourceAuthority.webhookSync.publicPath,
UNIDESK_GITEA_WEBHOOK_EVENTS: gitea.sourceAuthority.webhookSync.events.join(","),
UNIDESK_GITEA_WEBHOOK_DEPLOYMENT: gitea.sourceAuthority.webhookSync.bridge.deploymentName,
UNIDESK_GITEA_WEBHOOK_SERVICE: gitea.sourceAuthority.webhookSync.bridge.serviceName,
UNIDESK_GITEA_WEBHOOK_SERVICE_PORT: String(gitea.sourceAuthority.webhookSync.bridge.httpPort),
UNIDESK_GITEA_WEBHOOK_SECRET_NAME: gitea.sourceAuthority.webhookSync.bridge.secretName,
UNIDESK_GITEA_WEBHOOK_PUBLIC_URL: githubWebhookPublicUrl(gitea, target),
UNIDESK_GITEA_WEBHOOK_PATH: sync.publicPath,
UNIDESK_GITEA_WEBHOOK_EVENTS: sync.events.join(","),
UNIDESK_GITEA_WEBHOOK_DEPLOYMENT: sync.bridge.deploymentName,
UNIDESK_GITEA_WEBHOOK_SERVICE: sync.bridge.serviceName,
UNIDESK_GITEA_WEBHOOK_SERVICE_PORT: String(sync.bridge.httpPort),
UNIDESK_GITEA_WEBHOOK_SECRET_NAME: sync.bridge.secretName,
};
if (params.frpcSecret !== undefined) {
env.UNIDESK_GITEA_FRPC_SECRET_NAME = params.frpcSecret.secretName;
@@ -1347,6 +1385,7 @@ function targetSummary(target: GiteaTarget): Record<string, unknown> {
createNamespace: target.createNamespace,
storageClassName: target.storageClassName,
publicExposureEnabled: target.publicExposureEnabled,
webhookSync: target.webhookSync,
};
}
@@ -1382,21 +1421,23 @@ function publicExposureEnabled(gitea: GiteaConfig, target: GiteaTarget): boolean
}
function targetPublicExposure(gitea: GiteaConfig, target: GiteaTarget): GiteaConfig["app"]["publicExposure"] {
return publicExposureEnabled(gitea, target) ? gitea.app.publicExposure : { ...gitea.app.publicExposure, enabled: false };
const frpcExposure = targetFrpcExposure(gitea, target);
return frpcExposure === null ? { ...gitea.app.publicExposure, enabled: false } : frpcExposure;
}
function prepareGiteaFrpcSecret(gitea: GiteaConfig, target: GiteaTarget): FrpcSecretMaterial | undefined {
if (!publicExposureEnabled(gitea, target)) return undefined;
const exposure = targetFrpcExposure(gitea, target);
if (exposure === null) return undefined;
const base = prepareFrpcSecret({
secretRoot: gitea.app.publicExposure.secretRoot,
exposure: gitea.app.publicExposure,
exposure,
sourcePathRedactor: redactRepoPath,
parseEnvFile,
requiredEnvValue,
readTextFile,
});
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return base;
const sync = targetWebhookSync(gitea, target);
if (!sync.enabled || !publicExposureEnabled(gitea, target)) return base;
const extraProxy = [
"[[proxies]]",
`name = "${escapeTomlString(sync.frpc.proxyName)}"`,
@@ -1414,18 +1455,51 @@ function prepareGiteaFrpcSecret(gitea: GiteaConfig, target: GiteaTarget): FrpcSe
};
}
function targetFrpcExposure(gitea: GiteaConfig, target: GiteaTarget): GiteaConfig["app"]["publicExposure"] | null {
if (publicExposureEnabled(gitea, target)) return gitea.app.publicExposure;
if (target.webhookSync === null || !gitea.sourceAuthority.webhookSync.enabled) return null;
const sync = targetWebhookSync(gitea, target);
return {
...gitea.app.publicExposure,
enabled: true,
frpc: {
...gitea.app.publicExposure.frpc,
proxyName: sync.frpc.proxyName,
remotePort: sync.frpc.remotePort,
localIP: `${sync.bridge.serviceName}.${target.namespace}.svc.cluster.local`,
localPort: sync.bridge.httpPort,
},
};
}
function targetWebhookSync(gitea: GiteaConfig, target: GiteaTarget): GiteaWebhookSync {
const base = gitea.sourceAuthority.webhookSync;
if (target.webhookSync === null) return base;
return {
...base,
publicPath: target.webhookSync.publicPath,
frpc: target.webhookSync.frpc,
};
}
function caddyExposureNeeded(gitea: GiteaConfig, target: GiteaTarget): boolean {
return publicExposureEnabled(gitea, target) || (gitea.sourceAuthority.webhookSync.enabled && target.webhookSync !== null);
}
async function applyGiteaCaddyBlock(config: UniDeskConfig, gitea: GiteaConfig): Promise<Record<string, unknown>> {
const exposure = gitea.app.publicExposure;
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return await applyPk01CaddyBlock(config, "gitea", exposure);
const siteBlock = `${exposure.dns.hostname} {
handle ${sync.publicPath}* {
reverse_proxy 127.0.0.1:${sync.frpc.remotePort} {
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");
if (webhookHandles === "") return await applyPk01CaddyBlock(config, "gitea", exposure);
const siteBlock = `${exposure.dns.hostname} {
${webhookHandles}
handle {
reverse_proxy 127.0.0.1:${exposure.frpc.remotePort} {
@@ -1438,6 +1512,19 @@ async function applyGiteaCaddyBlock(config: UniDeskConfig, gitea: GiteaConfig):
return await applyPk01CaddySiteBlock(config, "gitea", exposure, siteBlock, caddyManagedBlockMarkers("gitea"));
}
function webhookCaddyRoutes(gitea: GiteaConfig): Array<{ publicPath: string; remotePort: number }> {
const sync = gitea.sourceAuthority.webhookSync;
if (!sync.enabled) return [];
const routes = new Map<string, { publicPath: string; remotePort: number }>();
routes.set(sync.publicPath, { publicPath: sync.publicPath, remotePort: sync.frpc.remotePort });
for (const target of gitea.targets) {
if (!target.enabled || target.webhookSync === null) continue;
const targetSync = targetWebhookSync(gitea, target);
routes.set(targetSync.publicPath, { publicPath: targetSync.publicPath, remotePort: targetSync.frpc.remotePort });
}
return [...routes.values()];
}
function publicExposureSummary(gitea: GiteaConfig): Record<string, unknown> {
const exposure = gitea.app.publicExposure;
return {
@@ -1469,11 +1556,11 @@ function frpcSecretSummary(secret: FrpcSecretMaterial | undefined): Record<strin
};
}
function webhookSecretSummary(gitea: GiteaConfig, secrets: MirrorSecrets): Record<string, unknown> {
const sync = gitea.sourceAuthority.webhookSync;
function webhookSecretSummary(gitea: GiteaConfig, target: GiteaTarget, secrets: MirrorSecrets): Record<string, unknown> {
const sync = targetWebhookSync(gitea, target);
return {
enabled: sync.enabled,
publicUrl: githubWebhookPublicUrl(gitea),
publicUrl: githubWebhookPublicUrl(gitea, target),
sourceRef: sync.secret.sourceRef,
targetSecret: sync.bridge.secretName,
keys: ["github-token", "gitea-username", "gitea-password", "github-webhook-secret"],
@@ -1482,9 +1569,9 @@ function webhookSecretSummary(gitea: GiteaConfig, secrets: MirrorSecrets): Recor
};
}
function githubWebhookPublicUrl(gitea: GiteaConfig): string {
function githubWebhookPublicUrl(gitea: GiteaConfig, target: GiteaTarget): string {
const base = gitea.app.publicExposure.publicBaseUrl.replace(/\/+$/u, "");
return `${base}${gitea.sourceAuthority.webhookSync.publicPath}`;
return `${base}${targetWebhookSync(gitea, target).publicPath}`;
}
function statusSummary(payload: Record<string, unknown>): Record<string, unknown> {
@@ -1573,12 +1660,12 @@ function mirrorNextCommands(targetId: string): Record<string, string> {
};
}
function webhookSyncSummary(gitea: GiteaConfig): Record<string, unknown> {
const sync = gitea.sourceAuthority.webhookSync;
function webhookSyncSummary(gitea: GiteaConfig, target: GiteaTarget): Record<string, unknown> {
const sync = targetWebhookSync(gitea, target);
return {
enabled: sync.enabled,
direction: sync.direction,
publicUrl: githubWebhookPublicUrl(gitea),
publicUrl: githubWebhookPublicUrl(gitea, target),
events: sync.events,
bridge: sync.bridge,
frpc: sync.frpc,
@@ -1836,15 +1923,32 @@ function renderMirrorWebhookApply(result: Record<string, unknown>): RenderedCliR
}
function renderMirrorWebhookStatus(result: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.repository), boolText(repo.hookReady), stringValue(repo.hookId), stringValue(repo.active), compactTail(stringValue(repo.error))]);
const repos = arrayRecords(result.repositories).map((repo) => {
const latestPush = record(repo.latestPushDelivery);
const latest = record(repo.latestDelivery);
const delivery = latestPush.event !== "" ? latestPush : latest;
const bridge = record(repo.lastBridgeEvent);
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),
`${stringValue(delivery.event)}:${stringValue(delivery.statusCode)}:${stringValue(delivery.status)}`,
`${stringValue(bridge.event)}:${boolText(bridge.ok)}:${stringValue(bridge.sourceCommit).slice(0, 12)}`,
compactTail(stringValue(repo.error)),
];
});
const bridge = record(result.bridge);
const bridgeLogs = record(result.bridgeLogs);
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook status", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS",
...table(["TARGET", "BRIDGE", "READY", "URL"], [[stringValue(record(result.target).id), stringValue(bridge.deployment), boolText(bridge.ready), stringValue(record(result.webhook).publicUrl)]]),
...table(["TARGET", "BRIDGE", "READY", "URL", "LOGS"], [[stringValue(record(result.target).id), stringValue(bridge.deployment), boolText(bridge.ready), stringValue(record(result.webhook).publicUrl), `rc=${stringValue(bridgeLogs.exitCode)}`]]),
"",
"GITHUB HOOKS",
...(repos.length === 0 ? ["-"] : table(["KEY", "REPOSITORY", "READY", "HOOK_ID", "ACTIVE", "ERROR"], repos)),
...(repos.length === 0 ? ["-"] : table(["KEY", "HOOK", "GH_HEAD", "GITEA", "SNAPSHOT", "MATCH", "DELIVERY", "BRIDGE_EVENT", "ERROR"], repos)),
...remoteErrorLines(result),
"",
`NEXT ${stringValue(next.webhookApply)}`,
+223 -23
View File
@@ -43,7 +43,7 @@ interface HostProxyServer {
interface HostProxySource {
id: string;
sourceType: "benchmark-validated-master-shadowsocks" | "vpn-server-shadowsocks";
sourceType: "benchmark-validated-master-shadowsocks" | "vpn-server-shadowsocks" | "vpn-server-hysteria-existing";
serverRef: string;
benchmarkRef: string;
implementationRef: string;
@@ -51,14 +51,17 @@ interface HostProxySource {
sourceRef: string;
sourceKey: string;
sourceFingerprint: string;
masterShadowsocks: MasterShadowsocksSourceSpec;
masterShadowsocks: MasterShadowsocksSourceSpec | null;
client: HostProxyClient;
proxyUrl: string;
externalProbeUrl: string;
expectedServer: string | null;
fingerprint: string;
}
interface HostProxyClient {
type HostProxyClient = HostProxyManagedClient | HostProxyExistingHysteriaClient;
interface HostProxyManagedClient {
mode: "trans-static-binary";
upstreamUrl: string;
version: string;
@@ -79,6 +82,16 @@ interface HostProxyClient {
healthUrl: string;
}
interface HostProxyExistingHysteriaClient {
mode: "external-existing-hysteria";
configPath: string;
serviceName: string;
httpListenHost: string;
httpListenPort: number;
socks5ListenHost: string;
socks5ListenPort: number;
}
interface HostProxyPodAccess {
enabled: boolean;
listenHost: string;
@@ -136,14 +149,11 @@ export async function runPlatformInfraHostProxyCommand(_config: UniDeskConfig, a
if (options.action === "plan") return renderPlan(plan(config.server, target));
if (options.action === "status") return renderStatus(status(config.server, target));
if (!options.confirm || options.dryRun) {
const dryRunPlan = plan(config.server, target);
return renderPlan({
...plan(config.server, target),
...dryRunPlan,
mode: "dry-run",
mutation: false,
next: {
apply: `bun scripts/cli.ts platform-infra egress-proxy host apply --target ${target.id} --confirm`,
status: `bun scripts/cli.ts platform-infra egress-proxy host status --target ${target.id}`,
},
});
}
return renderApply(apply(config.server, target));
@@ -233,13 +243,14 @@ function parseServer(raw: Record<string, unknown>, label: string): HostProxyServ
}
function parseSource(id: string, raw: Record<string, unknown>): HostProxySource {
const sourceType = enumField(raw, "sourceType", `${configPath}.sources.${id}`, ["benchmark-validated-master-shadowsocks", "vpn-server-shadowsocks"] as const);
const sourceType = enumField(raw, "sourceType", `${configPath}.sources.${id}`, ["benchmark-validated-master-shadowsocks", "vpn-server-shadowsocks", "vpn-server-hysteria-existing"] as const);
if (sourceType === "vpn-server-hysteria-existing") return parseExistingHysteriaSource(id, raw, sourceType);
const sourceConfigRef = stringField(raw, "sourceConfigRef", `${configPath}.sources.${id}`);
const resolved = resolveEgressProxySourceRef(sourceConfigRef, `${configPath}.sources.${id}.sourceConfigRef`);
if (resolved.sourceType !== "master-shadowsocks" || resolved.masterShadowsocks === null) {
throw new Error(`${configPath}.sources.${id}.sourceConfigRef must reference a master-shadowsocks source`);
}
const client = clientSpec(record(raw.client, `${configPath}.sources.${id}.client`), `${configPath}.sources.${id}.client`);
const client = managedClientSpec(record(raw.client, `${configPath}.sources.${id}.client`), `${configPath}.sources.${id}.client`);
const proxyUrl = urlField(raw, "proxyUrl", `${configPath}.sources.${id}`);
const expectedProxyUrl = `http://${client.listenHost}:${client.listenPort}`;
if (proxyUrl !== expectedProxyUrl) throw new Error(`${configPath}.sources.${id}.proxyUrl must be ${expectedProxyUrl}`);
@@ -257,6 +268,7 @@ function parseSource(id: string, raw: Record<string, unknown>): HostProxySource
client,
proxyUrl,
externalProbeUrl: urlField(raw, "externalProbeUrl", `${configPath}.sources.${id}`),
expectedServer: null,
fingerprint: "",
};
return {
@@ -274,7 +286,46 @@ function parseSource(id: string, raw: Record<string, unknown>): HostProxySource
};
}
function clientSpec(raw: Record<string, unknown>, label: string): HostProxyClient {
function parseExistingHysteriaSource(id: string, raw: Record<string, unknown>, sourceType: HostProxySource["sourceType"]): HostProxySource {
const client = existingHysteriaClientSpec(record(raw.client, `${configPath}.sources.${id}.client`), `${configPath}.sources.${id}.client`);
const proxyUrl = urlField(raw, "proxyUrl", `${configPath}.sources.${id}`);
const expectedProxyUrl = `http://${client.httpListenHost}:${client.httpListenPort}`;
if (proxyUrl !== expectedProxyUrl) throw new Error(`${configPath}.sources.${id}.proxyUrl must be ${expectedProxyUrl}`);
const sourceConfigRef = stringField(raw, "sourceConfigRef", `${configPath}.sources.${id}`);
const expectedServer = listenAddressField(raw, "expectedServer", `${configPath}.sources.${id}`);
const source = {
id,
sourceType,
serverRef: stringField(raw, "serverRef", `${configPath}.sources.${id}`),
benchmarkRef: stringField(raw, "benchmarkRef", `${configPath}.sources.${id}`),
implementationRef: stringField(raw, "implementationRef", `${configPath}.sources.${id}`),
sourceConfigRef,
sourceRef: sourceConfigRef,
sourceKey: "",
sourceFingerprint: fingerprint({ sourceConfigRef, expectedServer }),
masterShadowsocks: null,
client,
proxyUrl,
externalProbeUrl: urlField(raw, "externalProbeUrl", `${configPath}.sources.${id}`),
expectedServer,
fingerprint: "",
};
return {
...source,
fingerprint: fingerprint({
sourceType,
benchmarkRef: source.benchmarkRef,
implementationRef: source.implementationRef,
sourceConfigRef,
expectedServer,
client,
proxyUrl,
externalProbeUrl: source.externalProbeUrl,
}),
};
}
function managedClientSpec(raw: Record<string, unknown>, label: string): HostProxyManagedClient {
const mode = enumField(raw, "mode", label, ["trans-static-binary"] as const);
const podAccess = raw.podAccess === undefined ? null : podAccessSpec(record(raw.podAccess, `${label}.podAccess`), `${label}.podAccess`);
const client = {
@@ -302,6 +353,22 @@ function clientSpec(raw: Record<string, unknown>, label: string): HostProxyClien
return client;
}
function existingHysteriaClientSpec(raw: Record<string, unknown>, label: string): HostProxyExistingHysteriaClient {
const mode = enumField(raw, "mode", label, ["external-existing-hysteria"] as const);
const client = {
mode,
configPath: absolutePathField(raw, "configPath", label),
serviceName: stringField(raw, "serviceName", label),
httpListenHost: hostField(raw, "httpListenHost", label),
httpListenPort: portField(raw, "httpListenPort", label),
socks5ListenHost: hostField(raw, "socks5ListenHost", label),
socks5ListenPort: portField(raw, "socks5ListenPort", label),
};
if (client.httpListenHost !== "127.0.0.1") throw new Error(`${label}.httpListenHost must stay 127.0.0.1 for host-local HTTP proxy`);
if (client.socks5ListenHost !== "127.0.0.1") throw new Error(`${label}.socks5ListenHost must stay 127.0.0.1 for host-local SOCKS5 proxy`);
return client;
}
function podAccessSpec(raw: Record<string, unknown>, label: string): HostProxyPodAccess {
const enabled = booleanField(raw, "enabled", label);
const listenHost = hostField(raw, "listenHost", label);
@@ -394,6 +461,7 @@ function resolveTarget(config: HostProxyConfig, targetId: string): HostProxyTarg
function plan(server: HostProxyServer, target: HostProxyTarget): Record<string, unknown> {
const client = target.source.client;
const applyEnabled = client.mode === "trans-static-binary";
return {
ok: true,
command: "platform-infra egress-proxy host plan",
@@ -407,14 +475,17 @@ function plan(server: HostProxyServer, target: HostProxyTarget): Record<string,
env: { proxyUrl: target.env.httpProxy, noProxyCount: target.env.noProxy.length, noProxyIncludesHyueapi: true },
valuesPrinted: false,
next: {
dryRun: `bun scripts/cli.ts platform-infra egress-proxy host apply --target ${target.id} --dry-run`,
apply: `bun scripts/cli.ts platform-infra egress-proxy host apply --target ${target.id} --confirm`,
dryRun: applyEnabled ? `bun scripts/cli.ts platform-infra egress-proxy host apply --target ${target.id} --dry-run` : "disabled: existing external host proxy client",
apply: applyEnabled ? `bun scripts/cli.ts platform-infra egress-proxy host apply --target ${target.id} --confirm` : "disabled: existing external host proxy client",
status: `bun scripts/cli.ts platform-infra egress-proxy host status --target ${target.id}`,
},
};
}
function apply(server: HostProxyServer, target: HostProxyTarget): Record<string, unknown> {
if (target.source.client.mode !== "trans-static-binary") {
throw new Error(`${configPath}.targets.${target.id} uses ${target.source.client.mode}; host apply is disabled for existing external clients. Use status to verify the declared runtime.`);
}
const material = proxySecretMaterial(server, target.source);
const serverApply = sourceUsesManagedServer(target.source) ? applyMasterProxyServer(server, material) : skippedMasterProxyServer(target.source, "apply");
const artifact = prepareClientArtifact(target.source.client);
@@ -495,13 +566,14 @@ function skippedMasterProxyServer(source: HostProxySource, mode: "apply" | "stat
existingHealthy: null,
inspect: null,
container: "external",
listen: `${source.masterShadowsocks.serverHost}:${source.masterShadowsocks.serverPort}`,
listen: source.masterShadowsocks === null ? source.expectedServer ?? source.proxyUrl : `${source.masterShadowsocks.serverHost}:${source.masterShadowsocks.serverPort}`,
health: { ok: true, mode: "external", host: "", port: 0 },
valuesPrinted: false,
};
}
function proxySecretMaterial(server: HostProxyServer, source: HostProxySource): HostProxySecretMaterial {
if (source.masterShadowsocks === null) throw new Error(`${configPath}.sources.${source.id} does not render managed shadowsocks secret material`);
const envSource = readEnvSourceFile({
root: rootPath(".state", "secrets"),
sourceRef: source.sourceRef,
@@ -531,6 +603,9 @@ function renderMasterShadowsocksServerConfig(server: HostProxyServer, password:
}
function renderSingBoxClientConfig(source: HostProxySource, password: string): string {
if (source.masterShadowsocks === null || source.client.mode !== "trans-static-binary") {
throw new Error(`${configPath}.sources.${source.id} cannot render sing-box client config`);
}
const client = source.client;
const inbounds = [
{ type: "mixed", tag: "mixed-host-local", listen: client.listenHost, listen_port: client.listenPort },
@@ -741,6 +816,7 @@ ${remoteStatusProbeShell(target)}
function remoteStatusProbeShell(target: HostProxyTarget): string {
const client = target.source.client;
if (client.mode === "external-existing-hysteria") return remoteExistingHysteriaStatusProbeShell(target, client);
const podAccess = client.podAccess;
const podAccessShell = podAccess?.enabled
? `
@@ -811,6 +887,101 @@ PY
`;
}
function remoteExistingHysteriaStatusProbeShell(target: HostProxyTarget, client: HostProxyExistingHysteriaClient): string {
return `
set -eu
config_present=missing
[ -f ${shQuote(client.configPath)} ] && config_present=present
service_active="$(systemctl is-active ${shQuote(client.serviceName)} 2>/dev/null || true)"
http_rc=0
http_metrics="$(curl -sS -o /dev/null -w '%{http_code} %{time_connect} %{time_pretransfer} %{time_starttransfer} %{time_total}' --max-time 20 -x ${shQuote(target.env.httpProxy)} ${shQuote(target.source.externalProbeUrl)} 2>/tmp/unidesk-host-proxy-hysteria-http.err)" || http_rc=$?
socks_rc=0
socks_metrics="$(curl -sS -o /dev/null -w '%{http_code} %{time_connect} %{time_pretransfer} %{time_starttransfer} %{time_total}' --max-time 20 --socks5-hostname ${shQuote(`${client.socks5ListenHost}:${client.socks5ListenPort}`)} ${shQuote(target.source.externalProbeUrl)} 2>/tmp/unidesk-host-proxy-hysteria-socks.err)" || socks_rc=$?
python3 - "$config_present" "$service_active" "$http_rc" "$http_metrics" "$socks_rc" "$socks_metrics" <<'PY'
import json
import re
import sys
from pathlib import Path
config_path = "${client.configPath}"
expected_server = "${target.source.expectedServer ?? ""}"
expected_http = "${client.httpListenHost}:${client.httpListenPort}"
expected_socks = "${client.socks5ListenHost}:${client.socks5ListenPort}"
def parse_metrics(raw):
parts = str(raw or "").split()
if len(parts) != 5:
return {"status": "", "timeConnect": "", "timePretransfer": "", "timeStarttransfer": "", "timeTotal": ""}
return {
"status": parts[0],
"timeConnect": parts[1],
"timePretransfer": parts[2],
"timeStarttransfer": parts[3],
"timeTotal": parts[4],
}
def section_listen(text, section):
match = re.search(r"(?ms)^" + re.escape(section) + r":\\s*\\n(?:[ \\t]+.*\\n)*?[ \\t]+listen:\\s*([^\\s#]+)", text)
return match.group(1).strip() if match else ""
text = Path(config_path).read_text(encoding="utf-8") if Path(config_path).exists() else ""
server_match = re.search(r"(?m)^server:\\s*([^\\s#]+)", text)
server = server_match.group(1).strip() if server_match else ""
server_host_port = re.sub(r"^[^@]*@", "", server)
http_listen = section_listen(text, "http")
socks_listen = section_listen(text, "socks5")
http = parse_metrics(sys.argv[4])
socks = parse_metrics(sys.argv[6])
payload = {
"ok": (
sys.argv[1] == "present"
and sys.argv[2] == "active"
and server_host_port == expected_server
and http_listen == expected_http
and socks_listen == expected_socks
and int(sys.argv[3]) == 0
and http["status"] == "204"
and int(sys.argv[5]) == 0
and socks["status"] == "204"
),
"target": "${target.id}",
"route": "${target.route}",
"sourceRef": "${target.sourceRef}",
"sourceFingerprint": "${target.source.fingerprint}",
"client": {
"mode": "${client.mode}",
"service": "${client.serviceName}",
"serviceActive": sys.argv[2],
"configPath": config_path,
"configPresent": sys.argv[1],
"configMatches": server_host_port == expected_server and http_listen == expected_http and socks_listen == expected_socks,
"server": server_host_port,
"expectedServer": expected_server,
"httpListen": http_listen,
"socks5Listen": socks_listen,
"listen": expected_http,
"podAccess": {"enabled": False, "listen": "", "proxyUrl": ""},
},
"files": {
"envFile": "status-only",
"profile": "status-only",
"apt": "status-only",
"dockerSystemdDropIn": "status-only",
"k3sSystemdDropIn": "status-only",
},
"noProxy": {"hyueapi": True, "wildcardHyueapi": True},
"health": {"exitCode": int(sys.argv[3]), "url": "${target.source.externalProbeUrl}", "mode": "http-proxy"},
"externalProbe": {"exitCode": int(sys.argv[3]), **http, "url": "${target.source.externalProbeUrl}", "proxyUrl": "${target.env.httpProxy}"},
"socks5Probe": {"exitCode": int(sys.argv[5]), **socks, "url": "${target.source.externalProbeUrl}", "proxyUrl": "socks5h://${client.socks5ListenHost}:${client.socks5ListenPort}"},
"podAccessProbe": {"enabled": False, "exitCode": 0, "status": "skipped", "url": "${target.source.externalProbeUrl}"},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
function renderClientUnit(client: HostProxyClient): string {
return [
"[Unit]",
@@ -900,15 +1071,7 @@ function targetSummary(target: HostProxyTarget): Record<string, unknown> {
proxyUrl: target.source.proxyUrl,
benchmarkRef: target.source.benchmarkRef,
implementationRef: target.source.implementationRef,
client: {
mode: client.mode,
version: client.version,
installPath: client.installPath,
configPath: client.configPath,
unitPath: client.unitPath,
serviceName: client.serviceName,
listen: `${client.listenHost}:${client.listenPort}`,
},
client: clientSummary(client),
trans: {
hostProxyEnv: transHostProxyEnvSummary(target.id),
},
@@ -916,6 +1079,19 @@ function targetSummary(target: HostProxyTarget): Record<string, unknown> {
}
function artifactSummary(client: HostProxyClient): Record<string, unknown> {
if (client.mode === "external-existing-hysteria") {
return {
mode: client.mode,
version: "existing",
upstreamUrl: "external",
archiveCachePath: "external",
binaryCachePath: "external",
archiveSha256: "external",
archiveInstallPath: "external",
binarySha256: "external",
installPath: client.configPath,
};
}
return {
mode: client.mode,
version: client.version,
@@ -929,6 +1105,30 @@ function artifactSummary(client: HostProxyClient): Record<string, unknown> {
};
}
function clientSummary(client: HostProxyClient): Record<string, unknown> {
if (client.mode === "external-existing-hysteria") {
return {
mode: client.mode,
version: "existing",
installPath: "external",
configPath: client.configPath,
unitPath: "external",
serviceName: client.serviceName,
listen: `${client.httpListenHost}:${client.httpListenPort}`,
socks5Listen: `${client.socks5ListenHost}:${client.socks5ListenPort}`,
};
}
return {
mode: client.mode,
version: client.version,
installPath: client.installPath,
configPath: client.configPath,
unitPath: client.unitPath,
serviceName: client.serviceName,
listen: `${client.listenHost}:${client.listenPort}`,
};
}
function masterProxyTcpHealth(server: HostProxyServer): Record<string, unknown> {
const result = runCommand(["bash", "-lc", `timeout 5 bash -lc ${shQuote(`</dev/tcp/${server.health.host}/${server.health.port}`)}`], rootPath(), { timeoutMs: 8_000 });
return { ok: result.exitCode === 0, mode: server.health.mode, host: server.health.host, port: server.health.port, result: compactLocalResult(result) };
@@ -285,6 +285,7 @@ export function readManualBindingSources(value: unknown, key: string): CodexPool
kind: readManualBindingKind(rawSource.kind, `${path}.kind`),
provider: readManualBindingProvider(rawSource.provider, `${path}.provider`),
description: readManualAccountReason(rawSource.description, `${path}.description`),
hostProxyRef: readManualBindingHostProxyRef(rawSource.hostProxyRef, `${path}.hostProxyRef`),
fixedProxy: null,
};
if (source.kind === "proxy" && source.provider === "fixed-http-proxy") {
@@ -359,6 +360,16 @@ export function readManualBindingProvider(value: unknown, key: string): CodexPoo
return text;
}
export function readManualBindingHostProxyRef(value: unknown, key: string): string | null {
if (value === undefined || value === null) return null;
const text = stringValue(value)?.trim() ?? "";
if (text.length === 0) return null;
if (!/^config\/platform-infra\/host-proxy\.yaml#targets\.[A-Za-z0-9_-]+$/u.test(text)) {
throw new Error(`${codexPoolConfigPath}.${key} must point at config/platform-infra/host-proxy.yaml#targets.<id>`);
}
return text;
}
export function validateManualBindingSourceId(value: string, key: string): void {
if (!/^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/u.test(value)) throw new Error(`${codexPoolConfigPath}.${key} has an unsupported source id format`);
}
@@ -169,6 +169,7 @@ export function manualBindingSourcePlan(source: CodexPoolManualBindingSource): R
kind: source.kind,
provider: source.provider,
description: source.description,
hostProxyRef: source.hostProxyRef,
fixedProxy: source.fixedProxy,
};
}
@@ -209,6 +209,7 @@ export interface CodexPoolManualBindingSource {
kind: CodexPoolManualBindingKind;
provider: CodexPoolManualBindingProvider;
description: string | null;
hostProxyRef: string | null;
fixedProxy: CodexPoolManualFixedProxyConfig | null;
}