fix(cicd): 收敛 public-edge 单一写入权

This commit is contained in:
pikastech
2026-07-18 17:59:04 +02:00
parent 6429f49823
commit 866afde16b
15 changed files with 987 additions and 179 deletions
+35 -6
View File
@@ -130,6 +130,41 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help
- 已退役 K8s branch-follower 的只读状态、事件、日志和历史边界: [references/branch-follower.md](references/branch-follower.md)。
- 生产事故优先回退、consumer 私有配置边界和最短恢复路径: [references/incident-recovery.md](references/incident-recovery.md)。
## NC01 Public Edge 单一 Authority
- `config/platform-infra/public-edge.yaml` 同时拥有共享边缘聚合配置与 delivery authority
- 普通业务、L1、领域 CLI、任务 worktree 和人工会话只能修改 owning YAML
- 公开入口只允许 `plan``apply --dry-run``status`
- `apply --confirm` 必须 fail closed,禁止以紧急修复、验收或补链为由恢复。
- 唯一 mutation 链固定为:
- GitHub `master` merge
- Gitea immutable snapshot
- `platform-infra-gitea-nc01` PaC 独立 checkout
- YAML SecretRef 控制的 `trans`
- NC01 host-local archive helper
- `platform-infra public-edge reconcile`
- reconcile 必须同时满足:
- source checkout、事件 commit 与 archive marker 完全一致;
- 绝对外部 `configRef` 由 owning YAML pin 住 40 位 Git commit
renderer 只用 `git show` 读取该对象,禁止读取外部 working tree
- candidate ancestry 包含 installed source commit
- installed authority 与 YAML `authorityId` 一致;
- host-local lock 已独占;
- managed block、Compose 与 Caddy候选已验证。
- 乱序自动交付处理:
- installed commit 与 candidate 相同则幂等返回;
- installed commit 不是 candidate 祖先时,以 `stale-or-diverged-source-commit` 拒绝;
- 禁止旧 PipelineRun、分叉提交或人工补跑覆盖较新的运行面。
- 共享 Caddy 更新必须使用 owner managed block
- public-edge controller只替换 `public-edge-*` blocks
- 非 public-edge managed block 必须保留;
- 领域服务不得生成或安装完整 Caddyfile。
- `status` 必须披露 redacted provenance
- installed/desired source commit
- authority、managed fingerprint、Compose fingerprint 与 site count
- legacy container 是否已退役;
- 不披露 Secret、token 或 archive payload。
## P0 边界
- 未经用户明确授权,不得修改 CI/CD 公共服务面:
@@ -148,12 +183,6 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help
- 回退也必须通过最小单一职责 PR 和正常自动事件交付,禁止用直接 Gitea push、人工 PipelineRun、Argo sync 或运行面热补伪造恢复;
- 只有回退后的正常事件已经证明自动链恢复,才允许从健康基线重新设计前向修复;前向修复必须拆成可独立验证、可独立回退的最小变更;
- 回退存在不可逆 schema、数据迁移或外部状态风险时,先停止继续发布并保全证据,再选择已有受控恢复路径;不得在没有恢复边界的情况下继续叠加代码修复。
- 生产事故案例:PaC 工作目录删除导致自动链中断:
- 致因提交 `1b423f2b` 把 public-edge Tekton 后续步骤留在已删除的 `/workspace/source` 工作目录,导致多个自动 PipelineRun 在进入实际业务逻辑前统一报 `getcwd()` 失败;
- 正确处置是从共同失败指纹回溯到该提交,通过 UniDesk PR `#2224` 回退并恢复最后健康实现,再观察回退 PR 产生的新 webhook、PaC、Tekton、GitOps 和 Argo 正常事件;
- 事故处理中曾在故障基线上推进共享 Repository 多分支改造,`#2223``#2226` 的不完整实现分别通过 `#2225``#2228` 回退;误合并的无关 PR `#2227` 通过 `#2229` 回退,不得让事故恢复夹带无关语义;
- 基线恢复后,再通过独立 PR `#2230``#2231``#2233` 完整实现共享 Repository、多分支参数、release Argo 自动同步和资源镜像 ref,并分别通过正常自动事件与 release canary 验收;
- 本案例的固定结论是:生产自动链故障的第一目标是缩小变更面并恢复已知健康状态,不是让当前失败变更通过更多补丁勉强工作。
- PaC 公共 status renderer 故障必须先区分交付失败与观察失败:
- 先用 PipelineRun、Argo 与 runtime 独立事实确认自动交付是否成功,不得把 `parse-failure` 或 status timeout 直接表述为交付链失败;
- 引用缺失变量导致非法 JSON 时,优先从最后已知正确实现恢复该赋值,不得整体回退并撤销仍然正确的 fail-closed 业务语义;
+6
View File
@@ -60,6 +60,12 @@ description: >-
- IP、port、bind/probe 地址和临时 URL 不得作为 L1 用户入口返回;
- 固定 HTTPS origin 缺失时必须明确失败,不得回退到 IP、localhost、端口拼接或代码默认值;
- 内部端口冲突时通过 owning YAML 退让到空闲端口,固定 HTTPS origin 保持不变。
- L1 使用共享 public-edge 时:
- 只允许修改本服务 owning YAML 的 `publicExposure` 与聚合 YAML 的 `configRef/path`
- 只允许执行 `platform-infra public-edge plan|status``apply --dry-run`
- 禁止执行、提示或恢复 `public-edge apply --confirm`、内部 `reconcile`、Caddyfile 写入或容器重建;
- 公网配置随正常 `master` merge 由唯一 PaC authority 自动收敛;
- 自动链未收敛时使用 `$unidesk-cicd` 只读归因,不由 L1 会话补写。
- L1 验收命令必须实际从固定 HTTPS origin 打开页面和 API;本机 `127.0.0.1`/`localhost` 只可作为进程健康 probe,不构成 L1 证据。
- 执行任何 L1 流程时必须扫描当前项目已声明的 L1:
- 发现历史 L1 尚未配置固定 HTTPS exposure 时,立即建立可追踪记录;
+6
View File
@@ -90,6 +90,12 @@ bun scripts/superapi-cli.ts capture validate
- 仓库为 `/root/superapi`
- 配置为 `/root/superapi/config/superapi.yaml`
- 项目名统一使用 SuperAPI,不与硬件 AI网关混用。
- SuperAPI 共享公网入口由 UniDesk public-edge 唯一 authority 收敛:
- `/root/superapi/config/superapi.yaml#publicExposure` 仍是产品 owning YAML
- `config/platform-infra/public-edge.yaml` 的 SuperAPI site 必须 pin 该仓库 40 位 commit
- public-edge renderer 只用 `git show` 读取 pinned YAML,不读取 SuperAPI working tree
- 修改 `publicExposure` 时先提交 SuperAPI,再在 UniDesk PR 更新 `sourceCommit`
- 禁止 SuperAPI L1、CLI 或人工会话执行 public-edge 强写、内部 reconcile 或 Caddyfile 写入。
- 最短验收顺序:
```bash
+1 -2
View File
@@ -69,10 +69,9 @@ bun scripts/cli.ts platform-infra temporal status --target NC01 --full
bun scripts/cli.ts platform-infra temporal validate --target NC01
```
5. 只在 Temporal ready 后收敛共享公网入口:
5. Temporal ready 后只读核对共享公网入口;公网配置由 `master` merge 后的唯一 PaC authority 自动收敛
```bash
bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm
bun scripts/cli.ts platform-infra public-edge status --target NC01
```
+8
View File
@@ -56,6 +56,14 @@ description: UniDesk YAML-first 运维正规化技能。用户提到 ymal-first/
- 先执行 `git status --short --branch``git pull --ff-only origin master`;保留并绕开无关并行修改。
- 源码、配置、部署类正规化默认在独立 `.worktree/<task>` 中做;轻量 skill/docs/reference 收敛可按项目规则直接在主 worktree 做。
- YAML 是 source of truth。不得新增隐藏代码默认值、schema 数值硬限制、合同测试或测试硬编码策略。
- 共享 public-edge 只能由 owning YAML 声明的 merge-driven authority mutation
- 业务服务、L1、领域 CLI、普通 worktree 和人工会话只能写 owning YAML,不能直接写 Caddyfile、Compose 或 edge container
- 公开 CLI 只保留 `plan``apply --dry-run``status``apply --confirm` 必须 fail closed
- 自动 reconcile 必须使用 immutable source commit、ancestor-only CAS、host lock 与 redacted provenance
- 绝对外部 `configRef` 必须声明 40 位 Git `sourceCommit`
renderer 只能读取该 commit 中的 YAML,禁止把外部 working tree 当隐式配置源;
- Caddy变更必须按 owner managed block 合并,禁止服务 renderer 整文件覆盖;
- 串行锁不能替代 source commit 单调校验,旧或分叉自动任务必须在 mutation 前拒绝。
- `$unidesk-daddev` P2 调试例外不改变 YAML-first 终态:
- 可对运行面做最小、可逆 patch 以验证根因或临时恢复;
- patch 不得反向成为配置真相、第二 authority 或自动交付成功证据;
@@ -54,3 +54,33 @@ spec:
volumeMounts:
- name: workspace
mountPath: /workspace
- name: reconcile-public-edge
image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1
imagePullPolicy: IfNotPresent
workingDir: /workspace
env:
- name: SOURCE_COMMIT
value: "{{revision}}"
- name: SOURCE_SNAPSHOT_PREFIX
value: "{{source_snapshot_prefix}}"
- name: UNIDESK_SSH_CLIENT_TOKEN
valueFrom:
secretKeyRef:
name: public-edge-trans
key: UNIDESK_SSH_CLIENT_TOKEN
script: |
#!/bin/sh
set -eu
rm -rf public-edge-source
git clone --filter=blob:none --no-checkout http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git public-edge-source
cd public-edge-source
git fetch --filter=blob:none origin "+$SOURCE_SNAPSHOT_PREFIX/$SOURCE_COMMIT:refs/remotes/origin/public-edge-source"
git checkout --detach "$SOURCE_COMMIT"
test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT"
exec bun scripts/native/cicd/deliver-platform-infra-public-edge.mjs \
--source-root /workspace/public-edge-source \
--source-commit "$SOURCE_COMMIT" \
--confirm
volumeMounts:
- name: workspace
mountPath: /workspace
+34 -4
View File
@@ -8,10 +8,33 @@ metadata:
- 2181
- 2274
- 2358
- 2541
defaults:
targetId: NC01
delivery:
enabled: true
authorityId: public-edge-master
sourceBranch: master
pipelineId: platform-infra-gitea-nc01
sourceCommitEnvironment: SOURCE_COMMIT
transport:
hostCwd: /root
secretRef:
configRef: config/secrets-distribution.yaml
targetId: public-edge-delivery-nc01
secretName: public-edge-trans
targetKey: UNIDESK_SSH_CLIENT_TOKEN
hostHelper:
archiveRoot: /root/unidesk/.state/public-edge-delivery
entrypoint: scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs
sourcePaths:
- config
- scripts
- package.json
- bun.lock
targets:
NC01:
route: NC01
@@ -29,12 +52,18 @@ targets:
- h2
runtime:
image: caddy:2.10.2-alpine
containerName: pikaoa-edge
workDir: /opt/unidesk/pikaoa-edge
composePath: /opt/unidesk/pikaoa-edge/docker-compose.yaml
caddyfilePath: /opt/unidesk/pikaoa-edge/Caddyfile
containerName: unidesk-public-edge
workDir: /opt/unidesk/public-edge
composePath: /opt/unidesk/public-edge/docker-compose.yaml
caddyfilePath: /opt/unidesk/public-edge/Caddyfile
dataDir: /var/lib/unidesk/pikaoa-edge/caddy-data
configDir: /var/lib/unidesk/pikaoa-edge/caddy-config
statePath: /var/lib/unidesk/public-edge/reconcile-state.json
lockPath: /run/lock/unidesk-public-edge.lock
legacy:
containerName: pikaoa-edge
composePath: /opt/unidesk/pikaoa-edge/docker-compose.yaml
caddyfilePath: /opt/unidesk/pikaoa-edge/Caddyfile
dnsServers:
- 10.43.0.10
responseHeaderTimeoutSeconds: 60
@@ -93,3 +122,4 @@ targets:
- id: superapi
configRef: /root/superapi/config/superapi.yaml
path: publicExposure
sourceCommit: 2f68c9989f49601537780e8804d02b74600adf7d
+20
View File
@@ -15,10 +15,17 @@ metadata:
- 2263
- 2274
- 2380
- 2541
sources:
root: /root/.unidesk/.state/secrets
files:
- sourceRef: unidesk-host-k8s.env
type: env
requiredKeys:
- UNIDESK_SSH_CLIENT_TOKEN
createIfMissing:
enabled: false
- sourceRef: platform-db/langbot-db.env
type: env
requiredKeys:
@@ -321,8 +328,21 @@ targets:
namespace: temporal
scope: temporal
enabled: true
- id: public-edge-delivery-nc01
route: NC01:k3s
namespace: devops-infra
scope: public-edge-delivery
enabled: true
kubernetesSecrets:
- name: public-edge-trans
targetId: public-edge-delivery-nc01
secretName: public-edge-trans
type: Opaque
data:
- sourceRef: unidesk-host-k8s.env
sourceKey: UNIDESK_SSH_CLIENT_TOKEN
targetKey: UNIDESK_SSH_CLIENT_TOKEN
- name: temporal-web-admin
targetId: temporal-nc01
secretName: temporal-web-admin
+3 -1
View File
@@ -180,10 +180,12 @@
```bash
bun scripts/cli.ts platform-infra public-edge plan --target NC01
bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm
bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run
bun scripts/cli.ts platform-infra public-edge status --target NC01
```
- 公网入口 mutation 只由 `master` merge 触发的唯一 PaC authority执行;PikaOA任务和 L1 会话不得执行 `apply --confirm`、内部 `reconcile` 或直接改写 Caddy。
- NC01 共享 edge 约束:
- 复用唯一 Caddy `80/443` listener
- 不访问 PK01
+40 -19
View File
@@ -25,7 +25,7 @@
## 公网入站 HTTPS/Public Edge
- `config/platform-infra/public-edge.yaml` 是共享 TCP `80/443` listener、
Caddy runtime、workDir、Compose、TLS data/config 和站点聚合顺序的唯一真相。
Caddy runtime、delivery authority、Compose、TLS data/config 和站点聚合顺序的唯一真相。
- NC01 只是 `targets.NC01` 的目标值;可复用文件名、parser 和 renderer 不绑定 node。
- `config/platform-infra/vpn-subscription.yaml` 独立拥有 `proxy.hwpod.com`
HTTPS/8443 upstream、TLS SNI、`/subscribe` 和 DNS expected A。
@@ -35,32 +35,53 @@
- 共享 YAML、renderer 和产品 YAML 不得重复声明同一 hostname 或 upstream
- 路径缺失、对象形状不兼容、hostname 重复或 `expectedA` 不匹配时,
`plan/status` 必须以 `blocking=false` warning 精确披露,顶层只读状态不失败;
- 引用不完整时,`apply --confirm` 必须保持 `mutation=false` 并拒绝执行。
- 引用不完整时,dry-run 与自动 reconcile 都必须保持 `mutation=false` 并拒绝执行。
- `targets.<id>.isolation` 必须声明 `excludedRoutes``preservedUdpPorts`
- target route 命中排除项时 parser 立即拒绝;
- `h3` 不得抢占保留的 UDP `443`
- status 与 apply 前置检查必须显示并验证保留 UDP listener。
- 共享边缘复用 `pikaoa-edge` 的 Caddy workDir、data、config、容器名和证书状态;
禁止创建第二 Caddy、FRP 或 fallback listener。
- 共享 `public-edge` desired state 被不同任务反复覆盖时:
- 该现象属于公共入口控制面故障,业务服务不得通过循环执行 `apply` 争夺共享 Caddy
- 正式 HTTPS 入口仍可用时继续交付,不让非阻塞 fingerprint 漂移中断业务
- 正式 HTTPS 入口不可用且需要即时交付时,只允许临时使用业务 owning 配置声明的固定 HTTP port
- 临时 HTTP 入口必须保留业务认证,并在交付说明中标注降级状态;
- 业务任务应创建公共故障 issue,记录 current/desired 漂移、覆盖来源可见性缺口和受影响站点
- 公共入口主责修复 authority 与协调器后,业务服务再复测正式 HTTPS 并撤回临时入口。
- `platform-infra public-edge apply --confirm` 每次重新读取 YAML 与运行面:
- 先生成临时候选 Caddyfile
- 先执行 Caddy adapt 与 validate
- 校验成功后才安装并 reload
- reload 失败时恢复上一份有效 Caddyfile 与 Compose
- 不得基于旧 `status` 结果直接 mutation。
- status 与 reconcile 前置检查必须显示并验证保留 UDP listener。
- 共享边缘运行面:
- 唯一容器为 YAML 声明的 `unidesk-public-edge`
- 复用既有 TLS data/config 目录和证书状态;
- 首次受控迁移后必须退役旧 `pikaoa-edge` 容器
- 旧 worktree因找不到其声明的旧容器而在 preflight 阶段拒绝 mutation
- 禁止创建第二 Caddy、FRP 或 fallback listener。
- 公开 CLI mutation 边界:
- `plan``apply --dry-run``status` 可由业务、L1 和人工会话执行
- `apply --confirm` 固定返回 `interactive-mutation-disabled`
- 内部 `reconcile` 只允许 immutable PaC archive helper调用;
- 领域 CLI 的 `Next`、帮助和 skill 不得输出强写或内部 reconcile 命令。
- 唯一自动交付链:
- GitHub `master` merge
- Gitea immutable snapshot
- `platform-infra-gitea-nc01` PaC 独立 checkout
- YAML SecretRef 控制的 `trans`
- NC01 host-local archive helper
- 同一版本 renderer 的 `public-edge reconcile`
- 自动 reconcile 防覆盖合同:
- host-local lock 只负责串行 mutation
- 绝对外部 `configRef` 必须 pin 40 位 Git commit
renderer 只允许以 `git show` 读取该 commit 中的 YAML
- 外部 working tree 的未提交修改或后续提交不得改变既有 UniDesk commit 的渲染结果;
- installed source commit 必须是 candidate source commit 的祖先或与其相同;
- 旧提交、分叉提交和乱序 PipelineRun 在安装候选前返回
`stale-or-diverged-source-commit`
- candidate 与 installed commit 相同且 fingerprint 一致时幂等返回;
- candidate 使用 `public-edge-global``public-edge-site:<id>` managed block
- 合并只替换 `public-edge-*` blocks,并保留其他 owner blocks
- Caddy/Compose候选验证成功后才安装和重建;
- 重建或 provenance 写入失败时恢复上一运行面。
- `status` 必须披露:
- installed/desired source commit
- authority、managed Caddy fingerprint、Compose fingerprint 与 site count
- legacy container 是否已退役;
- DNS、listener、Caddy validate 与逐站点 probe 摘要;
- 输出保持 `valuesPrinted=false`,不披露 Secret 或 archive payload。
- 受控入口:
```bash
bun scripts/cli.ts platform-infra public-edge plan --target NC01
bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run
bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm
bun scripts/cli.ts platform-infra public-edge status --target NC01
```
@@ -102,7 +102,19 @@ FRP remote port、node 内部 service、ClusterIP、pod IP、历史 nip.io 域
公开入口应通过受控 FRP、Caddy、DNS 和 TLS 投递目标 Web/API 服务,使外部流量到达声明的 node/lane runtime。
Caddy 更新必须使用受控 managed block 合并,不能用某个服务的渲染结果整文件覆盖共享 Caddyfile。FRP、Caddy 和 TLS 输出只能包含目标、状态、fingerprint、health 和错误摘要,不输出 Secret、token、证书私钥或可复制凭据。
Caddy 更新必须使用受控 managed block 合并,不能用某个服务的渲染结果整文件覆盖共享 Caddyfile。
共享边缘应满足以下 authority 与防覆盖要求:
- 只有 owning YAML 声明的唯一 merge-driven authority 可以执行 mutation。
- L1、领域 CLI、任务 worktree 和人工会话只能提交 owning YAML 与执行只读 plan/status。
- 自动 reconcile 必须串行执行。
- 自动 reconcile 必须以 installed source commit 和 candidate ancestry 做单调判定。
- 绝对外部 owning YAML 必须 pin 40 位 Git commit,并从该 commit 读取。
- 旧提交、分叉提交和乱序交付必须在 mutation 前拒绝。
- 运行面必须记录 redacted authority、source commit 与 fingerprint provenance。
FRP、Caddy 和 TLS 输出只能包含目标、状态、fingerprint、health 和错误摘要,不输出 Secret、token、证书私钥或可复制凭据。
### 6.3 OPS-PUBLIC-REQ-003 Route 边界
@@ -0,0 +1,74 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { readFileSync, realpathSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
const options = parseOptions(process.argv.slice(2));
const sourceRoot = realpathSync(options.sourceRoot);
const archiveRoot = dirname(sourceRoot);
const markerCommit = readFileSync(join(archiveRoot, ".unidesk-source-commit"), "utf8").trim();
if (markerCommit !== options.sourceCommit) fail("archive source commit marker 不匹配");
const ancestryFile = join(archiveRoot, ".unidesk-source-ancestors");
const ancestors = readFileSync(ancestryFile, "utf8").split(/\r?\n/u).filter(Boolean);
if (!ancestors.includes(options.sourceCommit) || ancestors.some((value) => !/^[0-9a-f]{40}$/u.test(value))) fail("archive ancestry proof 无效");
const configPath = resolve(sourceRoot, options.config);
if (!configPath.startsWith(`${sourceRoot}/`)) fail("--config 必须位于 source archive 内");
const result = spawnSync("bun", [
"scripts/cli.ts",
"platform-infra",
"public-edge",
"reconcile",
"--config",
configPath,
"--target",
options.target,
"--authority",
options.authority,
"--source-commit",
options.sourceCommit,
"--ancestry-file",
ancestryFile,
"--confirm",
], {
cwd: sourceRoot,
env: { ...process.env, UNIDESK_PUBLIC_EDGE_RECONCILE: "1", UNIDESK_TRANS_REPO_ROOT: sourceRoot },
stdio: "inherit",
timeout: 120_000,
});
process.exit(result.status ?? 1);
function parseOptions(args) {
const values = new Map();
let confirm = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--confirm") {
confirm = true;
continue;
}
if (!["--source-root", "--source-commit", "--authority", "--config", "--target"].includes(arg)) fail(`不支持的参数:${arg}`);
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) fail(`${arg} 需要参数`);
values.set(arg.slice(2), value);
index += 1;
}
const sourceRoot = values.get("source-root");
const sourceCommit = values.get("source-commit");
const authority = values.get("authority");
const config = values.get("config");
const target = values.get("target");
if (!confirm) fail("必须显式传入 --confirm");
if (typeof sourceRoot !== "string" || !isAbsolute(sourceRoot)) fail("--source-root 必须是绝对路径");
if (typeof sourceCommit !== "string" || !/^[0-9a-f]{40}$/u.test(sourceCommit)) fail("--source-commit 必须是 40 位 commit");
if (typeof authority !== "string" || authority.length === 0) fail("缺少 --authority");
if (typeof config !== "string" || isAbsolute(config)) fail("--config 必须是 archive 内相对路径");
if (typeof target !== "string" || target.length === 0) fail("缺少 --target");
return { sourceRoot, sourceCommit, authority, config, target };
}
function fail(message) {
process.stderr.write(`${JSON.stringify({ ok: false, action: "platform-infra-public-edge-archive-reconcile", error: message, valuesPrinted: false })}\n`);
process.exit(1);
}
@@ -0,0 +1,237 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
const options = parseOptions(process.argv.slice(2));
const sourceRoot = resolve(options.sourceRoot);
const configPath = resolve(sourceRoot, options.config);
const root = Bun.YAML.parse(readFileSync(configPath, "utf8"));
const plan = readPlan(root, sourceRoot, configPath);
verifySourceCommit(sourceRoot, options.sourceCommit, plan.sourceCommitEnvironment);
if (options.dryRun) {
output({ ok: true, action: "platform-infra-public-edge-delivery-plan", mutation: false, sourceCommit: options.sourceCommit, ...plan.summary });
process.exit(0);
}
const token = process.env[plan.secret.targetKey]?.trim() ?? "";
if (token.length === 0) fail(`缺少 SecretRef 投影环境变量:${plan.secret.targetKey}`);
const temporaryRoot = mkdtempSync(join(tmpdir(), "unidesk-public-edge-delivery-"));
try {
const archivePath = createArchive(sourceRoot, options.sourceCommit, plan.sourcePaths, temporaryRoot);
const archiveSha256 = sha256File(archivePath);
const route = `${plan.route}:${plan.hostCwd}`;
const remoteArchive = `${plan.archiveRoot}/incoming/public-edge-${options.sourceCommit}.tar.gz`;
const transPath = join(sourceRoot, "scripts/trans");
const env = { ...process.env, UNIDESK_TRANS_REPO_ROOT: sourceRoot };
runTrans(transPath, [route, "sh", "--", `umask 077; mkdir -p ${shellQuote(`${plan.archiveRoot}/incoming`)} ${shellQuote(`${plan.archiveRoot}/tmp`)}`], env);
runTrans(transPath, [route, "upload", archivePath, remoteArchive], env);
const remote = runTrans(transPath, [route, "sh"], env, remoteApplyScript({
archivePath: remoteArchive,
archiveRoot: plan.archiveRoot,
archiveSha256,
authorityId: plan.authorityId,
config: options.config,
entrypoint: plan.entrypoint,
sourceCommit: options.sourceCommit,
targetId: plan.targetId,
}));
const runtime = parseOutput(remote.stdout);
if (runtime.ok !== true) fail("host-local public-edge reconcile 未返回 ok=true");
output({
ok: true,
action: "platform-infra-public-edge-delivery",
mutation: runtime.mutation === true,
sourceCommit: options.sourceCommit,
archive: { sha256: `sha256:${archiveSha256}`, sourcePaths: plan.sourcePaths },
runtime,
...plan.summary,
});
} finally {
rmSync(temporaryRoot, { recursive: true, force: true });
}
function parseOptions(args) {
let sourceRoot = null;
let sourceCommit = null;
let config = "config/platform-infra/public-edge.yaml";
let dryRun = false;
let confirm = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--dry-run") dryRun = true;
else if (arg === "--confirm") confirm = true;
else if (arg === "--source-root" || arg === "--source-commit" || arg === "--config") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) fail(`${arg} 需要参数`);
if (arg === "--source-root") sourceRoot = value;
else if (arg === "--source-commit") sourceCommit = value;
else config = value;
index += 1;
} else fail(`不支持的参数:${arg}`);
}
if (sourceRoot === null) fail("缺少 --source-root");
if (sourceCommit === null || !/^[0-9a-f]{40}$/u.test(sourceCommit)) fail("--source-commit 必须是 40 位 commit");
if (dryRun === confirm) fail("必须且只能选择 --dry-run 或 --confirm");
return { sourceRoot, sourceCommit, config, dryRun, confirm };
}
function readPlan(root, sourceRoot, configPath) {
const delivery = record(root.delivery, "delivery");
if (delivery.enabled !== true) fail("delivery.enabled 必须为 true");
const defaults = record(root.defaults, "defaults");
const targetId = string(defaults.targetId, "defaults.targetId");
const target = record(record(root.targets, "targets")[targetId], `targets.${targetId}`);
const transport = record(delivery.transport, "delivery.transport");
const secret = record(transport.secretRef, "delivery.transport.secretRef");
const helper = record(delivery.hostHelper, "delivery.hostHelper");
const sourcePaths = array(helper.sourcePaths, "delivery.hostHelper.sourcePaths").map((value, index) => relativePath(value, `delivery.hostHelper.sourcePaths[${index}]`));
const entrypoint = relativePath(helper.entrypoint, "delivery.hostHelper.entrypoint");
const configRelative = relativeWithin(sourceRoot, configPath);
if (!sourcePaths.some((path) => entrypoint === path || entrypoint.startsWith(`${path}/`))) fail("host helper 不在 sourcePaths 中");
if (!sourcePaths.some((path) => configRelative === path || configRelative.startsWith(`${path}/`))) fail("public-edge config 不在 sourcePaths 中");
const secretSummary = {
configRef: relativePath(secret.configRef, "delivery.transport.secretRef.configRef"),
targetId: string(secret.targetId, "delivery.transport.secretRef.targetId"),
secretName: string(secret.secretName, "delivery.transport.secretRef.secretName"),
targetKey: string(secret.targetKey, "delivery.transport.secretRef.targetKey"),
};
verifySecretBinding(sourceRoot, secretSummary);
const authorityId = string(delivery.authorityId, "delivery.authorityId");
const archiveRoot = absolutePath(helper.archiveRoot, "delivery.hostHelper.archiveRoot");
return {
authorityId,
sourceCommitEnvironment: string(delivery.sourceCommitEnvironment, "delivery.sourceCommitEnvironment"),
targetId,
route: string(target.route, `targets.${targetId}.route`),
hostCwd: absolutePath(transport.hostCwd, "delivery.transport.hostCwd"),
archiveRoot,
entrypoint,
sourcePaths,
secret: secretSummary,
summary: {
authorityId,
sourceBranch: string(delivery.sourceBranch, "delivery.sourceBranch"),
pipelineId: string(delivery.pipelineId, "delivery.pipelineId"),
configRef: `${configRelative}#targets.${targetId}`,
hostHelper: { archiveRoot, entrypoint, sourcePaths },
secretRef: secretSummary,
},
};
}
function verifySecretBinding(sourceRoot, secret) {
const root = Bun.YAML.parse(readFileSync(resolve(sourceRoot, secret.configRef), "utf8"));
const target = array(root.targets, "secret targets").find((item) => record(item, "secret target").id === secret.targetId);
if (target === undefined || record(target, "secret target").enabled !== true) fail(`Secret target 未启用:${secret.targetId}`);
const binding = array(root.kubernetesSecrets, "kubernetesSecrets").find((item) => record(item, "secret binding").name === secret.secretName);
if (binding === undefined) fail(`Secret binding 不存在:${secret.secretName}`);
const item = array(record(binding, "secret binding").data, "secret data").find((entry) => record(entry, "secret data").targetKey === secret.targetKey);
if (item === undefined) fail(`Secret targetKey 不存在:${secret.targetKey}`);
}
function verifySourceCommit(sourceRoot, sourceCommit, environmentName) {
if ((process.env[environmentName]?.trim() ?? "") !== sourceCommit) fail(`${environmentName} 与 --source-commit 不一致`);
const result = runChecked("git", ["rev-parse", "HEAD"], sourceRoot);
if (result.stdout.trim() !== sourceCommit) fail("source checkout HEAD 与 --source-commit 不一致");
}
function createArchive(sourceRoot, sourceCommit, sourcePaths, temporaryRoot) {
const tarPath = join(temporaryRoot, "source.tar");
const markerPath = join(temporaryRoot, ".unidesk-source-commit");
const ancestryPath = join(temporaryRoot, ".unidesk-source-ancestors");
writeFileSync(markerPath, `${sourceCommit}\n`, { mode: 0o600 });
const ancestry = runChecked("git", ["rev-list", sourceCommit], sourceRoot).stdout;
writeFileSync(ancestryPath, ancestry, { mode: 0o600 });
runChecked("git", ["archive", "--format=tar", "--prefix=source/", `--output=${tarPath}`, sourceCommit, "--", ...sourcePaths], sourceRoot);
runChecked("tar", ["--append", "--file", tarPath, "--directory", temporaryRoot, ".unidesk-source-commit", ".unidesk-source-ancestors"], sourceRoot);
runChecked("gzip", ["-n", tarPath], sourceRoot);
return `${tarPath}.gz`;
}
function remoteApplyScript(input) {
return `set -eu
archive=${shellQuote(input.archivePath)}
archive_root=${shellQuote(input.archiveRoot)}
expected_sha=${shellQuote(input.archiveSha256)}
source_commit=${shellQuote(input.sourceCommit)}
actual_sha="$(sha256sum "$archive" | awk '{print $1}')"
test "$actual_sha" = "$expected_sha"
tmp="$(mktemp -d "$archive_root/tmp/$source_commit.XXXXXX")"
cleanup() { rm -rf "$tmp"; rm -f "$archive"; }
trap cleanup EXIT HUP INT TERM
tar -xzf "$archive" -C "$tmp"
test "$(cat "$tmp/.unidesk-source-commit")" = "$source_commit"
cd "$tmp/source"
bun ${shellQuote(input.entrypoint)} --source-root "$tmp/source" --source-commit "$source_commit" --authority ${shellQuote(input.authorityId)} --config ${shellQuote(input.config)} --target ${shellQuote(input.targetId)} --confirm
`;
}
function runTrans(path, args, env, input) {
const result = spawnSync(path, args, { encoding: "utf8", env, input, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
if (result.status !== 0) fail(`trans ${args.slice(1, 3).join(" ")} 失败:${result.stderr || result.stdout}`);
return result;
}
function runChecked(command, args, cwd) {
const result = spawnSync(command, args, { cwd, encoding: "utf8", timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
if (result.status !== 0) fail(`${command} ${args.join(" ")} 失败:${result.stderr || result.stdout}`);
return result;
}
function parseOutput(stdout) {
const text = stdout.trim();
for (const candidate of [text, text.slice(text.lastIndexOf("\n") + 1)]) {
try {
const parsed = JSON.parse(candidate);
if (parsed && typeof parsed === "object") return parsed;
} catch {}
}
return { ok: false, error: "remote-output-invalid", stdoutTail: text.slice(-2000) };
}
function record(value, label) {
if (value === null || typeof value !== "object" || Array.isArray(value)) fail(`${label} 必须是对象`);
return value;
}
function array(value, label) {
if (!Array.isArray(value) || value.length === 0) fail(`${label} 必须是非空数组`);
return value;
}
function string(value, label) {
if (typeof value !== "string" || value.trim().length === 0) fail(`${label} 必须是非空字符串`);
return value.trim();
}
function absolutePath(value, label) {
const path = string(value, label);
if (!isAbsolute(path)) fail(`${label} 必须是绝对路径`);
return path.replace(/\/+$/u, "") || "/";
}
function relativePath(value, label) {
const path = string(value, label);
if (isAbsolute(path) || !/^[A-Za-z0-9._/-]+$/u.test(path) || path.split("/").some((part) => part === "" || part === "..")) fail(`${label} 必须是仓库相对路径`);
return path;
}
function relativeWithin(root, path) {
const prefix = `${root.replace(/\/+$/u, "")}/`;
if (!path.startsWith(prefix)) fail("--config 必须位于 source root");
return path.slice(prefix.length);
}
function sha256File(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
function shellQuote(value) {
return `'${String(value).replace(/'/gu, `'\\''`)}'`;
}
function output(value) {
process.stdout.write(`${JSON.stringify({ ...value, valuesPrinted: false }, null, 2)}\n`);
}
function fail(message) {
process.stderr.write(`${JSON.stringify({ ok: false, action: "platform-infra-public-edge-delivery", error: message, valuesPrinted: false })}\n`);
process.exit(1);
}
+472 -138
View File
@@ -1,5 +1,7 @@
import { Buffer } from "node:buffer";
import { existsSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, isAbsolute, relative, sep } from "node:path";
import type { UniDeskConfig } from "./config";
import { renderMachine } from "./cicd-render";
@@ -8,6 +10,7 @@ import { CliInputError } from "./output";
import {
capture,
compactCapture,
assertNoDuplicateYamlMappingKeys,
parseJsonOutput,
readYamlRecord,
resolveRepoPath,
@@ -15,7 +18,7 @@ import {
shQuote,
} from "./platform-infra-ops-library";
type PublicEdgeAction = "plan" | "apply" | "status";
type PublicEdgeAction = "plan" | "apply" | "reconcile" | "status";
interface PublicEdgeOptions {
action: PublicEdgeAction;
@@ -23,12 +26,23 @@ interface PublicEdgeOptions {
targetId: string | null;
dryRun: boolean;
confirm: boolean;
authorityId: string | null;
sourceCommit: string | null;
ancestryFile: string | null;
}
interface PublicEdgeDelivery {
enabled: boolean;
authorityId: string;
sourceBranch: string;
pipelineId: string;
}
export interface SiteReference {
id: string;
configRef: string;
path: string;
sourceCommit?: string;
}
export interface ResolvedSiteRoute {
@@ -68,6 +82,13 @@ export interface PublicEdgeTarget {
caddyfilePath: string;
dataDir: string;
configDir: string;
statePath: string;
lockPath: string;
legacy: {
containerName: string;
composePath: string;
caddyfilePath: string;
};
dnsServers: string[];
responseHeaderTimeoutSeconds: number;
};
@@ -86,7 +107,6 @@ export function publicEdgeHelp(): Record<string, unknown> {
usage: [
"bun scripts/cli.ts platform-infra public-edge plan --target NC01",
"bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run",
"bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm",
"bun scripts/cli.ts platform-infra public-edge status --target NC01",
],
source: "config/platform-infra/public-edge.yaml",
@@ -94,7 +114,10 @@ export function publicEdgeHelp(): Record<string, unknown> {
"平台 YAML 只拥有 listener、Caddy runtime 和 site 聚合顺序。",
"hostname 与 upstream 只从产品 owning YAML 的 configRef/path 解析。",
"apply 每次重新读取 YAML 与运行面,缺失引用时拒绝 mutation。",
"Caddyfile 与 Compose 同目录候选先校验,再以 rename 原子安装并重建唯一 Caddy。",
"交互式 apply 只允许 dry-runmutation 只接受 immutable master PaC archive reconcile。",
"站点以 owner managed block 合并,禁止服务或 worktree 整文件强写。",
"reconcile 使用 host lock 与 source commit ancestor-only CAS,拒绝旧提交和分叉提交。",
"候选先校验,再原子安装并重建唯一 Caddy,同时记录 redacted provenance。",
"只有安装或唯一 Caddy 重建失败时回滚;站点探测失败只输出非阻塞 warning。",
],
};
@@ -106,18 +129,19 @@ export async function runPlatformInfraPublicEdgeCommand(config: UniDeskConfig, a
}
const options = parseOptions(args);
const root = readConfig(options.configPath);
const delivery = readDelivery(root);
const target = readTarget(root, options.targetId);
const resolution = resolveSites(target);
if (options.action === "plan") {
const result = planResult(options.configPath, target, resolution, "plan");
return renderMachine("platform-infra public-edge plan", result, "json", result.ok === true);
}
if (options.action === "apply" && options.dryRun) {
if (options.action === "apply") {
const result = planResult(options.configPath, target, resolution, "dry-run");
return renderMachine("platform-infra public-edge apply", result, "json", result.ok === true);
}
if (options.action === "status") {
const result = await status(config, options.configPath, target, resolution);
const result = await status(config, options.configPath, delivery, target, resolution);
return renderMachine("platform-infra public-edge status", result, "json", result.ok === true);
}
if (resolution.unresolved.length > 0) {
@@ -125,30 +149,43 @@ export async function runPlatformInfraPublicEdgeCommand(config: UniDeskConfig, a
...planResult(options.configPath, target, resolution, "blocked"),
ok: false,
mutation: false,
error: "存在缺失或不兼容的产品 owning YAML 引用,apply 未执行。",
error: "存在缺失或不兼容的产品 owning YAML 引用,reconcile 未执行。",
};
return renderMachine("platform-infra public-edge apply", result, "json", false);
return renderMachine("platform-infra public-edge reconcile", result, "json", false);
}
const result = await apply(config, options.configPath, target, resolution.sites);
return renderMachine("platform-infra public-edge apply", result, "json", result.ok === true);
if (options.authorityId !== delivery.authorityId) {
throw inputError("reconcile authority 与 owning YAML 不一致", "reconcile-authority-mismatch", "--authority");
}
if (process.env.UNIDESK_PUBLIC_EDGE_RECONCILE !== "1") {
throw inputError("reconcile 仅允许 immutable PaC archive helper 调用", "reconcile-automation-required");
}
const ancestry = readAncestry(options.ancestryFile, options.sourceCommit);
const result = await reconcile(config, options.configPath, delivery, target, resolution.sites, options.sourceCommit!, ancestry);
return renderMachine("platform-infra public-edge reconcile", result, "json", result.ok === true);
}
function parseOptions(args: string[]): PublicEdgeOptions {
const action = args[0];
if (action !== "plan" && action !== "apply" && action !== "status") {
throw inputError("public-edge action 必须是 plan、apply 或 status", "invalid-action", action ?? "<missing>");
if (action !== "plan" && action !== "apply" && action !== "reconcile" && action !== "status") {
throw inputError("public-edge action 必须是 plan、apply、reconcile 或 status", "invalid-action", action ?? "<missing>");
}
let configPath = resolveRepoPath("config/platform-infra/public-edge.yaml");
let targetId: string | null = null;
let dryRun = false;
let confirm = false;
let authorityId: string | null = null;
let sourceCommit: string | null = null;
let ancestryFile: string | null = null;
for (let index = 1; index < args.length; index += 1) {
const arg = args[index]!;
if (arg === "--config" || arg === "--target") {
if (arg === "--config" || arg === "--target" || arg === "--authority" || arg === "--source-commit" || arg === "--ancestry-file") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw inputError(`${arg} 需要参数`, "missing-option-value", arg);
if (arg === "--config") configPath = resolveRepoPath(value);
else targetId = value;
else if (arg === "--target") targetId = value;
else if (arg === "--authority") authorityId = value;
else if (arg === "--source-commit") sourceCommit = value;
else ancestryFile = value;
index += 1;
} else if (arg === "--dry-run") {
dryRun = true;
@@ -158,9 +195,19 @@ function parseOptions(args: string[]): PublicEdgeOptions {
throw inputError(`不支持的 public-edge 参数:${arg}`, "unsupported-option", arg);
}
}
if (action !== "apply" && (dryRun || confirm)) throw inputError(`${action} 不接受 mutation 参数`, "mutation-option-not-allowed");
if (action === "apply" && dryRun === confirm) throw inputError("apply 必须且只能选择 --dry-run 或 --confirm", "apply-mode-required");
return { action, configPath, targetId, dryRun, confirm };
if (action === "apply") {
if (confirm) throw inputError("交互式 apply --confirm 已禁用;提交 owning YAML 后由唯一 PaC reconcile", "interactive-mutation-disabled", "--confirm");
if (!dryRun) throw inputError("apply 只接受 --dry-run", "apply-dry-run-required");
} else if (action === "reconcile") {
if (!confirm || dryRun) throw inputError("reconcile 必须使用 --confirm", "reconcile-confirm-required");
if (authorityId === null || sourceCommit === null || ancestryFile === null) {
throw inputError("reconcile 缺少 --authority、--source-commit 或 --ancestry-file", "reconcile-provenance-required");
}
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw inputError("--source-commit 40 commit", "invalid-source-commit");
} else if (dryRun || confirm || authorityId !== null || sourceCommit !== null || ancestryFile !== null) {
throw inputError(`${action} 不接受 mutation/provenance 参数`, "mutation-option-not-allowed");
}
return { action, configPath, targetId, dryRun, confirm, authorityId, sourceCommit, ancestryFile };
}
function readConfig(configPath: string): Record<string, unknown> {
@@ -168,6 +215,18 @@ function readConfig(configPath: string): Record<string, unknown> {
return readYamlRecord(configPath, "platform-infra-public-edge");
}
function readDelivery(root: Record<string, unknown>): PublicEdgeDelivery {
const delivery = record(root.delivery, "delivery");
const parsed = {
enabled: boolean(delivery.enabled, "delivery.enabled"),
authorityId: string(delivery.authorityId, "delivery.authorityId"),
sourceBranch: string(delivery.sourceBranch, "delivery.sourceBranch"),
pipelineId: string(delivery.pipelineId, "delivery.pipelineId"),
};
if (!parsed.enabled) throw inputError("delivery.enabled 必须为 true", "delivery-disabled", "delivery.enabled");
return parsed;
}
function readTarget(root: Record<string, unknown>, requestedTargetId: string | null): PublicEdgeTarget {
const defaults = record(root.defaults, "defaults");
const targets = record(root.targets, "targets");
@@ -176,6 +235,7 @@ function readTarget(root: Record<string, unknown>, requestedTargetId: string | n
const listener = record(target.listener, `targets.${targetId}.listener`);
const isolation = record(target.isolation, `targets.${targetId}.isolation`);
const runtime = record(target.runtime, `targets.${targetId}.runtime`);
const legacy = record(runtime.legacy, `targets.${targetId}.runtime.legacy`);
const sites = array(target.sites, `targets.${targetId}.sites`);
const parsed: PublicEdgeTarget = {
id: targetId,
@@ -198,6 +258,13 @@ function readTarget(root: Record<string, unknown>, requestedTargetId: string | n
caddyfilePath: absolutePath(runtime.caddyfilePath, `targets.${targetId}.runtime.caddyfilePath`),
dataDir: absolutePath(runtime.dataDir, `targets.${targetId}.runtime.dataDir`),
configDir: absolutePath(runtime.configDir, `targets.${targetId}.runtime.configDir`),
statePath: absolutePath(runtime.statePath, `targets.${targetId}.runtime.statePath`),
lockPath: absolutePath(runtime.lockPath, `targets.${targetId}.runtime.lockPath`),
legacy: {
containerName: string(legacy.containerName, `targets.${targetId}.runtime.legacy.containerName`),
composePath: absolutePath(legacy.composePath, `targets.${targetId}.runtime.legacy.composePath`),
caddyfilePath: absolutePath(legacy.caddyfilePath, `targets.${targetId}.runtime.legacy.caddyfilePath`),
},
dnsServers: array(runtime.dnsServers, `targets.${targetId}.runtime.dnsServers`).map((value, index) => ipv4(value, `targets.${targetId}.runtime.dnsServers[${index}]`)),
responseHeaderTimeoutSeconds: positiveInteger(runtime.responseHeaderTimeoutSeconds, `targets.${targetId}.runtime.responseHeaderTimeoutSeconds`),
},
@@ -207,6 +274,9 @@ function readTarget(root: Record<string, unknown>, requestedTargetId: string | n
id: string(site.id, `targets.${targetId}.sites[${index}].id`),
configRef: yamlConfigPath(site.configRef, `targets.${targetId}.sites[${index}].configRef`),
path: string(site.path, `targets.${targetId}.sites[${index}].path`),
...(site.sourceCommit === undefined
? {}
: { sourceCommit: gitCommit(site.sourceCommit, `targets.${targetId}.sites[${index}].sourceCommit`) }),
};
}),
};
@@ -229,8 +299,7 @@ export function resolveSites(target: PublicEdgeTarget): ResolutionResult {
const ownerPath = reference.configRef.startsWith("/")
? reference.configRef
: resolveRepoPath(reference.configRef);
if (!existsSync(ownerPath)) throw new Error(`configRef 文件不存在:${reference.configRef}`);
const owner = resolveObjectPath(readYamlRecord(ownerPath), reference.path);
const owner = resolveObjectPath(readSiteOwner(reference, ownerPath), reference.path);
const exposure = exposureObject(owner);
const dns = optionalRecord(exposure.dns);
const label = `${reference.configRef}#${reference.path}`;
@@ -267,6 +336,38 @@ export function resolveSites(target: PublicEdgeTarget): ResolutionResult {
return { sites, unresolved };
}
function readSiteOwner(reference: SiteReference, ownerPath: string): Record<string, unknown> {
if (!isAbsolute(reference.configRef)) {
if (reference.sourceCommit !== undefined) throw new Error(`仓库内 configRef 不接受 sourceCommit${reference.configRef}`);
if (!existsSync(ownerPath)) throw new Error(`configRef 文件不存在:${reference.configRef}`);
return readYamlRecord(ownerPath);
}
if (reference.sourceCommit === undefined) {
throw new Error(`绝对 configRef 必须声明 sourceCommit${reference.configRef}`);
}
const repository = spawnSync("git", ["-C", dirname(ownerPath), "rev-parse", "--show-toplevel"], {
encoding: "utf8",
timeout: 10_000,
});
const repositoryRoot = repository.status === 0 ? repository.stdout.trim() : "";
if (!isAbsolute(repositoryRoot)) throw new Error(`外部 configRef 不在可读 Git 仓库:${reference.configRef}`);
const repositoryPath = relative(repositoryRoot, ownerPath);
if (repositoryPath.length === 0 || repositoryPath === ".." || repositoryPath.startsWith(`..${sep}`) || isAbsolute(repositoryPath)) {
throw new Error(`外部 configRef 不属于解析出的 Git 仓库:${reference.configRef}`);
}
const gitPath = repositoryPath.split(sep).join("/");
const source = spawnSync("git", ["-C", repositoryRoot, "show", `${reference.sourceCommit}:${gitPath}`], {
encoding: "utf8",
timeout: 10_000,
maxBuffer: 4 * 1024 * 1024,
});
if (source.status !== 0) {
throw new Error(`外部 configRef 无法从 pinned commit 读取:${reference.configRef}@${reference.sourceCommit}`);
}
assertNoDuplicateYamlMappingKeys(source.stdout, `${reference.configRef}@${reference.sourceCommit}`);
return record(Bun.YAML.parse(source.stdout) as unknown, `${reference.configRef}@${reference.sourceCommit}`);
}
function resolveObjectPath(root: unknown, path: string): Record<string, unknown> {
let value: unknown = root;
for (const segment of path.split(".")) {
@@ -356,6 +457,7 @@ function planResult(configPath: string, target: PublicEdgeTarget, resolution: Re
warnings: resolution.unresolved.map((item) => ({ ...item, level: "warning", blocking: false })),
complete,
rendered: rendered === null ? null : {
desiredSourceCommit: checkoutSourceCommit(),
caddyfileFingerprint: sha256Fingerprint(rendered.caddyfile),
composeFingerprint: sha256Fingerprint(rendered.compose),
siteCount: resolution.sites.length,
@@ -364,14 +466,30 @@ function planResult(configPath: string, target: PublicEdgeTarget, resolution: Re
};
}
async function apply(config: UniDeskConfig, configPath: string, target: PublicEdgeTarget, sites: ResolvedSite[]): Promise<Record<string, unknown>> {
async function reconcile(
config: UniDeskConfig,
configPath: string,
delivery: PublicEdgeDelivery,
target: PublicEdgeTarget,
sites: ResolvedSite[],
sourceCommit: string,
ancestry: string[],
): Promise<Record<string, unknown>> {
const artifacts = renderPublicEdgeArtifacts(target, sites);
const result = await capture(config, target.route, ["sh"], applyScript(target, sites, artifacts), { runtimeTimeoutMs: 120_000 });
const result = await capture(
config,
target.route,
["sh"],
applyScript(target, sites, artifacts, { authorityId: delivery.authorityId, sourceCommit, ancestry }),
{ runtimeTimeoutMs: 120_000 },
);
const runtime = parseJsonOutput(result.stdout) ?? { ok: false, capture: compactCapture(result, { full: true }) };
return {
ok: result.exitCode === 0 && runtime.ok !== false,
action: "platform-infra-public-edge-apply",
action: "platform-infra-public-edge-reconcile",
mutation: result.exitCode === 0 && runtime.ok !== false && runtime.mutation === true,
authority: { id: delivery.authorityId, sourceBranch: delivery.sourceBranch, pipelineId: delivery.pipelineId },
sourceCommit,
configRef: `${configPath}#targets.${target.id}`,
target: targetSummary(target),
sites,
@@ -380,15 +498,34 @@ async function apply(config: UniDeskConfig, configPath: string, target: PublicEd
};
}
async function status(config: UniDeskConfig, configPath: string, target: PublicEdgeTarget, resolution: ResolutionResult): Promise<Record<string, unknown>> {
async function status(
config: UniDeskConfig,
configPath: string,
delivery: PublicEdgeDelivery,
target: PublicEdgeTarget,
resolution: ResolutionResult,
): Promise<Record<string, unknown>> {
const artifacts = resolution.unresolved.length === 0 ? renderPublicEdgeArtifacts(target, resolution.sites) : null;
const result = await capture(config, target.route, ["sh"], statusScript(target, resolution.sites, artifacts?.caddyfile ?? null));
const result = await capture(
config,
target.route,
["sh"],
statusScript(
target,
resolution.sites,
artifacts?.caddyfile ?? null,
artifacts?.compose ?? null,
checkoutSourceCommit(),
delivery.authorityId,
),
);
const runtime = parseJsonOutput(result.stdout) ?? { ok: false, capture: compactCapture(result, { full: true }) };
const ok = result.exitCode === 0 && runtime.ok !== false;
return {
ok,
action: "platform-infra-public-edge-status",
mutation: false,
authority: { id: delivery.authorityId, sourceBranch: delivery.sourceBranch, pipelineId: delivery.pipelineId },
configRef: `${configPath}#targets.${target.id}`,
target: targetSummary(target),
sites: resolution.sites,
@@ -402,13 +539,19 @@ async function status(config: UniDeskConfig, configPath: string, target: PublicE
export function renderPublicEdgeArtifacts(target: PublicEdgeTarget, sites: ResolvedSite[]): { caddyfile: string; compose: string } {
const protocols = target.listener.protocols.join(" ");
const global = `\{\n\tservers \{\n\t\tprotocols ${protocols}\n\t\}\n\}\n`;
const blocks = sites.map((site) => renderSiteBlock(site, target.runtime.responseHeaderTimeoutSeconds)).join("\n");
const global = renderManagedBlock("public-edge-global", `\{\n\tservers \{\n\t\tprotocols ${protocols}\n\t\}\n\}`);
const blocks = sites
.map((site) => renderManagedBlock(`public-edge-site:${site.id}`, renderSiteBlock(site, target.runtime.responseHeaderTimeoutSeconds)))
.join("\n");
const dnsServers = target.runtime.dnsServers.map((server) => ` - ${server}`).join("\n");
const compose = `services:\n caddy:\n image: ${target.runtime.image}\n container_name: ${target.runtime.containerName}\n network_mode: host\n restart: unless-stopped\n dns:\n${dnsServers}\n volumes:\n - ${target.runtime.caddyfilePath}:/etc/caddy/Caddyfile:ro\n - ${target.runtime.dataDir}:/data\n - ${target.runtime.configDir}:/config\n`;
return { caddyfile: `${global}\n${blocks}`, compose };
}
function renderManagedBlock(owner: string, body: string): string {
return `# BEGIN unidesk managed ${owner}\n${body.trim()}\n# END unidesk managed ${owner}\n`;
}
function renderSiteBlock(site: ResolvedSite, defaultTimeoutSeconds: number): string {
if (site.routes === undefined) {
return `${site.hostname} \{\n${renderReverseProxy(site.upstream, defaultTimeoutSeconds, site.upstreamTlsServerName, 1)}\n\}\n`;
@@ -439,161 +582,321 @@ ${tab}\t\}
${tab}\}`;
}
export function applyScript(target: PublicEdgeTarget, sites: ResolvedSite[], artifacts: { caddyfile: string; compose: string }): string {
export function applyScript(
target: PublicEdgeTarget,
sites: ResolvedSite[],
artifacts: { caddyfile: string; compose: string },
provenance: { authorityId: string; sourceCommit: string; ancestry: string[] },
): string {
const caddyfile = Buffer.from(artifacts.caddyfile, "utf8").toString("base64");
const compose = Buffer.from(artifacts.compose, "utf8").toString("base64");
const ancestry = Buffer.from(`${provenance.ancestry.join("\n")}\n`, "utf8").toString("base64");
const desiredCaddyFingerprint = sha256Fingerprint(artifacts.caddyfile);
const desiredComposeFingerprint = sha256Fingerprint(artifacts.compose);
const dnsItems = sites.map((site) => shQuote(`${site.hostname}|${site.expectedA}`)).join(" ");
const probeItems = sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}`)).join(" ");
return `set -u
mkdir -p "$(dirname ${shQuote(target.runtime.lockPath)})"
exec 9>${shQuote(target.runtime.lockPath)}
if ! flock -x 9; then
printf '%s\\n' '{"ok":false,"mutation":false,"error":"public-edge-lock-failed","valuesPrinted":false}'
exit 1
fi
tmp="$(mktemp -d)"
caddy_candidate=${shQuote(`${target.runtime.caddyfilePath}.next`)}.$$
compose_candidate=${shQuote(`${target.runtime.composePath}.next`)}.$$
caddy_rollback=${shQuote(`${target.runtime.caddyfilePath}.rollback`)}.$$
compose_rollback=${shQuote(`${target.runtime.composePath}.rollback`)}.$$
trap 'rm -rf "$tmp"; rm -f "$caddy_candidate" "$compose_candidate" "$caddy_rollback" "$compose_rollback"' EXIT
mkdir_rc=0
mkdir -p ${shQuote(target.runtime.workDir)} ${shQuote(target.runtime.dataDir)} ${shQuote(target.runtime.configDir)} || mkdir_rc=$?
candidate_write_rc=1
if [ "$mkdir_rc" -eq 0 ]; then
candidate_write_rc=0
printf '%s' ${shQuote(caddyfile)} | base64 -d >"$caddy_candidate" || candidate_write_rc=$?
printf '%s' ${shQuote(compose)} | base64 -d >"$compose_candidate" || candidate_write_rc=$?
trap 'rm -rf "$tmp"; rm -f "$caddy_candidate" "$compose_candidate"' EXIT
ancestry_file="$tmp/ancestors"
desired_caddy="$tmp/Caddyfile.desired"
current_caddy="$tmp/Caddyfile.current"
current_caddy_source="empty"
mkdir -p ${shQuote(target.runtime.workDir)} ${shQuote(target.runtime.dataDir)} ${shQuote(target.runtime.configDir)} "$(dirname ${shQuote(target.runtime.statePath)})"
printf '%s' ${shQuote(ancestry)} | base64 -d >"$ancestry_file"
printf '%s' ${shQuote(caddyfile)} | base64 -d >"$desired_caddy"
printf '%s' ${shQuote(compose)} | base64 -d >"$compose_candidate"
installed_commit=""
installed_authority=""
installed_caddy_sha=""
installed_compose_sha=""
if [ -f ${shQuote(target.runtime.statePath)} ]; then
state_fields="$tmp/installed-state-fields"
python3 - ${shQuote(target.runtime.statePath)} "$state_fields" <<'PY' || {
import json, pathlib, re, sys
state = json.load(open(sys.argv[1], encoding="utf-8"))
valid = (
state.get("version") == 1
and isinstance(state.get("authorityId"), str) and bool(state["authorityId"])
and isinstance(state.get("sourceCommit"), str) and re.fullmatch(r"[0-9a-f]{40}", state["sourceCommit"]) is not None
and isinstance(state.get("managedCaddyFingerprint"), str) and re.fullmatch(r"sha256:[0-9a-f]{64}", state["managedCaddyFingerprint"]) is not None
and isinstance(state.get("composeFingerprint"), str) and re.fullmatch(r"sha256:[0-9a-f]{64}", state["composeFingerprint"]) is not None
and isinstance(state.get("siteCount"), int) and state["siteCount"] >= 0
)
if not valid:
raise SystemExit(2)
pathlib.Path(sys.argv[2]).write_text("\\n".join([
state["sourceCommit"],
state["authorityId"],
state["managedCaddyFingerprint"],
state["composeFingerprint"],
]) + "\\n", encoding="utf-8")
PY
printf '%s\\n' '{"ok":false,"mutation":false,"error":"installed-provenance-invalid","valuesPrinted":false}'
exit 1
}
installed_commit="$(sed -n '1p' "$state_fields")"
installed_authority="$(sed -n '2p' "$state_fields")"
installed_caddy_sha="$(sed -n '3p' "$state_fields")"
installed_compose_sha="$(sed -n '4p' "$state_fields")"
fi
container_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.containerName)} 2>/dev/null | grep -qx true || container_rc=1
listeners_rc=0; ss -ltn | grep -Eq '[:.]${target.listener.httpPort}[[:space:]]' || listeners_rc=1; ss -ltn | grep -Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc=1
if [ -n "$installed_authority" ] && [ "$installed_authority" != ${shQuote(provenance.authorityId)} ]; then
printf '%s\\n' '{"ok":false,"mutation":false,"error":"installed-authority-mismatch","valuesPrinted":false}'
exit 1
fi
if [ -n "$installed_commit" ] && ! grep -Fxq "$installed_commit" "$ancestry_file"; then
python3 - "$installed_commit" ${shQuote(provenance.sourceCommit)} <<'PY'
import json, sys
print(json.dumps({"ok": False, "mutation": False, "error": "stale-or-diverged-source-commit", "installedSourceCommit": sys.argv[1], "candidateSourceCommit": sys.argv[2], "valuesPrinted": False}))
PY
exit 1
fi
if [ -f ${shQuote(target.runtime.caddyfilePath)} ]; then
cp ${shQuote(target.runtime.caddyfilePath)} "$current_caddy"
current_caddy_source="managed"
elif [ -f ${shQuote(target.runtime.legacy.caddyfilePath)} ]; then
cp ${shQuote(target.runtime.legacy.caddyfilePath)} "$current_caddy"
current_caddy_source="legacy"
else
: >"$current_caddy"
fi
python3 - "$current_caddy" "$desired_caddy" "$caddy_candidate" "$current_caddy_source" <<'PY'
import pathlib, re, sys
current = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
desired = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")
pattern = re.compile(r"(?ms)^# BEGIN unidesk managed (?P<name>[^\\n]+)\\n(?P<body>.*?)\\n# END unidesk managed (?P=name)\\n*")
desired_blocks = [m.group(0).rstrip() for m in pattern.finditer(desired)]
is_public_edge = lambda name: re.fullmatch(r"public-edge(?:-global|-site:.+)", name) is not None
if not desired_blocks or any(not is_public_edge(m.group("name")) for m in pattern.finditer(desired)):
raise SystemExit("desired public-edge managed blocks invalid")
if sys.argv[4] == "legacy":
preserved = [m.group(0).rstrip() for m in pattern.finditer(current) if not is_public_edge(m.group("name"))]
base = "\\n\\n".join(preserved)
else:
base = pattern.sub(lambda m: "" if is_public_edge(m.group("name")) else m.group(0), current).rstrip()
merged = (base + "\\n\\n" if base else "") + "\\n\\n".join(desired_blocks) + "\\n"
pathlib.Path(sys.argv[3]).write_text(merged, encoding="utf-8")
PY
new_running=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.containerName)} 2>/dev/null | grep -qx true && new_running=1
legacy_running=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.legacy.containerName)} 2>/dev/null | grep -qx true && legacy_running=1
if [ "$new_running" -ne 1 ] && [ "$legacy_running" -ne 1 ]; then
printf '%s\\n' '{"ok":false,"mutation":false,"error":"managed-edge-not-running","valuesPrinted":false}'
exit 1
fi
listeners_rc=0
ss -ltn | grep -Eq '[:.]${target.listener.httpPort}[[:space:]]' || listeners_rc=1
ss -ltn | grep -Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc=1
udp_rc=0
${target.isolation.preservedUdpPorts.map((value) => `ss -lun | grep -Eq '[:.]${value}[[:space:]]' || udp_rc=1`).join("\n")}
dns_rc=0
for item in ${dnsItems}; do host="${"${item%%|*}"}"; expected="${"${item#*|}"}"; getent ahostsv4 "$host" | awk '{print $1}' | grep -Fxq "$expected" || dns_rc=1; done
compose_validate_rc=1; adapt_rc=1; validate_rc=1
if [ "$candidate_write_rc" -eq 0 ] && [ "$container_rc" -eq 0 ] && [ "$listeners_rc" -eq 0 ] && [ "$udp_rc" -eq 0 ] && [ "$dns_rc" -eq 0 ]; then
docker compose -f "$compose_candidate" config >/dev/null 2>"$tmp/compose-validate.err"; compose_validate_rc=$?
if [ "$compose_validate_rc" -eq 0 ]; then
docker run --rm -v "$caddy_candidate:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy adapt --validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/adapt.err"; adapt_rc=$?
fi
if [ "$adapt_rc" -eq 0 ]; then
docker run --rm -v "$caddy_candidate:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/validate.err"; validate_rc=$?
fi
compose_rc=0; docker compose -f "$compose_candidate" config >/dev/null 2>"$tmp/compose.err" || compose_rc=$?
adapt_rc=0; docker run --rm -v "$caddy_candidate:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy adapt --validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/adapt.err" || adapt_rc=$?
validate_rc=0; docker run --rm -v "$caddy_candidate:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/validate.err" || validate_rc=$?
if [ "$listeners_rc" -ne 0 ] || [ "$udp_rc" -ne 0 ] || [ "$dns_rc" -ne 0 ] || [ "$compose_rc" -ne 0 ] || [ "$adapt_rc" -ne 0 ] || [ "$validate_rc" -ne 0 ]; then
python3 - "$listeners_rc" "$udp_rc" "$dns_rc" "$compose_rc" "$adapt_rc" "$validate_rc" <<'PY'
import json, sys
names = ["tcpListeners", "preservedUdpListeners", "dns", "compose", "caddyAdapt", "caddyValidate"]
print(json.dumps({"ok": False, "mutation": False, "error": "candidate-validation-failed", "checks": {name: {"exitCode": int(value)} for name, value in zip(names, sys.argv[1:])}, "valuesPrinted": False}))
PY
exit 1
fi
preserve_rc=-1; install_attempted=0; install_compose_rc=-1; install_caddy_rc=-1
recreate_attempted=0; recreate_rc=-1; runtime_ready_rc=-1; probe_attempted=0; probe_rc=-1
probe_failures="$tmp/probe-failures.tsv"; : >"$probe_failures"
rollback_attempted=0; rollback_compose_rc=-1; rollback_caddy_rc=-1; rollback_recreate_rc=-1
mutation_started=0
if [ "$compose_validate_rc" -eq 0 ] && [ "$validate_rc" -eq 0 ]; then
preserve_rc=0
cp ${shQuote(target.runtime.caddyfilePath)} "$tmp/Caddyfile.previous" || preserve_rc=$?
cp ${shQuote(target.runtime.composePath)} "$tmp/docker-compose.previous.yaml" || preserve_rc=$?
if [ "$preserve_rc" -eq 0 ]; then
install_attempted=1
chmod 0644 "$compose_candidate" && mv "$compose_candidate" ${shQuote(target.runtime.composePath)}
install_compose_rc=$?
if [ "$install_compose_rc" -eq 0 ]; then
mutation_started=1
chmod 0644 "$caddy_candidate" && mv "$caddy_candidate" ${shQuote(target.runtime.caddyfilePath)}
install_caddy_rc=$?
if [ "$install_caddy_rc" -eq 0 ]; then
recreate_attempted=1
docker compose -f ${shQuote(target.runtime.composePath)} up -d --no-build --no-deps --force-recreate caddy >/dev/null 2>"$tmp/recreate.err"; recreate_rc=$?
if [ "$recreate_rc" -eq 0 ]; then
runtime_ready_rc=1
current_managed_sha="$(python3 - "$current_caddy" <<'PY'
import hashlib, pathlib, re, sys
text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
pattern = re.compile(r"(?ms)^# BEGIN unidesk managed (?P<name>[^\\n]+)\\n(?P<body>.*?)\\n# END unidesk managed (?P=name)\\n*")
blocks = [m.group(0).rstrip() for m in pattern.finditer(text) if re.fullmatch(r"public-edge(?:-global|-site:.+)", m.group("name"))]
print("sha256:" + hashlib.sha256(("\\n\\n".join(blocks) + ("\\n" if blocks else "")).encode()).hexdigest() if blocks else "")
PY
)"
current_compose_sha=""
if [ -f ${shQuote(target.runtime.composePath)} ]; then current_compose_sha="sha256:$(sha256sum ${shQuote(target.runtime.composePath)} | awk '{print $1}')"; fi
if [ "$new_running" -eq 1 ] \
&& [ "$installed_commit" = ${shQuote(provenance.sourceCommit)} ] \
&& [ "$installed_caddy_sha" = ${shQuote(desiredCaddyFingerprint)} ] \
&& [ "$installed_compose_sha" = ${shQuote(desiredComposeFingerprint)} ] \
&& [ "$current_managed_sha" = ${shQuote(desiredCaddyFingerprint)} ] \
&& [ "$current_compose_sha" = ${shQuote(desiredComposeFingerprint)} ]; then
python3 - ${shQuote(provenance.authorityId)} ${shQuote(provenance.sourceCommit)} <<'PY'
import json, sys
print(json.dumps({"ok": True, "mutation": False, "mode": "already-current", "authorityId": sys.argv[1], "sourceCommit": sys.argv[2], "valuesPrinted": False}))
PY
exit 0
fi
had_compose=0; had_caddy=0; had_state=0
if [ -f ${shQuote(target.runtime.composePath)} ]; then cp ${shQuote(target.runtime.composePath)} "$tmp/compose.previous"; had_compose=1; fi
if [ -f ${shQuote(target.runtime.caddyfilePath)} ]; then cp ${shQuote(target.runtime.caddyfilePath)} "$tmp/Caddyfile.previous"; had_caddy=1; fi
if [ -f ${shQuote(target.runtime.statePath)} ]; then cp ${shQuote(target.runtime.statePath)} "$tmp/state.previous"; had_state=1; fi
chmod 0644 "$compose_candidate" "$caddy_candidate"
mv "$compose_candidate" ${shQuote(target.runtime.composePath)}
mv "$caddy_candidate" ${shQuote(target.runtime.caddyfilePath)}
if [ "$legacy_running" -eq 1 ]; then docker rm -f ${shQuote(target.runtime.legacy.containerName)} >/dev/null 2>"$tmp/legacy-stop.err"; fi
recreate_rc=0
docker compose -f ${shQuote(target.runtime.composePath)} up -d --no-build --no-deps --force-recreate caddy >/dev/null 2>"$tmp/recreate.err" || recreate_rc=$?
ready_rc=1
if [ "$recreate_rc" -eq 0 ]; then
for attempt in $(seq 1 20); do
if docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.containerName)} 2>/dev/null | grep -qx true && ss -ltn | grep -Eq '[:.]${target.listener.httpPort}[[:space:]]' && ss -ltn | grep -Eq '[:.]${target.listener.httpsPort}[[:space:]]'; then
runtime_ready_rc=0
break
fi
if docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.containerName)} 2>/dev/null | grep -qx true && ss -ltn | grep -Eq '[:.]${target.listener.httpPort}[[:space:]]' && ss -ltn | grep -Eq '[:.]${target.listener.httpsPort}[[:space:]]'; then ready_rc=0; break; fi
sleep 1
done
fi
if [ "$recreate_rc" -eq 0 ] && [ "$runtime_ready_rc" -eq 0 ]; then
probe_attempted=1; probe_rc=0
for item in ${probeItems}; do
host="${"${item%%|*}"}"; path="${"${item#*|}"}"; path="/${"${path#/}"}"
if ! curl --noproxy '*' -fsS --connect-timeout 5 --max-time 20 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null 2>>"$tmp/probe.err"; then
probe_rc=1
printf '%s\t%s\n' "$host" "$path" >>"$probe_failures"
fi
done
fi
fi
fi
fi
fi
if [ "$mutation_started" -eq 1 ] && { [ "$install_caddy_rc" -ne 0 ] || [ "$recreate_rc" -ne 0 ] || [ "$runtime_ready_rc" -ne 0 ]; }; then
rollback_attempted=1
cp "$tmp/docker-compose.previous.yaml" "$compose_rollback" && chmod 0644 "$compose_rollback" && mv "$compose_rollback" ${shQuote(target.runtime.composePath)}
rollback_compose_rc=$?
cp "$tmp/Caddyfile.previous" "$caddy_rollback" && chmod 0644 "$caddy_rollback" && mv "$caddy_rollback" ${shQuote(target.runtime.caddyfilePath)}
rollback_caddy_rc=$?
if [ "$rollback_compose_rc" -eq 0 ] && [ "$rollback_caddy_rc" -eq 0 ]; then
docker compose -f ${shQuote(target.runtime.composePath)} up -d --no-build --no-deps --force-recreate caddy >/dev/null 2>>"$tmp/recreate.err"; rollback_recreate_rc=$?
fi
state_rc=1
if [ "$recreate_rc" -eq 0 ] && [ "$ready_rc" -eq 0 ]; then
state_candidate="$tmp/reconcile-state.json"
python3 - "$state_candidate" ${shQuote(provenance.authorityId)} ${shQuote(provenance.sourceCommit)} ${shQuote(desiredCaddyFingerprint)} ${shQuote(desiredComposeFingerprint)} ${sites.length} <<'PY'
import datetime, json, pathlib, sys
path = pathlib.Path(sys.argv[1])
path.write_text(json.dumps({
"version": 1,
"authorityId": sys.argv[2],
"sourceCommit": sys.argv[3],
"managedCaddyFingerprint": sys.argv[4],
"composeFingerprint": sys.argv[5],
"siteCount": int(sys.argv[6]),
"updatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}, separators=(",", ":")) + "\\n", encoding="utf-8")
PY
chmod 0644 "$state_candidate"
mv "$state_candidate" ${shQuote(target.runtime.statePath)} && state_rc=0
fi
python3 - "$container_rc" "$listeners_rc" "$udp_rc" "$dns_rc" "$candidate_write_rc" "$compose_validate_rc" "$adapt_rc" "$validate_rc" "$preserve_rc" "$install_attempted" "$install_compose_rc" "$install_caddy_rc" "$recreate_attempted" "$recreate_rc" "$runtime_ready_rc" "$probe_attempted" "$probe_rc" "$rollback_attempted" "$rollback_compose_rc" "$rollback_caddy_rc" "$rollback_recreate_rc" "$probe_failures" <<'PY'
rollback_rc=-1
if [ "$recreate_rc" -ne 0 ] || [ "$ready_rc" -ne 0 ] || [ "$state_rc" -ne 0 ]; then
docker rm -f ${shQuote(target.runtime.containerName)} >/dev/null 2>&1 || true
if [ "$had_compose" -eq 1 ]; then cp "$tmp/compose.previous" ${shQuote(target.runtime.composePath)}; else rm -f ${shQuote(target.runtime.composePath)}; fi
if [ "$had_caddy" -eq 1 ]; then cp "$tmp/Caddyfile.previous" ${shQuote(target.runtime.caddyfilePath)}; else rm -f ${shQuote(target.runtime.caddyfilePath)}; fi
if [ "$had_state" -eq 1 ]; then cp "$tmp/state.previous" ${shQuote(target.runtime.statePath)}; else rm -f ${shQuote(target.runtime.statePath)}; fi
rollback_rc=0
if [ "$legacy_running" -eq 1 ]; then
docker compose -f ${shQuote(target.runtime.legacy.composePath)} up -d --no-build --no-deps --force-recreate caddy >/dev/null 2>>"$tmp/rollback.err" || rollback_rc=$?
elif [ "$new_running" -eq 1 ] && [ "$had_compose" -eq 1 ]; then
docker compose -f ${shQuote(target.runtime.composePath)} up -d --no-build --no-deps --force-recreate caddy >/dev/null 2>>"$tmp/rollback.err" || rollback_rc=$?
fi
python3 - "$recreate_rc" "$ready_rc" "$state_rc" "$rollback_rc" <<'PY'
import json, sys
print(json.dumps({"ok": False, "mutation": False, "error": "reconcile-failed", "recreate": {"exitCode": int(sys.argv[1]), "readyExitCode": int(sys.argv[2])}, "provenance": {"exitCode": int(sys.argv[3])}, "rollback": {"exitCode": int(sys.argv[4])}, "valuesPrinted": False}))
PY
exit 1
fi
probe_rc=0
probe_failures="$tmp/probe-failures.tsv"; : >"$probe_failures"
probe_index=0
for item in ${probeItems}; do
probe_index=$((probe_index + 1))
(
host="${"${item%%|*}"}"; path="${"${item#*|}"}"; path="/${"${path#/}"}"
if ! curl --noproxy '*' -fsS --connect-timeout 3 --max-time 10 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null 2>"$tmp/probe-$probe_index.err"; then
printf '%s\t%s\\n' "$host" "$path" >"$tmp/probe-failure-$probe_index.tsv"
fi
) &
done
wait || true
for failure in "$tmp"/probe-failure-*.tsv; do
[ -f "$failure" ] || continue
probe_rc=1
cat "$failure" >>"$probe_failures"
done
python3 - "$probe_rc" "$probe_failures" ${shQuote(provenance.authorityId)} ${shQuote(provenance.sourceCommit)} "$legacy_running" <<'PY'
import json, sys
values = [int(value) for value in sys.argv[1:-1]]
(container, listeners, udp, dns, candidate_write, compose_validate, adapt, validate, preserve,
install_attempted, install_compose, install_caddy, recreate_attempted, recreate, runtime_ready,
probe_attempted, probe, rollback_attempted, rollback_compose, rollback_caddy, rollback_recreate) = values
ok = all(value == 0 for value in [container, listeners, udp, dns, candidate_write, compose_validate, adapt, validate, preserve, install_compose, install_caddy, recreate, runtime_ready]) and rollback_attempted == 0
if rollback_attempted:
rollback_exit = 0 if all(value == 0 for value in [rollback_compose, rollback_caddy, rollback_recreate]) else 1
else:
rollback_exit = None
warnings = []
with open(sys.argv[-1], encoding="utf-8") as handle:
with open(sys.argv[2], encoding="utf-8") as handle:
for line in handle:
hostname, path = line.rstrip("\\n").split("\\t", 1)
warnings.append({"code": "site-probe-failed", "level": "warning", "blocking": False, "hostname": hostname, "path": path})
failure_phase = None
for name, failed in [
("validation", any(value != 0 for value in [container, listeners, udp, dns, candidate_write, compose_validate, adapt, validate])),
("install", preserve >= 0 and (preserve != 0 or (install_attempted == 1 and any(value != 0 for value in [install_compose, install_caddy])))),
("recreate", recreate_attempted == 1 and any(value != 0 for value in [recreate, runtime_ready])),
("rollback", rollback_attempted == 1 and rollback_exit != 0),
]:
if failed:
failure_phase = name
print(json.dumps({
"ok": ok,
"mutation": ok,
"failurePhase": failure_phase,
"preflight": {"container": {"exitCode": container}, "tcpListeners": {"exitCode": listeners}, "preservedUdpListeners": {"exitCode": udp}, "dns": {"exitCode": dns}},
"validation": {"candidateWrite": {"exitCode": candidate_write}, "compose": {"exitCode": compose_validate}, "caddyAdapt": {"exitCode": adapt}, "caddyValidate": {"exitCode": validate}},
"install": {"attempted": bool(install_attempted), "preserveExitCode": preserve, "composeExitCode": install_compose, "caddyfileExitCode": install_caddy, "exitCode": 0 if install_attempted and preserve == install_compose == install_caddy == 0 else 1},
"recreate": {"attempted": bool(recreate_attempted), "exitCode": recreate, "runtimeReadyExitCode": runtime_ready},
"probe": {"attempted": bool(probe_attempted), "exitCode": probe, "blocking": False},
"ok": True,
"mutation": True,
"authorityId": sys.argv[3],
"sourceCommit": sys.argv[4],
"legacyRetired": sys.argv[5] == "1",
"probe": {"attempted": True, "exitCode": int(sys.argv[1]), "blocking": False},
"warnings": warnings,
"rollback": {"attempted": bool(rollback_attempted), "composeExitCode": rollback_compose, "caddyfileExitCode": rollback_caddy, "recreateExitCode": rollback_recreate, "exitCode": rollback_exit},
"valuesPrinted": False,
}))
raise SystemExit(0 if ok else 1)
PY
`;
}
function statusScript(target: PublicEdgeTarget, sites: ResolvedSite[], desiredCaddyfile: string | null): string {
function statusScript(
target: PublicEdgeTarget,
sites: ResolvedSite[],
desiredCaddyfile: string | null,
desiredCompose: string | null,
desiredSourceCommit: string | null,
authorityId: string,
): string {
const probeItems = sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}|${site.expectedA}`)).join(" ");
const desiredFingerprint = desiredCaddyfile === null ? "" : sha256Fingerprint(desiredCaddyfile);
const desiredComposeFingerprint = desiredCompose === null ? "" : sha256Fingerprint(desiredCompose);
return `set -u
container_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.containerName)} 2>/dev/null | grep -qx true || container_rc=1
legacy_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.legacy.containerName)} 2>/dev/null | grep -qx true || legacy_rc=1
listeners_rc=0; ss -ltn | grep -Eq '[:.]${target.listener.httpPort}[[:space:]]' || listeners_rc=1; ss -ltn | grep -Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc=1
udp_rc=0
${target.isolation.preservedUdpPorts.map((value) => `ss -lun | grep -Eq '[:.]${value}[[:space:]]' || udp_rc=1`).join("\n")}
validate_rc=0; docker exec ${shQuote(target.runtime.containerName)} caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>/dev/null || validate_rc=1
sites_rc=0; dns_rc=0
sites_rc=0; dns_rc=0; probe_index=0
for item in ${probeItems}; do
probe_index=$((probe_index + 1))
(
host="${"${item%%|*}"}"; rest="${"${item#*|}"}"; path="/${"${rest%%|*}"}"; path="/${"${path#/}"}"; expected="${"${rest#*|}"}"
getent ahostsv4 "$host" | awk '{print $1}' | grep -Fxq "$expected" || dns_rc=1
curl --noproxy '*' -fsS --connect-timeout 5 --max-time 20 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null || sites_rc=1
getent ahostsv4 "$host" | awk '{print $1}' | grep -Fxq "$expected" || : >"/tmp/public-edge-dns-$$-$probe_index.failed"
curl --noproxy '*' -fsS --connect-timeout 3 --max-time 10 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null || : >"/tmp/public-edge-site-$$-$probe_index.failed"
) &
done
current_sha=""; if [ -f ${shQuote(target.runtime.caddyfilePath)} ]; then current_sha="sha256:$(sha256sum ${shQuote(target.runtime.caddyfilePath)} | awk '{print $1}')"; fi
python3 - "$container_rc" "$listeners_rc" "$udp_rc" "$validate_rc" "$dns_rc" "$sites_rc" "$current_sha" ${shQuote(desiredFingerprint)} <<'PY'
import json, sys
container, listeners, udp, validate, dns, sites = [int(value) for value in sys.argv[1:7]]
current, desired = sys.argv[7:9]
ok = all(value == 0 for value in [container, listeners, udp, validate, dns, sites])
print(json.dumps({"ok": ok, "container": {"exitCode": container}, "tcpListeners": {"exitCode": listeners}, "preservedUdpListeners": {"exitCode": udp}, "validate": {"exitCode": validate}, "dns": {"exitCode": dns}, "siteProbes": {"exitCode": sites}, "config": {"currentFingerprint": current or None, "desiredFingerprint": desired or None, "matchesDesired": bool(desired) and current == desired}, "valuesPrinted": False}))
wait || true
for failure in /tmp/public-edge-dns-$$-*.failed; do [ -f "$failure" ] || continue; dns_rc=1; rm -f "$failure"; done
for failure in /tmp/public-edge-site-$$-*.failed; do [ -f "$failure" ] || continue; sites_rc=1; rm -f "$failure"; done
current_sha=""
if [ -f ${shQuote(target.runtime.caddyfilePath)} ]; then
current_sha="$(python3 - ${shQuote(target.runtime.caddyfilePath)} <<'PY'
import hashlib, pathlib, re, sys
text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
pattern = re.compile(r"(?ms)^# BEGIN unidesk managed (?P<name>[^\\n]+)\\n(?P<body>.*?)\\n# END unidesk managed (?P=name)\\n*")
blocks = [m.group(0).rstrip() for m in pattern.finditer(text) if re.fullmatch(r"public-edge(?:-global|-site:.+)", m.group("name"))]
print("sha256:" + hashlib.sha256(("\\n\\n".join(blocks) + ("\\n" if blocks else "")).encode()).hexdigest() if blocks else "")
PY
)"
fi
current_compose_sha=""
if [ -f ${shQuote(target.runtime.composePath)} ]; then current_compose_sha="sha256:$(sha256sum ${shQuote(target.runtime.composePath)} | awk '{print $1}')"; fi
python3 - "$container_rc" "$legacy_rc" "$listeners_rc" "$udp_rc" "$validate_rc" "$dns_rc" "$sites_rc" "$current_sha" ${shQuote(desiredFingerprint)} "$current_compose_sha" ${shQuote(desiredComposeFingerprint)} ${shQuote(desiredSourceCommit ?? "")} ${shQuote(authorityId)} ${shQuote(target.runtime.statePath)} <<'PY'
import json, pathlib, sys
container, legacy, listeners, udp, validate, dns, sites = [int(value) for value in sys.argv[1:8]]
current, desired, current_compose, desired_compose, desired_commit, desired_authority, state_path = sys.argv[8:15]
state = None
try:
state = json.loads(pathlib.Path(state_path).read_text(encoding="utf-8"))
except FileNotFoundError:
pass
except Exception as error:
state = {"error": "installed-provenance-invalid", "detail": str(error)}
state_matches_runtime = (
state is not None
and "error" not in state
and state.get("version") == 1
and state.get("authorityId") == desired_authority
and state.get("sourceCommit") == desired_commit
and state.get("managedCaddyFingerprint") == current
and state.get("composeFingerprint") == current_compose
)
config_matches_desired = bool(desired) and current == desired and bool(desired_compose) and current_compose == desired_compose
ok = all(value == 0 for value in [container, listeners, udp, validate, dns, sites]) and legacy != 0 and state_matches_runtime and config_matches_desired
print(json.dumps({"ok": ok, "container": {"exitCode": container}, "legacyContainer": {"exitCode": legacy, "retired": legacy != 0}, "tcpListeners": {"exitCode": listeners}, "preservedUdpListeners": {"exitCode": udp}, "validate": {"exitCode": validate}, "dns": {"exitCode": dns}, "siteProbes": {"exitCode": sites}, "provenance": {"installed": state, "desiredSourceCommit": desired_commit or None, "matchesRuntime": state_matches_runtime}, "config": {"currentManagedFingerprint": current or None, "desiredManagedFingerprint": desired or None, "currentComposeFingerprint": current_compose or None, "desiredComposeFingerprint": desired_compose or None, "matchesDesired": config_matches_desired}, "valuesPrinted": False}))
raise SystemExit(0 if ok else 1)
PY
`;
@@ -612,10 +915,30 @@ function targetSummary(target: PublicEdgeTarget): Record<string, unknown> {
composePath: target.runtime.composePath,
dataDir: target.runtime.dataDir,
configDir: target.runtime.configDir,
statePath: target.runtime.statePath,
lockPath: target.runtime.lockPath,
legacyContainerName: target.runtime.legacy.containerName,
dnsServers: target.runtime.dnsServers,
};
}
function readAncestry(path: string | null, sourceCommit: string | null): string[] {
if (path === null || sourceCommit === null || !existsSync(path)) {
throw inputError("reconcile ancestry file 不存在", "reconcile-ancestry-missing", "--ancestry-file");
}
const commits = readFileSync(path, "utf8").split(/\r?\n/u).filter((value) => value.length > 0);
if (commits.length === 0 || commits.some((value) => !/^[0-9a-f]{40}$/u.test(value)) || !commits.includes(sourceCommit)) {
throw inputError("reconcile ancestry file 无效或不包含 candidate commit", "reconcile-ancestry-invalid", "--ancestry-file");
}
return commits;
}
function checkoutSourceCommit(): string | null {
const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: resolveRepoPath("."), encoding: "utf8" });
const value = result.status === 0 ? result.stdout.trim() : "";
return /^[0-9a-f]{40}$/u.test(value) ? value : null;
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} 必须是对象`);
return value as Record<string, unknown>;
@@ -625,6 +948,11 @@ function optionalRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`${path} 必须是布尔值`);
return value;
}
function array(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`${path} 必须是数组`);
return value;
@@ -697,6 +1025,12 @@ function yamlConfigPath(value: unknown, path: string): string {
return result;
}
function gitCommit(value: unknown, path: string): string {
const result = string(value, path);
if (!/^[0-9a-f]{40}$/u.test(result)) throw new Error(`${path} 40 Git commit`);
return result;
}
function duration(value: unknown, path: string): string {
const result = string(value, path);
if (!/^[1-9][0-9]*(?:ms|s|m)$/u.test(result)) throw new Error(`${path} mss m`);
+1 -1
View File
@@ -203,7 +203,7 @@ async function apply(config: UniDeskConfig, options: ReturnType<typeof parseOpsA
next: {
status: `bun scripts/cli.ts platform-infra temporal status --target ${target.id}`,
validate: `bun scripts/cli.ts platform-infra temporal validate --target ${target.id}`,
publicEdge: `bun scripts/cli.ts platform-infra public-edge apply --target ${target.id} --confirm`,
publicEdge: `bun scripts/cli.ts platform-infra public-edge status --target ${target.id}`,
},
};
}