From 866afde16b2eb208da7ce5ad21dad584ab70cf52 Mon Sep 17 00:00:00 2001 From: pikastech Date: Sat, 18 Jul 2026 17:59:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(cicd):=20=E6=94=B6=E6=95=9B=20public-edge?= =?UTF-8?q?=20=E5=8D=95=E4=B8=80=E5=86=99=E5=85=A5=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-cicd/SKILL.md | 41 +- .agents/skills/unidesk-devlevel/SKILL.md | 6 + .agents/skills/unidesk-sub2api/SKILL.md | 6 + .agents/skills/unidesk-temporal/SKILL.md | 3 +- .agents/skills/unidesk-ymalops/SKILL.md | 8 + .tekton/platform-infra-gitea-nc01-pac.yaml | 30 + config/platform-infra/public-edge.yaml | 38 +- config/secrets-distribution.yaml | 20 + docs/reference/pikaoa.md | 4 +- docs/reference/platform-infra.md | 59 +- .../specs/PJ2026-010604-public-entry.md | 14 +- ...ply-platform-infra-public-edge-archive.mjs | 74 +++ .../deliver-platform-infra-public-edge.mjs | 237 +++++++ scripts/src/platform-infra-public-edge.ts | 624 ++++++++++++++---- scripts/src/platform-infra-temporal.ts | 2 +- 15 files changed, 987 insertions(+), 179 deletions(-) create mode 100644 scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs create mode 100644 scripts/native/cicd/deliver-platform-infra-public-edge.mjs diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 85ab56e7..9783a18a 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -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 业务语义; diff --git a/.agents/skills/unidesk-devlevel/SKILL.md b/.agents/skills/unidesk-devlevel/SKILL.md index 590378e7..7a7ba5dd 100644 --- a/.agents/skills/unidesk-devlevel/SKILL.md +++ b/.agents/skills/unidesk-devlevel/SKILL.md @@ -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 时,立即建立可追踪记录; diff --git a/.agents/skills/unidesk-sub2api/SKILL.md b/.agents/skills/unidesk-sub2api/SKILL.md index f6544b2a..1bb19afb 100644 --- a/.agents/skills/unidesk-sub2api/SKILL.md +++ b/.agents/skills/unidesk-sub2api/SKILL.md @@ -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 diff --git a/.agents/skills/unidesk-temporal/SKILL.md b/.agents/skills/unidesk-temporal/SKILL.md index c969b04a..3f17bb9a 100644 --- a/.agents/skills/unidesk-temporal/SKILL.md +++ b/.agents/skills/unidesk-temporal/SKILL.md @@ -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 ``` diff --git a/.agents/skills/unidesk-ymalops/SKILL.md b/.agents/skills/unidesk-ymalops/SKILL.md index badfd4c2..4d829f18 100644 --- a/.agents/skills/unidesk-ymalops/SKILL.md +++ b/.agents/skills/unidesk-ymalops/SKILL.md @@ -56,6 +56,14 @@ description: UniDesk YAML-first 运维正规化技能。用户提到 ymal-first/ - 先执行 `git status --short --branch` 和 `git pull --ff-only origin master`;保留并绕开无关并行修改。 - 源码、配置、部署类正规化默认在独立 `.worktree/` 中做;轻量 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 或自动交付成功证据; diff --git a/.tekton/platform-infra-gitea-nc01-pac.yaml b/.tekton/platform-infra-gitea-nc01-pac.yaml index aac6f5e0..790ed0f9 100644 --- a/.tekton/platform-infra-gitea-nc01-pac.yaml +++ b/.tekton/platform-infra-gitea-nc01-pac.yaml @@ -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 diff --git a/config/platform-infra/public-edge.yaml b/config/platform-infra/public-edge.yaml index af8f89cc..d0fc56da 100644 --- a/config/platform-infra/public-edge.yaml +++ b/config/platform-infra/public-edge.yaml @@ -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 diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index 82fbb637..05d2c718 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -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 diff --git a/docs/reference/pikaoa.md b/docs/reference/pikaoa.md index 4bfe5382..333e3328 100644 --- a/docs/reference/pikaoa.md +++ b/docs/reference/pikaoa.md @@ -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; diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index f1e10797..b2a1ef72 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -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..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:` 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 ``` diff --git a/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md b/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md index 8046ece8..424e141f 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md +++ b/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md @@ -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 边界 diff --git a/scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs b/scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs new file mode 100644 index 00000000..a14f4040 --- /dev/null +++ b/scripts/native/cicd/apply-platform-infra-public-edge-archive.mjs @@ -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); +} diff --git a/scripts/native/cicd/deliver-platform-infra-public-edge.mjs b/scripts/native/cicd/deliver-platform-infra-public-edge.mjs new file mode 100644 index 00000000..f398dc38 --- /dev/null +++ b/scripts/native/cicd/deliver-platform-infra-public-edge.mjs @@ -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); +} diff --git a/scripts/src/platform-infra-public-edge.ts b/scripts/src/platform-infra-public-edge.ts index 9748bfbc..f94cf96c 100644 --- a/scripts/src/platform-infra-public-edge.ts +++ b/scripts/src/platform-infra-public-edge.ts @@ -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 { 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 { "平台 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 ?? ""); + if (action !== "plan" && action !== "apply" && action !== "reconcile" && action !== "status") { + throw inputError("public-edge action 必须是 plan、apply、reconcile 或 status", "invalid-action", action ?? ""); } 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 { @@ -168,6 +215,18 @@ function readConfig(configPath: string): Record { return readYamlRecord(configPath, "platform-infra-public-edge"); } +function readDelivery(root: Record): 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, requestedTargetId: string | null): PublicEdgeTarget { const defaults = record(root.defaults, "defaults"); const targets = record(root.targets, "targets"); @@ -176,6 +235,7 @@ function readTarget(root: Record, 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, 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, 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 { + 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 { 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> { +async function reconcile( + config: UniDeskConfig, + configPath: string, + delivery: PublicEdgeDelivery, + target: PublicEdgeTarget, + sites: ResolvedSite[], + sourceCommit: string, + ancestry: string[], +): Promise> { 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> { +async function status( + config: UniDeskConfig, + configPath: string, + delivery: PublicEdgeDelivery, + target: PublicEdgeTarget, + resolution: ResolutionResult, +): Promise> { 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[^\\n]+)\\n(?P.*?)\\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 -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 - 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 - 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 -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' +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 + +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[^\\n]+)\\n(?P.*?)\\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 ready_rc=0; break; fi + sleep 1 + done +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 + +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 - 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 + probe_index=$((probe_index + 1)) + ( + host="${"${item%%|*}"}"; rest="${"${item#*|}"}"; path="/${"${rest%%|*}"}"; path="/${"${path#/}"}"; expected="${"${rest#*|}"}" + 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[^\\n]+)\\n(?P.*?)\\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 { 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 { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} 必须是对象`); return value as Record; @@ -625,6 +948,11 @@ function optionalRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : 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`); diff --git a/scripts/src/platform-infra-temporal.ts b/scripts/src/platform-infra-temporal.ts index 26d38d5b..76113cd6 100644 --- a/scripts/src/platform-infra-temporal.ts +++ b/scripts/src/platform-infra-temporal.ts @@ -203,7 +203,7 @@ async function apply(config: UniDeskConfig, options: ReturnType