fix(cicd): 收敛 public-edge 单一写入权
This commit is contained in:
@@ -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 业务语义;
|
||||
|
||||
@@ -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 时,立即建立可追踪记录;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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-run;mutation 只接受 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=$?
|
||||
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
|
||||
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=$?
|
||||
|
||||
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
|
||||
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=$?
|
||||
|
||||
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
|
||||
runtime_ready_rc=1
|
||||
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"
|
||||
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
|
||||
done
|
||||
|
||||
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
|
||||
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
|
||||
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'
|
||||
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} 必须是正整数加 ms、s 或 m`);
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user