Merge pull request #1593 from pikasTech/fix/nc01-target-gitea-webhook
修复 NC01 Gitea PaC webhook 触发链路
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 体积与分流
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user