From b3421ac9cfac50779add64e812d67806ca43bf6a Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 1 Sep 2026 10:12:32 +0800 Subject: [PATCH] feat(ql3): prove Console adopted cutover entry --- .github/workflows/ql3-ci.yml | 32 ++- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 4 +- ...xact-headless-upgrade-cutover-rehearsal.md | 2 +- ...5-console-adopted-target-entry-evidence.md | 51 +++++ docs/adr/README.md | 1 + docs/operations/ql3-alpha-candidate.md | 3 +- docs/operations/ql3-local-alpha-trial-kit.md | 8 +- docs/operations/ql3-local-web-console.md | 4 +- packages/ql3-local-api/src/cli.ts | 49 ++--- .../src/production-process/cliCommand.ts | 49 +++++ .../production-process/cutoverProbeProcess.ts | 72 +++++++ packages/ql3-local-api/test/cli.test.cjs | 2 +- .../ql3-local-api/test/cliCommand.test.cjs | 49 +++++ .../test/cutoverProbeProcess.test.cjs | 71 +++++++ packages/ql3-local-application/package.json | 5 + .../cutover/target-run/targetRunContract.ts | 83 ++++++-- .../src/deployment/cutover/targetEvidence.ts | 114 ++++++++++- .../test/cutoverTargetRun.test.cjs | 188 ++++++++++++------ scripts/ql3-local-alpha-trial-kit-bundle.cjs | 1 - ...3-local-alpha-upgrade-cutover-rehearsal.sh | 24 ++- .../back/ql3LocalAlphaTrialKitBundle.test.cjs | 15 +- test/back/ql3PackageBoundaryAudit.test.cjs | 6 +- 22 files changed, 666 insertions(+), 167 deletions(-) create mode 100644 docs/adr/ADR-0525-console-adopted-target-entry-evidence.md create mode 100644 packages/ql3-local-api/src/production-process/cliCommand.ts create mode 100644 packages/ql3-local-api/src/production-process/cutoverProbeProcess.ts create mode 100644 packages/ql3-local-api/test/cliCommand.test.cjs create mode 100644 packages/ql3-local-api/test/cutoverProbeProcess.test.cjs diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 4fad8013..96218342 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -787,31 +787,25 @@ jobs: LEGACY_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-upgrade-legacy-${{ matrix.image_arch }}" TARGET_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-upgrade-target-${{ matrix.image_arch }}" trap 'docker rm --force "${QUICKSTART_CONTAINER}" "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}" >/dev/null 2>&1 || true' EXIT - if [[ "${TRIAL_VARIANT}" == 'headless' ]]; then - sh "${BUNDLE_ROOT}/upgrade-cutover-rehearsal.sh" \ - edge "${LEGACY_READINESS_ROOT}" "${UPGRADE_REHEARSAL_ROOT}" \ - "${SQLITE_PLAN_DIGEST}" "${DIRECTORY_PLAN_DIGEST}" \ - "${LEGACY_CONTAINER}" "${TARGET_CONTAINER}" - else - sh "${BUNDLE_ROOT}/upgrade-rehearsal.sh" \ - edge "${LEGACY_READINESS_ROOT}" "${UPGRADE_REHEARSAL_ROOT}" \ - "${SQLITE_PLAN_DIGEST}" "${DIRECTORY_PLAN_DIGEST}" - fi + sh "${BUNDLE_ROOT}/upgrade-cutover-rehearsal.sh" \ + edge "${LEGACY_READINESS_ROOT}" "${UPGRADE_REHEARSAL_ROOT}" \ + "${SQLITE_PLAN_DIGEST}" "${DIRECTORY_PLAN_DIGEST}" \ + "${LEGACY_CONTAINER}" "${TARGET_CONTAINER}" grep -q '"status":"verified"' \ "${UPGRADE_REHEARSAL_ROOT}/stage-summary.json" grep -q '"legacySource":"read_only"' \ "${UPGRADE_REHEARSAL_ROOT}/stage-summary.json" grep -q '"cutover":"not_authorized"' \ "${UPGRADE_REHEARSAL_ROOT}/stage-summary.json" - if [[ "${TRIAL_VARIANT}" == 'headless' ]]; then - grep -q '"status":"rollback_candidate"' \ - "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" - grep -q '"legacySource":"unchanged"' \ - "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" - grep -q '"target":"stopped"' \ - "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" - docker rm "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}" - fi + grep -q '"status":"rollback_candidate"' \ + "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" + grep -q '"variant":"'"${TRIAL_VARIANT}"'"' \ + "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" + grep -q '"legacySource":"unchanged"' \ + "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" + grep -q '"target":"stopped"' \ + "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" + docker rm "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}" test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-wal" test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-journal" trap - EXIT diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 0c817f7b..82137d3e 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,5 +1,7 @@ # QingLong 3.0 Architecture RFC +- D-426b2c/ADR-0525(源码与 CI 门禁已实现,exact Console 双架构阶段实物待生成):Console adopted target 不再借用 fresh HTTP journey,也不以普通 Local API 启动破坏 clean rollback。`ql3-local-api` 新增显式 `--cutover-probe --config `:严格验证外层 loopback/deployment/Owner 配置后委托既有 Application 只读 probe,不绑定 listener、不读取 credential/pepper、不激活 recovery、scheduler、execution、plugin recovery 或产品管理面。Owner target command 可选绑定 Local API 宿主/容器配置路径,target evidence 同时摘要并校验外层 API、内层 Application、严格不同的 target path、exact read-only mounts 与 `['--cutover-probe','--config',expectedApiPath]`;省略该字段时 headless command/journal digest 不变。Trial Kit cutover summary 升为 v2,绑定 variant 与 `local-application|local-api` entrypoint;原生 workflow 对 headless/Console 均要求 `legacyUpgradeCutover=passed`,同时保留真实 Console listener/API/credential/Task journey 作为独立门。没有新增 workspace package、依赖、daemon、sidecar、timer、watcher、连接池或稳态资源;默认低配路由设备仍选择 headless,Cluster 不复用 Local POSIX/SQLite/Docker proof。Local API 80/80、Owner CLI 314 total/307 pass/7 conditional skip/0 fail、Trial Kit 12/12、Application 56 total/51 pass/5 conditional skip/0 fail,package/Cluster/Edge/image 审计 compatible。本机 exact Console image build 被配置的 `register.liberx.info` 固定 Node base image EOF 阻断,未进入源码构建,因此当前不能写成“阶段实物已交付”;只有同源原生 amd64/arm64 artifact、milestone 与离线 auditor 在成功终态 CI 闭合后才成立。 + - D-426b2b/ADR-0524(已交付同源双架构 headless Alpha 阶段实物):exact 上传 Trial Kit 已把 D-426b1 的离线 image authority 与 D-426b2a 的 post-apply baseline 接入完整用户切换链。真实 artifact 预演暴露出普通 Application 启动会在 scheduler/recovery 激活期间改变 adopted SQLite,因而不能同时充当“未接收写入”的 clean rollback 证明;3.0 没有放宽 classifier 或重置基线,而是新增显式 `--cutover-probe` 进程,只加载 exact v4 config、验证 legacy/data-application/cutover commitment、以只读 SQLite readiness 打开 target、在数据库外发布 process-bound start/stop receipt,并保持 recovery、plugin recovery、execution、scheduler 和 product admission 全部冻结。Owner target evidence 固定要求 `['--cutover-probe','--config',expectedPath]`,普通 Application 命令不能冒充 probe;依赖审计只允许该 production-process 文件导入只读 readiness subpath,仍拒绝 writable runtime。提交 `79045a0d439074994812d9cd682f933b9e415706` 的显式 Local headless [run 33326143744](https://github.com/whyour/qinglong/actions/runs/33326143744) 为 `42 success / 2 expected scope skip / 0 fail`,两个原生架构均在 exact bundle 上完成 readiness、reviewed stage、Owner credential presentation、transform/apply、真实 legacy stop、probe start/stop 和 clean `rollback_candidate`,finalizer 生成 milestone v5。amd64/arm64/milestone artifact ID 为 `9736356778`/`9736354298`/`9736502478`,GitHub 压缩大小为 `226206170`/`221605850`/`6492` bytes,保留至 2026-09-29;下载后三个仓库离线 auditor 均返回 `compatible=true`。内部 Docker archive digest 为 amd64 `sha256:1e1c5c83fd2c39b3bbe7b194113998a96cbe810e69d34858c3f40d2638837c60`、arm64 `sha256:dcec37f65382d7d8c06f448780878ec2474e45d6e64b2febb764b1836898d2d6`,verification v6 的漏洞、SBOM、128 MiB entrypoint、fresh lifecycle、API cancellation 与 legacy readiness/stage/cutover 全为 `passed`。同 run 的 128 MiB/0.5 CPU/64 PID router stress 记录 x64/arm64 peak `77967360`/`72581120` bytes,但仍明确不是物理设备最低配置承诺。本阶段实物只证明隔离合成数据上的 exact 切换链和“target 未产生业务写入”的 clean rollback candidate;不停止用户真实 2.x、不授权 Legacy restart、写后 reconciliation、生产 cutover、Public Release 或 LTS。 - D-426b2a/ADR-0523(已由 D-426b2b 双架构阶段实物闭合):修复 D-426b 的真实架构矛盾:受认证 `local-data-directory.adoption.apply` 必然在 target 启动前改变 activation 中记录的 SQLite 内容摘要,因此旧 classifier 会把“apply 后未产生任何 target 写入”的合法停止错误判为 `reconciliation_required`。现仅为 `docker-target` adopted Application v4 发布 no-replace 私有 `service/adopted-target-baseline.json`,绑定 activation/legacy commitment、Application semantic digest、data application commit/receipt、target path/device/inode/SHA-256 和 sidecar-clear 事实;v4 target start/stop 必须闭合该基线,缺失或漂移进入 `manual_review`,启动后写入进入 `reconciliation_required`,未写入才得到 `rollback_candidate`。v3/fresh 与历史 journal shape/activation 语义保持不变;新停止证据同时保留真实 `targetMatchesActivation` 并增加 `baselineKind/baselineDigest/targetMatchesBaseline`。Local Owner CLI 完整包级门为 `308 total / 301 pass / 7 conditional skip / 0 fail`。没有新增 workspace package、daemon、listener、timer、watcher 或稳态资源;D-426b2b 的 exact 上传 bundle 已实跑 reviewed stage、transform/verify、Owner 强认证 apply/verify、真实 legacy stop、只读 target probe start/stop 与 clean rollback,并生成同源双架构 artifact。该闭合仍不授权 target 接受业务写入后的回退。 @@ -24,7 +26,7 @@ - 目标版本:QingLong 3.x - 作者:QingLong Maintainers - 创建日期:2026-07-17 -- 最后更新:2026-08-31 +- 最后更新:2026-09-01 - 讨论范围:架构与演进路线,不包含最终 UI 视觉方案 最新增量证据(2026-08-29): diff --git a/docs/adr/ADR-0524-exact-headless-upgrade-cutover-rehearsal.md b/docs/adr/ADR-0524-exact-headless-upgrade-cutover-rehearsal.md index d5b97dd0..f8cbec38 100644 --- a/docs/adr/ADR-0524-exact-headless-upgrade-cutover-rehearsal.md +++ b/docs/adr/ADR-0524-exact-headless-upgrade-cutover-rehearsal.md @@ -48,4 +48,4 @@ ADR-0523 已修正 Apply 后 Adopted Target 的回退基线,但仓库内单元 ## 后续 -D-426b2c 评估 Console adopted target 的显式双进程/入口证据模型;D-426c 继续处理 target 写入后的 capture、review、reconciliation 与恢复。两者都不得削弱 headless 已闭合的离线镜像和回退基线。 +D-426b2c 已由 ADR-0525 实现为 Console Local API 外层入口与 Application 内层配置的双重证据,且保持 headless 既有 command/journal shape;exact Console 双架构 artifact 闭合仍待成功终态 CI。D-426c 继续处理 target 写入后的 capture、review、reconciliation 与恢复,不得削弱 headless 已闭合的离线镜像和回退基线。 diff --git a/docs/adr/ADR-0525-console-adopted-target-entry-evidence.md b/docs/adr/ADR-0525-console-adopted-target-entry-evidence.md new file mode 100644 index 00000000..9646c30b --- /dev/null +++ b/docs/adr/ADR-0525-console-adopted-target-entry-evidence.md @@ -0,0 +1,51 @@ +# ADR-0525:Console Adopted Target 入口证据 + +- 状态:Accepted(源码与 CI 门禁已实现;exact Console 双架构阶段实物待生成) +- 日期:2026-09-01 +- 决策:D-426b2c +- 关联:ADR-0512、ADR-0513、ADR-0522、ADR-0523、ADR-0524 + +## 上下文 + +ADR-0524 已用 headless Trial Kit 的只读 Application cutover probe 闭合 clean rollback 证据,但 Console 镜像的生产入口是 `ql3-local-api`。普通 Local API 启动会同时启动 Application、loopback listener、认证操作面、recovery、scheduler 与 execution admission;直接把它用于切换探针可能改变 adopted SQLite,既不适合低性能路由设备,也不能证明“target 尚未接收业务写入”。 + +把 Console artifact 继续标记为 `legacyUpgradeCutover=not_applicable` 又会留下另一类缺口:fresh Console journey 只能证明面板可用,不能证明下载到的 Console 镜像可由 adopted target controller 以 exact 生产入口启动。 + +## 决策 + +1. `ql3-local-api` 增加显式 `--cutover-probe --config ` 模式。它严格解析 Local API 配置,随后调用既有 Application 只读 cutover probe;不绑定 listener、不读取 credential 或 pepper、不启动 recovery、scheduler、execution、plugin recovery 或产品管理面,也不持有可写数据库。 +2. Target run command 增加可选 `targetApi` 入口绑定:宿主 Local API 配置路径和容器内 exact 入口配置路径。省略该字段时,既有 headless command、journal digest 与证据 shape 保持不变。 +3. Console target evidence 同时绑定外层 Local API 配置与内层 Application 配置: + - 外层 schema、配置摘要、loopback host/port、deployment root、严格位于 root 内的 Owner pepper 目录; + - 外层 `applicationConfigFilePath` 必须指向 exact 内层 Application target path;两个配置 target path 必须不同; + - 两份只读配置 mount、deployment root mount、数据库与既有 activation/recovery/manifest/legacy mounts; + - 容器命令必须精确为 `['--cutover-probe','--config',expectedLocalApiPath]`。 +4. Trial Kit 的 canonical `upgrade-cutover-rehearsal.sh` 同时支持 `headless|console`。Console rehearsal 生成私有外层配置,运行同一 reviewed stage、Owner 强认证 apply、真实 synthetic Legacy stop、Console image probe start/stop 与 clean rollback classifier;成功 summary 升级为 `qinglong/local-alpha-upgrade-cutover-summary@v2`,并绑定 variant 与 target entrypoint。 +5. 原生 amd64/arm64 artifact workflow 对两个变体都要求 `legacyUpgradeCutover=passed`,并在上传前删除 synthetic target/legacy 容器。fresh Console journey 仍单独证明真实 listener、HTTP、credential 与自动化能力,不能由无 listener probe 替代。 + +## Profile 与资源边界 + +- Edge/路由设备:默认仍选择 headless;若显式选择 Console,cutover probe 只增加一次性配置读取与只读 SQLite readiness,不新增 listener、daemon、timer、watcher、连接池或稳态 RSS。 +- Standalone:与 Edge 使用同一证据模型,只保留现有 Profile 资源上限差异。 +- Cluster:不复用 Local SQLite、POSIX owner、loopback Console 或 Docker target proof;Cluster control/admin/worker 部署链不变。 + +## 被拒绝的方案 + +- 用普通 `ql3-local-api --config` 做 probe:会激活写能力,破坏 clean rollback 语义。 +- 只校验 Application 内层配置:不能证明下载镜像的真实 Console entrypoint 与 mount authority。 +- 为 Console 增加独立 probe daemon/sidecar:扩大低配设备常驻面,且制造第二套生命周期。 +- 把 fresh Console HTTP journey 当作 upgrade cutover:两者验证的权限、数据状态与失败恢复语义不同。 + +## 验证与交付状态 + +- Local API:80/80;cutover probe 单测覆盖成功、配置漂移与不启动 listener; +- Local Owner CLI:314 total / 307 pass / 7 conditional skip / 0 fail(新增双配置正向、内层指向漂移、路径别名与 Owner 目录边界); +- Trial Kit bundle:12/12;Console gate 从 `not_applicable` 改为 `passed`,summary v2 绑定 `variant=console` 与 `targetEntrypoint=local-api`; +- Local Application:56 total / 51 pass / 5 conditional skip / 0 fail; +- package boundary、Cluster dependency、Edge import 与 Local image/operator image audit 均为 compatible;workspace package 仍为 18 个,未新增依赖、package 或常驻进程。 + +本机 exact Console image build 尚未形成阶段实物:Docker 配置的镜像镜像源 `register.liberx.info` 在解析固定 Node base image 时连续 EOF,构建未进入源码层。因此本 ADR 当前只声明“源码与 CI 门禁已实现”;只有同源原生 amd64/arm64 Console artifact、milestone 与离线 auditor 在成功终态 workflow 中闭合后,才能升级为“阶段实物已交付”。 + +## 后续 + +D-426c 继续处理 target 接收写入后的 capture、review、reconciliation 与恢复;Public Release 仍需受保护 tag、immutable multi-arch digest、签名/attestation、deployment lock 与生产回退门。 diff --git a/docs/adr/README.md b/docs/adr/README.md index f4201153..ea3e6f48 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -528,6 +528,7 @@ | [ADR-0522](./ADR-0522-content-bound-offline-docker-adopted-target.md) | 内容绑定的离线 Docker Adopted Target | Accepted(D-426b 权威已由 D-426b2b 双架构实物闭合) | | [ADR-0523](./ADR-0523-post-apply-adopted-target-baseline.md) | Apply 后的 Adopted Target 启动前基线 | Accepted(D-426b2a 基线已由 D-426b2b 双架构实物闭合) | | [ADR-0524](./ADR-0524-exact-headless-upgrade-cutover-rehearsal.md) | Exact Headless 升级切换链演练 | Accepted(D-426b2b 双架构阶段实物已交付) | +| [ADR-0525](./ADR-0525-console-adopted-target-entry-evidence.md) | Console Adopted Target 入口证据 | Accepted(源码与 CI 门禁已实现;exact Console 双架构阶段实物待生成) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index 268b08a1..c6e963e3 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -15,7 +15,7 @@ 当维护者显式选择 `alpha_artifact_scope=all` 时,还会生成 `Alpha stage index`。它把同一次 run 的 Local/Cluster milestone 交叉绑定,并为 Edge、Standalone、Cluster 给出目标架构的最小 artifact 选择;这是阶段交付导航,不是正式 release catalog。只生成 Local 或 Cluster 时,各自 milestone 仍可独立成立,不制造一个不完整的总索引。 -## 当前阶段实物(2026-08-31) +## 当前阶段实物(2026-09-01) 在下面保留的历史 exact-image 证据之外,2026-08-28 的源码阶段已把 headless 用户旅程与 opt-in Console 合并为一条可选择的交付链: @@ -33,6 +33,7 @@ | D-425 2.x 升级就绪盘点 | 同源 v6 Trial Kit 已交付 amd64/arm64 headless 阶段实物;canonical `upgrade-readiness.sh` 把 2.x root 只读挂载,在 128 MiB/无网络边界内由 exact Operator 生成 SQLite 与完整目录两个计划;artifact job 实跑、bundle auditor 与 milestone v3 均闭合 | 只完成 inspect,不授权 stage、activation、cutover 或 rollback;不是 Public Release | | D-426a Side-by-side 暂存 | 同源 v7 Trial Kit 已交付 amd64/arm64 headless 阶段实物;reviewed-plan `upgrade-rehearsal.sh` 在新的私有 root 中执行 SQLite stage/verify/activation 与完整目录 stage/verify,legacy root 始终只读,summary 固定 `cutover=not_authorized`;exact artifact job 实跑且 milestone v4 离线审计闭合 | 不执行 transform/apply、目标启动、cutover 或回退;仍不是 Public Release | | D-426b2b Exact headless 切换链 | 同源 v8 Trial Kit 已交付 amd64/arm64 headless 阶段实物;exact 上传包完成 readiness、reviewed stage、Owner 强认证 transform/apply、真实 legacy stop、只读 target probe start/stop 与 clean `rollback_candidate`,milestone v5 和三个离线 auditor 均闭合 | 仅授权 fresh/隔离数据演练;不停止用户真实 2.x、不执行 Legacy restart、写后 reconciliation 或生产 cutover;仍不是 Public Release | +| D-426b2c Console adopted entry | Local API 增加无 listener/credential/mutation 的显式 cutover probe;controller 同时绑定外层 API 与内层 Application 配置、exact command/mount;Console 原生 CI 改为执行完整 cutover rehearsal 并要求 gate passed | 源码与门禁已实现,但 exact Console 双架构 archive/milestone 尚未由成功终态 artifact workflow 闭合;本机镜像源 EOF 不能替代该证据 | D-421 已关闭 D-420 记录的“Web Task mutation 必须独立设计”缺口,而且没有改名复用 run `33173769047` 的旧 archive。修复提交 `dc1686bd6fb3505174dd9a14098ae5c2c92a1a7f` 的普通主 CI [run 33229592307](https://github.com/whyour/qinglong/actions/runs/33229592307) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33229592293](https://github.com/whyour/qinglong/actions/runs/33229592293) 成功;随后显式 Local Console milestone [run 33230227006](https://github.com/whyour/qinglong/actions/runs/33230227006) 为 42 success/2 scope skip/0 fail。由此 Web 创建能力已进入新的阶段实物,而不再只是候选源码。 diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index 7ba39173..61fe1157 100644 --- a/docs/operations/ql3-local-alpha-trial-kit.md +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -137,7 +137,7 @@ staging manifest。summary 必须是 `status=verified`、`legacySource=read_only ## 隔离的真实切换链演练 -v8 headless bundle 进一步提供 `upgrade-cutover-rehearsal.sh`。它只面向 Linux Docker 测试主机,在新的 rehearsal root 和两个专用合成容器上消费上一阶段已审核的两个 plan digest: +v8 `headless|console` bundle 都提供 `upgrade-cutover-rehearsal.sh`。它只面向 Linux Docker 测试主机,在新的 rehearsal root 和两个专用合成容器上消费上一阶段已审核的两个 plan digest: ```sh sh upgrade-cutover-rehearsal.sh \ @@ -150,11 +150,11 @@ sh upgrade-cutover-rehearsal.sh \ ql3-alpha-upgrade-target ``` -脚本先重跑 canonical stage/verify,再完成 fresh Owner 建立、data-directory transform/apply、真实 Docker socket 上的合成 Legacy 停机和 3.0 target 启停。Operator 镜像仅增加固定版本 Docker CLI,仍不携带 daemon、Compose,也不常驻。Legacy root 在所有容器中均以只读方式挂载;脚本对演练前后的 `db/database.sqlite` 做 SHA-256 闭合校验。 +脚本先重跑 canonical stage/verify,再完成 fresh Owner 建立、data-directory transform/apply、真实 Docker socket 上的合成 Legacy 停机和 3.0 target 启停。headless target 使用 Application `--cutover-probe`;Console target 使用生产 `ql3-local-api` 入口的 `--cutover-probe`,同时绑定外层 Local API 与内层 Application 配置。后者只验证 loopback 配置并委托 Application 只读 readiness,不启动 listener、不读取 credential/pepper,也不激活 recovery、scheduler、execution 或管理面。Operator 镜像仅增加固定版本 Docker CLI,仍不携带 daemon、Compose,也不常驻。Legacy root 在所有容器中均以只读方式挂载;脚本对演练前后的 `db/database.sqlite` 做 SHA-256 闭合校验。 -成功时 `cutover-summary.json` 必须同时为 `status=rollback_candidate`、`legacySource=unchanged`、`target=stopped`。两个合成容器会保持停止状态供审查,随后按脚本输出显式 `docker rm`;失败时脚本自动清理。该结果证明打包产物能够走通控制器链和 Docker 证据闭环,但不会停止用户真实 2.x 容器、执行 Legacy restart/rollback 或授权生产升级。 +成功时 `cutover-summary.json` 使用 `qinglong/local-alpha-upgrade-cutover-summary@v2`,必须同时绑定当前 `variant`、`targetEntrypoint=local-application|local-api`、`status=rollback_candidate`、`legacySource=unchanged` 与 `target=stopped`。两个合成容器会保持停止状态供审查,随后按脚本输出显式 `docker rm`;失败时脚本自动清理。该结果证明打包产物能够走通 controller 与 Docker 证据闭环,但不会停止用户真实 2.x 容器、执行 Legacy restart/rollback 或授权生产升级。 -原生 amd64/arm64 headless artifact job 必须从将要上传的目录执行 exact `upgrade-cutover-rehearsal.sh`,检查 summary 和旧 SQLite 未变,并删除合成容器后才能上传;对应 gate 为 `verification-evidence.json.gates.legacyUpgradeCutover=passed`。Console artifact 继续实跑 canonical stage,但该 gate 固定为 `not_applicable`;在 adopted target 证据正式支持 Local API 入口前,不得把 Console fresh journey 冒充升级切换验证。 +原生 amd64/arm64 的 headless 与 Console artifact job 都必须从将要上传的目录执行 exact `upgrade-cutover-rehearsal.sh`,检查 summary 和旧 SQLite 未变,并删除合成容器后才能上传;对应 gate 均为 `verification-evidence.json.gates.legacyUpgradeCutover=passed`。Console 的 fresh HTTP/credential/Task journey 仍是独立门:它证明真实 listener 和产品面可用,而无 listener 的 cutover probe 只证明 adopted entry 与 clean rollback,两者不能互相冒充。 ## 手工加载与最小 smoke diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index ea0345c3..346c46ce 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -13,6 +13,8 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创 D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console。D-421/D-422 依次增加 request-scoped strong-auth Task 创建与双 proof 无损编辑,D-423 继续开放既有 immutable Trigger/cron authority。D-424 再增加 Secret current metadata、强认证 create/rotate 与 Task pinned binding;绑定提交 `f46fb44ac9534315b6965865bb3e990715bb2417` 的最新双架构实物已由 [milestone run 33252179178](https://github.com/whyour/qinglong/actions/runs/33252179178) 生成并完成 milestone checksum/auditor 复核,没有借用或改名 D-423 archive。 +D-426b2c 又补齐了 Console 镜像的 adopted-target 入口证据:切换演练使用 `ql3-local-api --cutover-probe --config `,同时绑定外层 API 配置、内层 Application 配置与 exact mounts,但该模式不会启动本页使用的 listener、credential、scheduler 或 mutation surface。正常启动仍使用下文不带 `--cutover-probe` 的命令;源码和 CI 门禁已实现,exact Console 双架构阶段实物仍待成功 artifact workflow 闭合。 + ## 前置条件 - 已完成 Local fresh setup,并有受支持的 Application config; @@ -79,4 +81,4 @@ D-424 阶段实物的可操作闭环是内建 argv command Task create/list/read D-424 的三项静态资产总计 102,182 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,210,024 / 4,210,168 bytes、482 files、12 packages、111 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 20,447,232 / 18,399,232 bytes,低于 28 MiB。默认 headless Edge 为 2,760,847 bytes、332 files、3 packages、59 modules,RSS delta 11,026,432 bytes;它不携带 Console/API 资产、listener 或 Secret mutation surface,只增加复用现有 SQLite connection 的有界 metadata 装配。 -停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。 +停止正常 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态;一次性 cutover probe 不绑定端口,也不会进入这条常驻生命周期。 diff --git a/packages/ql3-local-api/src/cli.ts b/packages/ql3-local-api/src/cli.ts index a51a96eb..ef7b889b 100644 --- a/packages/ql3-local-api/src/cli.ts +++ b/packages/ql3-local-api/src/cli.ts @@ -5,9 +5,15 @@ import type { LocalApplicationProcessSignalSource, } from '@qinglong/local-application/process'; +import { + localApiCliFailureFact, + parseLocalApiCliCommand, +} from './production-process/cliCommand'; +import { runProductionLocalApiCutoverProbe } from './production-process/cutoverProbeProcess'; import { runProductionLocalApiProcess } from './production-process/processApplication'; -const USAGE = 'Usage: ql3-local-api --config /absolute/private-config.json'; +const USAGE = + 'Usage: ql3-local-api [--cutover-probe] --config /absolute/private-config.json'; const nodeSignals: LocalApplicationProcessSignalSource = Object.freeze({ subscribe( @@ -26,36 +32,13 @@ const nodeSignals: LocalApplicationProcessSignalSource = Object.freeze({ }, }); -function configFileArgument(argv: readonly string[]): string | null { - return argv.length === 2 && argv[0] === '--config' && argv[1] - ? argv[1] - : null; -} - -function failureFact(error: unknown): Readonly> { - const candidate = error as { readonly name?: unknown; readonly code?: unknown }; - return Object.freeze({ - schemaVersion: 1, - component: 'qinglong3-local-api', - level: 'error', - event: 'process_failed', - name: - typeof candidate?.name === 'string' && candidate.name.length <= 128 - ? candidate.name - : 'Error', - ...(typeof candidate?.code === 'string' && candidate.code.length <= 128 - ? { code: candidate.code } - : {}), - }); -} - async function main(argv: readonly string[]): Promise { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { process.stdout.write(`${USAGE}\n`); return; } - const configFilePath = configFileArgument(argv); - if (!configFilePath) { + const command = parseLocalApiCliCommand(argv); + if (command === null) { process.stderr.write( `${JSON.stringify({ code: 'QL3_LOCAL_API_CLI_USAGE_INVALID', @@ -66,16 +49,20 @@ async function main(argv: readonly string[]): Promise { return; } try { - const stopResult = await runProductionLocalApiProcess({ - configFilePath, + const options = { + configFilePath: command.configFilePath, signals: nodeSignals, - emit(event) { + emit(event: Readonly>) { process.stdout.write(`${JSON.stringify(event)}\n`); }, - }); + }; + const stopResult = + command.mode === 'cutover_probe' + ? await runProductionLocalApiCutoverProbe(options) + : await runProductionLocalApiProcess(options); if (stopResult !== 'stopped') process.exitCode = 1; } catch (error) { - process.stderr.write(`${JSON.stringify(failureFact(error))}\n`); + process.stderr.write(`${JSON.stringify(localApiCliFailureFact(error))}\n`); process.exitCode = 1; } } diff --git a/packages/ql3-local-api/src/production-process/cliCommand.ts b/packages/ql3-local-api/src/production-process/cliCommand.ts new file mode 100644 index 00000000..92dadf8a --- /dev/null +++ b/packages/ql3-local-api/src/production-process/cliCommand.ts @@ -0,0 +1,49 @@ +export type LocalApiCliCommand = Readonly<{ + configFilePath: string; + mode: 'api' | 'cutover_probe'; +}>; + +export function localApiCliFailureFact( + error: unknown, +): Readonly> { + const candidate = error as { + readonly name?: unknown; + readonly code?: unknown; + }; + return Object.freeze({ + schemaVersion: 1, + component: 'qinglong3-local-api', + level: 'error', + event: 'process_failed', + name: + typeof candidate?.name === 'string' && candidate.name.length <= 128 + ? candidate.name + : 'Error', + ...(typeof candidate?.code === 'string' && candidate.code.length <= 128 + ? { code: candidate.code } + : {}), + }); +} + +export function parseLocalApiCliCommand( + argv: readonly string[], +): LocalApiCliCommand | null { + if (argv.length === 2 && argv[0] === '--config' && argv[1]) { + return Object.freeze({ + configFilePath: argv[1], + mode: 'api' as const, + }); + } + if ( + argv.length === 3 && + argv[0] === '--cutover-probe' && + argv[1] === '--config' && + argv[2] + ) { + return Object.freeze({ + configFilePath: argv[2], + mode: 'cutover_probe' as const, + }); + } + return null; +} diff --git a/packages/ql3-local-api/src/production-process/cutoverProbeProcess.ts b/packages/ql3-local-api/src/production-process/cutoverProbeProcess.ts new file mode 100644 index 00000000..d0d059d8 --- /dev/null +++ b/packages/ql3-local-api/src/production-process/cutoverProbeProcess.ts @@ -0,0 +1,72 @@ +import { + runProductionLocalApplicationCutoverProbe, + type ProductionLocalApplicationCutoverProbeOptions, +} from '@qinglong/local-application/cutover-probe'; + +import { + readLocalApiProcessConfig, + type LocalApiProcessConfig, +} from './config'; + +export type ProductionLocalApiCutoverProbeOptions = + ProductionLocalApplicationCutoverProbeOptions; + +export interface ProductionLocalApiCutoverProbeAdapters { + readonly readConfig: typeof readLocalApiProcessConfig; + readonly runApplicationProbe: typeof runProductionLocalApplicationCutoverProbe; +} + +function validateOptions(options: ProductionLocalApiCutoverProbeOptions): void { + if ( + !options || + typeof options !== 'object' || + Array.isArray(options) || + typeof options.configFilePath !== 'string' || + typeof options.signals?.subscribe !== 'function' || + typeof options.emit !== 'function' + ) { + throw new TypeError( + 'Production Local API cutover probe options are invalid', + ); + } +} + +function validateAdapters( + adapters: ProductionLocalApiCutoverProbeAdapters, +): void { + if ( + !adapters || + typeof adapters !== 'object' || + Array.isArray(adapters) || + typeof adapters.readConfig !== 'function' || + typeof adapters.runApplicationProbe !== 'function' + ) { + throw new TypeError( + 'Production Local API cutover probe adapters are invalid', + ); + } +} + +/** + * Binds the exact Local API entry configuration to the read-only adopted + * Application probe. No listener, credential authority, scheduler, recovery + * or write-capable database connection is opened in this mode. + */ +export async function runProductionLocalApiCutoverProbe( + options: ProductionLocalApiCutoverProbeOptions, + adapters: ProductionLocalApiCutoverProbeAdapters = { + readConfig: readLocalApiProcessConfig, + runApplicationProbe: runProductionLocalApplicationCutoverProbe, + }, +): Promise<'stopped'> { + validateOptions(options); + validateAdapters(adapters); + const config: Readonly = adapters.readConfig( + options.configFilePath, + ); + return adapters.runApplicationProbe({ + configFilePath: config.applicationConfigFilePath, + signals: options.signals, + emit: options.emit, + }); +} diff --git a/packages/ql3-local-api/test/cli.test.cjs b/packages/ql3-local-api/test/cli.test.cjs index 3a5bf615..9a5e2223 100644 --- a/packages/ql3-local-api/test/cli.test.cjs +++ b/packages/ql3-local-api/test/cli.test.cjs @@ -13,6 +13,6 @@ test('publishes bounded help without bootstrapping storage or a listener', () => assert.equal(result.stderr, ''); assert.equal( result.stdout, - 'Usage: ql3-local-api --config /absolute/private-config.json\n', + 'Usage: ql3-local-api [--cutover-probe] --config /absolute/private-config.json\n', ); }); diff --git a/packages/ql3-local-api/test/cliCommand.test.cjs b/packages/ql3-local-api/test/cliCommand.test.cjs new file mode 100644 index 00000000..4329f4bb --- /dev/null +++ b/packages/ql3-local-api/test/cliCommand.test.cjs @@ -0,0 +1,49 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + localApiCliFailureFact, + parseLocalApiCliCommand, +} = require('../dist/production-process/cliCommand.js'); + +test('parses only the normal API and frozen cutover-probe commands', () => { + assert.deepEqual(parseLocalApiCliCommand(['--config', '/private/api.json']), { + configFilePath: '/private/api.json', + mode: 'api', + }); + assert.deepEqual( + parseLocalApiCliCommand([ + '--cutover-probe', + '--config', + '/private/api.json', + ]), + { + configFilePath: '/private/api.json', + mode: 'cutover_probe', + }, + ); + for (const argv of [ + [], + ['--cutover-probe', '/private/api.json'], + ['--config', '/private/api.json', '--cutover-probe'], + ['--cutover-probe', '--config', ''], + ]) { + assert.equal(parseLocalApiCliCommand(argv), null); + } +}); + +test('publishes bounded Local API failure facts', () => { + assert.deepEqual( + localApiCliFailureFact( + Object.assign(new Error('secret detail'), { code: 'QL3_TEST' }), + ), + { + schemaVersion: 1, + component: 'qinglong3-local-api', + level: 'error', + event: 'process_failed', + name: 'Error', + code: 'QL3_TEST', + }, + ); +}); diff --git a/packages/ql3-local-api/test/cutoverProbeProcess.test.cjs b/packages/ql3-local-api/test/cutoverProbeProcess.test.cjs new file mode 100644 index 00000000..dc36b452 --- /dev/null +++ b/packages/ql3-local-api/test/cutoverProbeProcess.test.cjs @@ -0,0 +1,71 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + LOCAL_API_PROCESS_CONFIG_SCHEMA, +} = require('../dist/production-process/config.js'); +const { + runProductionLocalApiCutoverProbe, +} = require('../dist/production-process/cutoverProbeProcess.js'); + +test('validates the Local API entry config before delegating to the frozen Application probe', async () => { + const signals = Object.freeze({ + subscribe() { + return () => {}; + }, + }); + const events = []; + let calls = 0; + const result = await runProductionLocalApiCutoverProbe( + { + configFilePath: '/srv/qinglong/private/api.json', + signals, + emit(event) { + events.push(event); + }, + }, + { + readConfig(filePath) { + assert.equal(filePath, '/srv/qinglong/private/api.json'); + return Object.freeze({ + schema: LOCAL_API_PROCESS_CONFIG_SCHEMA, + deploymentRoot: '/srv/qinglong', + applicationConfigFilePath: '/srv/qinglong/private/application.json', + ownerPepperKeyringDirectory: '/srv/qinglong/private/owner-pepper', + listener: Object.freeze({ host: '127.0.0.1', port: 5701 }), + }); + }, + async runApplicationProbe(options) { + calls += 1; + assert.equal( + options.configFilePath, + '/srv/qinglong/private/application.json', + ); + assert.equal(options.signals, signals); + await options.emit({ event: 'cutover_probe_active' }); + return 'stopped'; + }, + }, + ); + assert.equal(result, 'stopped'); + assert.equal(calls, 1); + assert.deepEqual(events, [{ event: 'cutover_probe_active' }]); +}); + +test('rejects malformed adapters before reading any authority', async () => { + await assert.rejects( + runProductionLocalApiCutoverProbe( + { + configFilePath: '/srv/qinglong/private/api.json', + signals: Object.freeze({ + subscribe() { + return () => {}; + }, + }), + emit() {}, + }, + {}, + ), + /adapters are invalid/, + ); +}); diff --git a/packages/ql3-local-application/package.json b/packages/ql3-local-application/package.json index ed5ae258..5f7c17ea 100644 --- a/packages/ql3-local-application/package.json +++ b/packages/ql3-local-application/package.json @@ -34,6 +34,11 @@ "types": "./dist/production-process/processApplication.d.ts", "require": "./dist/production-process/processApplication.js", "default": "./dist/production-process/processApplication.js" + }, + "./cutover-probe": { + "types": "./dist/production-process/cutoverProbeProcess.d.ts", + "require": "./dist/production-process/cutoverProbeProcess.js", + "default": "./dist/production-process/cutoverProbeProcess.js" } }, "files": [ diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunContract.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunContract.ts index a540468c..481c5be0 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunContract.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunContract.ts @@ -23,6 +23,11 @@ export type LocalDeploymentTargetRunOperation = | 'local.deployment.cutover.target-start' | 'local.deployment.cutover.target-restart'; +export interface LocalDeploymentTargetApiEntry { + readonly configPath: string; + readonly expectedTargetConfigPath: string; +} + export interface LocalDeploymentTargetRunCommand { readonly schemaVersion: 1; readonly operation: LocalDeploymentTargetRunOperation; @@ -49,6 +54,7 @@ export interface LocalDeploymentTargetRunCommand { targetImage: Readonly; applicationConfigPath: string; expectedTargetApplicationConfigPath: string; + readonly targetApi?: Readonly; expectedTargetCommitmentPath: string; generation: number; requestedAtMs: number; @@ -183,31 +189,49 @@ export function normalizeLocalDeploymentTargetRunCommand( ); } const request = object(command.request, 'request'); + const requestKeys = [ + 'activationPath', + 'applicationConfigPath', + 'cutoverId', + 'expectedActivationDigest', + 'expectedLegacyCommitmentDigest', + 'expectedLegacyContainerId', + 'expectedLegacyDatabasePath', + 'expectedTargetApplicationConfigPath', + 'expectedTargetCommitmentPath', + 'expectedTargetContainerId', + 'generation', + 'instanceId', + 'legacySourcePath', + 'manifestPath', + 'profile', + 'recoveryPath', + 'requestedAtMs', + 'targetDatabasePath', + 'targetImage', + ]; exact( request, - [ - 'activationPath', - 'applicationConfigPath', - 'cutoverId', - 'expectedActivationDigest', - 'expectedLegacyCommitmentDigest', - 'expectedLegacyContainerId', - 'expectedLegacyDatabasePath', - 'expectedTargetApplicationConfigPath', - 'expectedTargetCommitmentPath', - 'expectedTargetContainerId', - 'generation', - 'instanceId', - 'legacySourcePath', - 'manifestPath', - 'profile', - 'recoveryPath', - 'requestedAtMs', - 'targetDatabasePath', - 'targetImage', - ], + request.targetApi === undefined + ? requestKeys + : [...requestKeys, 'targetApi'], 'request', ); + const targetApi = + request.targetApi === undefined + ? undefined + : object(request.targetApi, 'targetApi'); + if (targetApi !== undefined) { + exact(targetApi, ['configPath', 'expectedTargetConfigPath'], 'targetApi'); + if ( + targetApi.expectedTargetConfigPath === + request.expectedTargetApplicationConfigPath + ) { + throw new LocalDeploymentConfigurationError( + 'target API and Application configuration paths must be distinct', + ); + } + } const generation = integer(request.generation, 'generation', 1); if ( typeof request.cutoverId !== 'string' || @@ -242,7 +266,8 @@ export function normalizeLocalDeploymentTargetRunCommand( request.recoveryPath, request.manifestPath, request.applicationConfigPath, - ]).size !== 6 + ...(targetApi === undefined ? [] : [targetApi.configPath]), + ]).size !== (targetApi === undefined ? 6 : 7) ) { throw new LocalDeploymentConfigurationError( 'target run authority paths must be distinct', @@ -301,6 +326,20 @@ export function normalizeLocalDeploymentTargetRunCommand( request.expectedTargetApplicationConfigPath, 'expectedTargetApplicationConfigPath', ), + ...(targetApi === undefined + ? {} + : { + targetApi: Object.freeze({ + configPath: safeAbsolutePath( + targetApi.configPath, + 'targetApi.configPath', + ), + expectedTargetConfigPath: safeAbsolutePath( + targetApi.expectedTargetConfigPath, + 'targetApi.expectedTargetConfigPath', + ), + }), + }), expectedTargetCommitmentPath: safeAbsolutePath( request.expectedTargetCommitmentPath, 'expectedTargetCommitmentPath', diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts index 84485ada..1938439c 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts @@ -31,6 +31,16 @@ export interface TargetApplicationBinding { readonly targetManifestPath: string; readonly legacyDataApplicationCommitDigest: string | null; readonly legacyDataApplicationReceiptDigest: string | null; + readonly localApi?: Readonly<{ + configDigest: string; + targetConfigPath: string; + targetDeploymentRoot: string; + targetOwnerPepperKeyringDirectory: string; + listener: Readonly<{ + host: '127.0.0.1' | '::1'; + port: number; + }>; + }>; } export interface TargetContainerEvidence { @@ -70,6 +80,71 @@ function textDigest(value: string): string { return crypto.createHash('sha256').update(value, 'utf8').digest('hex'); } +function normalizedAbsolutePath(value: unknown): value is string { + return ( + typeof value === 'string' && + path.isAbsolute(value) && + path.normalize(value) === value && + path.parse(value).root !== value + ); +} + +function readTargetLocalApiBinding( + command: Readonly, +): TargetApplicationBinding['localApi'] { + const targetApi = command.request.targetApi; + if (targetApi === undefined) return undefined; + const config = object( + readPrivateLocalCommandFile(targetApi.configPath), + 'target Local API configuration', + ); + exact( + config, + [ + 'applicationConfigFilePath', + 'deploymentRoot', + 'listener', + 'ownerPepperKeyringDirectory', + 'schema', + ], + 'target Local API configuration', + ); + const listener = object(config.listener, 'target Local API listener'); + exact(listener, ['host', 'port'], 'target Local API listener'); + const ownerPepperRelative = + normalizedAbsolutePath(config.deploymentRoot) && + normalizedAbsolutePath(config.ownerPepperKeyringDirectory) + ? path.relative(config.deploymentRoot, config.ownerPepperKeyringDirectory) + : null; + if ( + config.schema !== 'qinglong/local-api-process@v1' || + !normalizedAbsolutePath(config.deploymentRoot) || + config.applicationConfigFilePath !== + command.request.expectedTargetApplicationConfigPath || + !normalizedAbsolutePath(config.ownerPepperKeyringDirectory) || + ownerPepperRelative === null || + ownerPepperRelative.length === 0 || + ownerPepperRelative.startsWith('..') || + path.isAbsolute(ownerPepperRelative) || + (listener.host !== '127.0.0.1' && listener.host !== '::1') || + !Number.isSafeInteger(listener.port) || + (listener.port as number) < 1_024 || + (listener.port as number) > 65_535 + ) { + configurationError('target Local API configuration binding is invalid'); + } + return Object.freeze({ + configDigest: cutoverDigest(config), + targetConfigPath: targetApi.expectedTargetConfigPath, + targetDeploymentRoot: config.deploymentRoot, + targetOwnerPepperKeyringDirectory: config.ownerPepperKeyringDirectory, + listener: Object.freeze({ + host: listener.host as '127.0.0.1' | '::1', + port: listener.port as number, + }), + }); +} + function object(value: unknown, label: string): Record { if ( !value || @@ -265,6 +340,7 @@ export function readTargetApplicationBinding( 'target legacy data application configuration', ) : undefined; + const localApi = readTargetLocalApiBinding(command); if (legacyDataApplication !== undefined) { exact( legacyDataApplication, @@ -326,6 +402,7 @@ export function readTargetApplicationBinding( legacyDataApplication === undefined ? null : (legacyDataApplication.expectedReceiptDigest as string), + ...(localApi === undefined ? {} : { localApi }), }); } @@ -371,12 +448,11 @@ function mappedMount( path.join(mount.destination, relative) === targetPath ); }); - if ( - matches.length !== 1 || - matches[0]?.readWrite !== expectedReadWrite - ) { + if (matches.length !== 1 || matches[0]?.readWrite !== expectedReadWrite) { configurationError( - `${label} must have one ${expectedReadWrite ? 'read-write' : 'read-only'} bind mapping`, + `${label} must have one ${ + expectedReadWrite ? 'read-write' : 'read-only' + } bind mapping`, ); } return matches[0]!; @@ -540,6 +616,9 @@ export function parseTargetContainerEvidence( 'target container restart policy', ); const config = object(container.Config, 'target container config'); + const expectedEntryConfigPath = + application.localApi?.targetConfigPath ?? + command.request.expectedTargetApplicationConfigPath; const stopped = state.Running === false && state.Restarting === false && @@ -569,7 +648,7 @@ export function parseTargetContainerEvidence( JSON.stringify([ '--cutover-probe', '--config', - command.request.expectedTargetApplicationConfigPath, + expectedEntryConfigPath, ]) || typeof container.Created !== 'string' || typeof container.Name !== 'string' || @@ -593,6 +672,28 @@ export function parseTargetContainerEvidence( command.request.expectedTargetApplicationConfigPath, 'target application configuration', ); + const localApiBinding = + application.localApi === undefined || + command.request.targetApi === undefined + ? undefined + : Object.freeze({ + configDigest: application.localApi.configDigest, + configMount: mappedMount( + mounts, + command.request.targetApi.configPath, + command.request.targetApi.expectedTargetConfigPath, + 'target Local API configuration', + ), + deploymentMount: mappedMount( + mounts, + command.options.deploymentRoot, + application.localApi.targetDeploymentRoot, + 'target Local API deployment root', + ), + listener: application.localApi.listener, + targetOwnerPepperKeyringDirectory: + application.localApi.targetOwnerPepperKeyringDirectory, + }); const activationMount = mappedMount( mounts, command.request.activationPath, @@ -642,6 +743,7 @@ export function parseTargetContainerEvidence( databaseMount, recoveryMount, manifestMount, + ...(localApiBinding === undefined ? {} : { localApi: localApiBinding }), }), }); } diff --git a/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs b/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs index aab387b4..1ad17e23 100644 --- a/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs +++ b/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs @@ -109,7 +109,7 @@ function targetInspection(state, options = {}) { ? [] : ['--cutover-probe']), '--config', - state.targetApplicationConfigPath, + state.targetApiConfigPath ?? state.targetApplicationConfigPath, ], }, HostConfig: { @@ -157,7 +157,12 @@ function fixture(t) { const serviceRoot = path.join(deploymentRoot, 'service'); const cutoverId = 'cutover-edge-1'; const journal = path.join(serviceRoot, 'cutovers', cutoverId); - for (const directory of [deploymentRoot, serviceRoot, path.dirname(journal), journal]) { + for (const directory of [ + deploymentRoot, + serviceRoot, + path.dirname(journal), + journal, + ]) { fs.mkdirSync(directory, { mode: 0o700 }); } const legacySourcePath = path.join(managementRoot, 'database.sqlite'); @@ -261,7 +266,10 @@ function fixture(t) { ); state.legacyCommitmentDigest = legacy.commitmentDigest; state.legacyCommitmentPath = path.join(journal, '0002-legacy-stopped.json'); - state.applicationConfigPath = path.join(deploymentRoot, 'local-application.json'); + state.applicationConfigPath = path.join( + deploymentRoot, + 'local-application.json', + ); state.targetApplicationConfigPath = targetPath( state, state.applicationConfigPath, @@ -359,6 +367,29 @@ function prepareAdoptedV4Baseline(state) { return baseline; } +function prepareLocalApiTarget(state) { + state.apiConfigPath = path.join(state.deploymentRoot, 'local-api.json'); + state.targetApiConfigPath = targetPath(state, state.apiConfigPath); + state.targetApi = { + configPath: state.apiConfigPath, + expectedTargetConfigPath: state.targetApiConfigPath, + }; + fs.writeFileSync( + state.apiConfigPath, + `${JSON.stringify({ + schema: 'qinglong/local-api-process@v1', + deploymentRoot: targetPath(state, state.deploymentRoot), + applicationConfigFilePath: state.targetApplicationConfigPath, + ownerPepperKeyringDirectory: targetPath( + state, + path.join(state.deploymentRoot, 'owner-peppers'), + ), + listener: { host: '127.0.0.1', port: 5700 }, + })}\n`, + { mode: 0o600 }, + ); +} + function command(state, generation = 1) { return { schemaVersion: 1, @@ -392,8 +423,8 @@ function command(state, generation = 1) { imageId: state.targetImageId, }, applicationConfigPath: state.applicationConfigPath, - expectedTargetApplicationConfigPath: - state.targetApplicationConfigPath, + expectedTargetApplicationConfigPath: state.targetApplicationConfigPath, + ...(state.targetApi === undefined ? {} : { targetApi: state.targetApi }), expectedTargetCommitmentPath: state.targetCommitmentPath, generation, requestedAtMs: 2_000 + generation, @@ -500,7 +531,8 @@ function harness(state, options = {}) { if (args[0] === 'container' && args[1] === 'start') { if (args[2] === state.legacyContainerId) { if (options.leaveLegacyStopped !== true) state.legacyRunning = true; - if (options.startTargetWithLegacy === true) state.targetRunning = true; + if (options.startTargetWithLegacy === true) + state.targetRunning = true; if (options.loseLegacyStartResponse === true) { throw new Error('simulated lost legacy start response'); } @@ -561,7 +593,10 @@ function harness(state, options = {}) { test('starts an exact target once and replays the active commitment without Docker', async (t) => { const state = fixture(t); const controller = harness(state); - const active = await runLocalDeploymentDockerTarget(command(state), controller); + const active = await runLocalDeploymentDockerTarget( + command(state), + controller, + ); assert.equal(active.status, 'prepared'); assert.equal(active.state, 'target_active'); assert.equal(active.generation, 1); @@ -650,6 +685,66 @@ test('starts an offline Trial Kit image only when its local reference and conten assert.equal(active.state, 'target_active'); }); +test('starts an exact Local API cutover probe with both entry and Application configs bound', async (t) => { + const state = fixture(t); + prepareLocalApiTarget(state); + state.targetImageAuthority = 'local-image-id'; + state.targetImage = 'qinglong3-local-console:ci-amd64'; + const active = await runLocalDeploymentDockerTarget( + command(state), + harness(state), + ); + assert.equal(active.state, 'target_active'); + const request = JSON.parse( + fs.readFileSync( + path.join(state.journal, '0003-target-start-decision.json'), + 'utf8', + ), + ); + assert.match( + request.evidence.targetApplicationBindingDigest, + /^[0-9a-f]{64}$/, + ); +}); + +test('fails closed before starting when a Local API entry points at another Application config', async (t) => { + const state = fixture(t); + prepareLocalApiTarget(state); + const config = JSON.parse(fs.readFileSync(state.apiConfigPath, 'utf8')); + config.applicationConfigFilePath = '/host/runtime/other-application.json'; + fs.writeFileSync(state.apiConfigPath, `${JSON.stringify(config)}\n`, { + mode: 0o600, + }); + await assert.rejects( + runLocalDeploymentDockerTarget(command(state), harness(state)), + /Local API configuration binding is invalid/, + ); +}); + +test('rejects a Local API entry that aliases the Application target config path', async (t) => { + const state = fixture(t); + prepareLocalApiTarget(state); + state.targetApi.expectedTargetConfigPath = state.targetApplicationConfigPath; + await assert.rejects( + runLocalDeploymentDockerTarget(command(state), harness(state)), + /API and Application configuration paths must be distinct/, + ); +}); + +test('rejects a Local API owner pepper directory equal to its deployment root', async (t) => { + const state = fixture(t); + prepareLocalApiTarget(state); + const config = JSON.parse(fs.readFileSync(state.apiConfigPath, 'utf8')); + config.ownerPepperKeyringDirectory = config.deploymentRoot; + fs.writeFileSync(state.apiConfigPath, `${JSON.stringify(config)}\n`, { + mode: 0o600, + }); + await assert.rejects( + runLocalDeploymentDockerTarget(command(state), harness(state)), + /Local API configuration binding is invalid/, + ); +}); + test('makes an offline Trial Kit target manual-required when its inspected content ID drifted', async (t) => { const state = fixture(t); state.targetImageAuthority = 'local-image-id'; @@ -691,19 +786,14 @@ test('recovers a crash after the start barrier by inspection without repeating s /simulated supervisor crash/, ); assert.equal( - fs.existsSync( - path.join(state.journal, '0003-target-start-decision.json'), - ), + fs.existsSync(path.join(state.journal, '0003-target-start-decision.json')), true, ); assert.equal( fs.existsSync(path.join(state.journal, '0004-target-start-outcome.json')), false, ); - assert.equal( - crashing.calls.filter((args) => args[1] === 'start').length, - 1, - ); + assert.equal(crashing.calls.filter((args) => args[1] === 'start').length, 1); state.targetRunning = true; startupReceipt(state, ++state.nextProcessId); @@ -844,10 +934,7 @@ test('refuses target start when the writable target database mount is not bound' const detached = command(state); detached.request.targetDatabasePath = '/var/db/detached-qinglong3.sqlite'; const controller = harness(state); - const unresolved = await runLocalDeploymentDockerTarget( - detached, - controller, - ); + const unresolved = await runLocalDeploymentDockerTarget(detached, controller); assert.equal(unresolved.state, 'manual_required'); assert.equal( controller.calls.filter((args) => args[1] === 'start').length, @@ -904,12 +991,7 @@ test('prevents a new cutover id from bypassing a manual-required instance head', assert.equal(dockerCalls, 0); assert.equal( fs.existsSync( - path.join( - state.deploymentRoot, - 'service', - 'cutovers', - 'cutover-edge-2', - ), + path.join(state.deploymentRoot, 'service', 'cutovers', 'cutover-edge-2'), ), false, ); @@ -932,10 +1014,7 @@ test('diagnoses and resolves manual-required through inspect-only prepare and CA assert.ok(controller.calls.every((args) => args[1] === 'inspect')); const prepared = runLocalDeploymentCutoverManualCommand( - manualCommand( - state, - 'local.deployment.cutover.manual-resolution-prepare', - ), + manualCommand(state, 'local.deployment.cutover.manual-resolution-prepare'), harness(state, { leaveStopped: true }), ); assert.equal(prepared.state, 'resolution_prepared'); @@ -968,17 +1047,14 @@ test('diagnoses and resolves manual-required through inspect-only prepare and CA assert.equal(head.state, 'resolution_authorized'); assert.equal(head.previousHeadDigest, diagnosed.instanceHeadDigest); - const replay = runLocalDeploymentCutoverManualCommand( - commitCommand, - { - validateSocket() { - throw new Error('commit replay must not reopen Docker authority'); - }, - runDocker() { - throw new Error('commit replay must not inspect or mutate'); - }, + const replay = runLocalDeploymentCutoverManualCommand(commitCommand, { + validateSocket() { + throw new Error('commit replay must not reopen Docker authority'); }, - ); + runDocker() { + throw new Error('commit replay must not inspect or mutate'); + }, + }); assert.equal(replay.status, 'existing'); assert.equal(replay.instanceHeadDigest, committed.instanceHeadDigest); @@ -1041,10 +1117,7 @@ test('rejects manual resolution commit when stopped evidence drifts', async (t) harness(state, { leaveStopped: true, expireImmediately: true }), ); const prepared = runLocalDeploymentCutoverManualCommand( - manualCommand( - state, - 'local.deployment.cutover.manual-resolution-prepare', - ), + manualCommand(state, 'local.deployment.cutover.manual-resolution-prepare'), harness(state, { leaveStopped: true }), ); state.targetRunning = true; @@ -1085,10 +1158,7 @@ test('stops an active target and proves an unchanged rollback candidate', async ); assert.equal(stopped.state, 'target_stopped'); assert.equal(stopped.reconciliation, 'rollback_candidate'); - assert.equal( - controller.calls.filter((args) => args[1] === 'stop').length, - 1, - ); + assert.equal(controller.calls.filter((args) => args[1] === 'stop').length, 1); const request = JSON.parse( fs.readFileSync( path.join(state.journal, '0005-target-stop-decision.json'), @@ -1214,10 +1284,7 @@ test('prepares and commits an exact legacy rollback without mutating target data ); assert.equal(prepared.state, 'rollback_prepared'); assert.match(prepared.preparationDigest, /^[0-9a-f]{64}$/); - assert.equal( - preparing.calls.filter((args) => args[1] === 'start').length, - 0, - ); + assert.equal(preparing.calls.filter((args) => args[1] === 'start').length, 0); const targetBefore = fs.readFileSync(state.targetDatabasePath); const commitCommand = rollbackCommand( state, @@ -1226,15 +1293,11 @@ test('prepares and commits an exact legacy rollback without mutating target data prepared.preparationDigest, ); const committing = harness(state); - const committed = runLocalDeploymentLegacyRollback( - commitCommand, - committing, - ); + const committed = runLocalDeploymentLegacyRollback(commitCommand, committing); assert.equal(committed.state, 'legacy_running'); assert.equal( committing.calls.filter( - (args) => - args[1] === 'start' && args[2] === state.legacyContainerId, + (args) => args[1] === 'start' && args[2] === state.legacyContainerId, ).length, 1, ); @@ -1369,10 +1432,7 @@ test('does not blindly start legacy after a crash at the rollback barrier', asyn /simulated rollback supervisor crash/, ); const recovering = harness(state); - const result = runLocalDeploymentLegacyRollback( - commitCommand, - recovering, - ); + const result = runLocalDeploymentLegacyRollback(commitCommand, recovering); assert.equal(result.state, 'manual_required'); assert.equal( recovering.calls.filter((args) => args[1] === 'start').length, @@ -1410,8 +1470,7 @@ test('rechecks target stopped after the rollback barrier before starting legacy' assert.equal(result.state, 'manual_required'); assert.equal( controller.calls.some( - (args) => - args[1] === 'start' && args[2] === state.legacyContainerId, + (args) => args[1] === 'start' && args[2] === state.legacyContainerId, ), false, ); @@ -1447,10 +1506,7 @@ test('recovers a crash after legacy start by inspection without starting twice', /simulated crash after legacy start/, ); const recovering = harness(state); - const result = runLocalDeploymentLegacyRollback( - commitCommand, - recovering, - ); + const result = runLocalDeploymentLegacyRollback(commitCommand, recovering); assert.equal(result.state, 'legacy_running'); assert.equal( recovering.calls.filter((args) => args[1] === 'start').length, diff --git a/scripts/ql3-local-alpha-trial-kit-bundle.cjs b/scripts/ql3-local-alpha-trial-kit-bundle.cjs index 7b870aeb..6413116b 100644 --- a/scripts/ql3-local-alpha-trial-kit-bundle.cjs +++ b/scripts/ql3-local-alpha-trial-kit-bundle.cjs @@ -70,7 +70,6 @@ const VERIFICATION = Object.freeze({ function verificationGates(variant) { return Object.freeze({ ...VERIFICATION, - legacyUpgradeCutover: variant === 'headless' ? 'passed' : 'not_applicable', consoleLiveJourney: variant === 'console' ? 'passed' : 'not_applicable', firstAutomationJourney: variant === 'console' ? 'passed' : 'not_applicable', }); diff --git a/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh b/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh index 3d8b51eb..297f677e 100644 --- a/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh +++ b/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh @@ -44,7 +44,10 @@ container_name() { } [ "$#" -ge 5 ] && [ "$#" -le 7 ] || usage -[ "$VARIANT" = headless ] || fail 'cutover rehearsal is available only in the headless Trial Kit' +case "$VARIANT" in + headless|console) ;; + *) fail 'embedded Trial Kit variant is invalid' ;; +esac profile=$1 legacy_root=$2 rehearsal_root=$3 @@ -249,6 +252,19 @@ bundle_digest=$(extract_digest "$rehearsal_root/results/adopted-verify.result.js grep -Fq "\"reference\": \"$APPLICATION_IMAGE\"" "$rehearsal_root/service/docker-target.json" || fail 'target descriptor image reference drifted' grep -Fq "\"imageId\": \"$APPLICATION_ID\"" "$rehearsal_root/service/docker-target.json" || fail 'target descriptor image ID drifted' +target_entry_config_path="$rehearsal_root/local-application.json" +target_entrypoint=local_application +target_api_request= +if [ "$VARIANT" = console ]; then + target_entry_config_path="$rehearsal_root/local-api.json" + target_entrypoint=local_api + cat >"$target_entry_config_path" <"$rehearsal_root/commands/target-start.json" <"$rehearsal_root/cutover-summary.json" < { 'start exact target container', 'stop target and prove rollback candidate', ]) { - assert.match( - cutoverRehearsalContents, - new RegExp(`phase '${phase}'`), - ); + assert.match(cutoverRehearsalContents, new RegExp(`phase '${phase}'`)); } assert.match( cutoverRehearsalContents, @@ -331,7 +328,7 @@ test('materializes a distinct loopback Console trial kit without widening the he assert.equal(verification.gates.ownerCredentialPresentation, 'passed'); assert.equal(verification.gates.legacyUpgradeReadiness, 'passed'); assert.equal(verification.gates.legacyUpgradeStage, 'passed'); - assert.equal(verification.gates.legacyUpgradeCutover, 'not_applicable'); + assert.equal(verification.gates.legacyUpgradeCutover, 'passed'); const quickstartContents = fs.readFileSync( path.join(paths.outputRoot, 'quickstart.sh'), 'utf8', @@ -348,7 +345,13 @@ test('materializes a distinct loopback Console trial kit without widening the he 'utf8', ); assert.match(cutoverContents, /VARIANT='console'/); - assert.match(cutoverContents, /available only in the headless Trial Kit/); + assert.doesNotMatch( + cutoverContents, + /available only in the headless Trial Kit/, + ); + assert.match(cutoverContents, /"schema":"qinglong\/local-api-process@v1"/); + assert.match(cutoverContents, /"targetApi":\{"configPath":/); + assert.match(cutoverContents, /"targetEntrypoint":"\$target_entrypoint"/); const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); assert.equal(report.compatible, true); assert.equal(report.variant, 'console'); diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index f41a7ff6..0218b0b3 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -130,10 +130,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localApi.rootSourceFileRoles, }, { - sourceFiles: 25, + sourceFiles: 27, rootSourceFiles: 1, - rootSourceLines: 84, - nestedSourceFiles: 24, + rootSourceLines: 71, + nestedSourceFiles: 26, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );