feat(ql3): prove Console adopted cutover entry

This commit is contained in:
whyour
2026-09-01 10:12:32 +08:00
parent 50e6742b72
commit b3421ac9cf
22 changed files with 666 additions and 167 deletions
+13 -19
View File
@@ -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
+3 -1
View File
@@ -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 <outer-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、连接池或稳态资源;默认低配路由设备仍选择 headlessCluster 不复用 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 failpackage/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):
@@ -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 shapeexact Console 双架构 artifact 闭合仍待成功终态 CI。D-426c 继续处理 target 写入后的 capture、review、reconciliation 与恢复不得削弱 headless 已闭合的离线镜像和回退基线。
@@ -0,0 +1,51 @@
# ADR-0525Console 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.json>` 模式。它严格解析 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;若显式选择 Consolecutover probe 只增加一次性配置读取与只读 SQLite readiness,不新增 listener、daemon、timer、watcher、连接池或稳态 RSS。
- Standalone:与 Edge 使用同一证据模型,只保留现有 Profile 资源上限差异。
- Cluster:不复用 Local SQLite、POSIX owner、loopback Console 或 Docker target proofCluster 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 API80/80cutover probe 单测覆盖成功、配置漂移与不启动 listener;
- Local Owner CLI314 total / 307 pass / 7 conditional skip / 0 fail(新增双配置正向、内层指向漂移、路径别名与 Owner 目录边界);
- Trial Kit bundle12/12Console gate 从 `not_applicable` 改为 `passed`summary v2 绑定 `variant=console``targetEntrypoint=local-api`
- Local Application56 total / 51 pass / 5 conditional skip / 0 fail
- package boundary、Cluster dependency、Edge import 与 Local image/operator image audit 均为 compatibleworkspace 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 与生产回退门。
+1
View File
@@ -528,6 +528,7 @@
| [ADR-0522](./ADR-0522-content-bound-offline-docker-adopted-target.md) | 内容绑定的离线 Docker Adopted Target | AcceptedD-426b 权威已由 D-426b2b 双架构实物闭合) |
| [ADR-0523](./ADR-0523-post-apply-adopted-target-baseline.md) | Apply 后的 Adopted Target 启动前基线 | AcceptedD-426b2a 基线已由 D-426b2b 双架构实物闭合) |
| [ADR-0524](./ADR-0524-exact-headless-upgrade-cutover-rehearsal.md) | Exact Headless 升级切换链演练 | AcceptedD-426b2b 双架构阶段实物已交付) |
| [ADR-0525](./ADR-0525-console-adopted-target-entry-evidence.md) | Console Adopted Target 入口证据 | Accepted(源码与 CI 门禁已实现;exact Console 双架构阶段实物待生成) |
## 规则
+2 -1
View File
@@ -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/verifylegacy 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 probecontroller 同时绑定外层 API 与内层 Application 配置、exact command/mountConsole 原生 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 创建能力已进入新的阶段实物,而不再只是候选源码。
+4 -4
View File
@@ -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
+3 -1
View File
@@ -13,6 +13,8 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创
D-418 已闭合独立 Console image/Trial KitD-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 <local-api.json>`,同时绑定外层 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 modulesRSS 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 不绑定端口,也不会进入这条常驻生命周期
+18 -31
View File
@@ -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<Record<string, unknown>> {
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<void> {
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<void> {
return;
}
try {
const stopResult = await runProductionLocalApiProcess({
configFilePath,
const options = {
configFilePath: command.configFilePath,
signals: nodeSignals,
emit(event) {
emit(event: Readonly<Record<string, unknown>>) {
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;
}
}
@@ -0,0 +1,49 @@
export type LocalApiCliCommand = Readonly<{
configFilePath: string;
mode: 'api' | 'cutover_probe';
}>;
export function localApiCliFailureFact(
error: unknown,
): Readonly<Record<string, unknown>> {
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;
}
@@ -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<LocalApiProcessConfig> = adapters.readConfig(
options.configFilePath,
);
return adapters.runApplicationProbe({
configFilePath: config.applicationConfigFilePath,
signals: options.signals,
emit: options.emit,
});
}
+1 -1
View File
@@ -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',
);
});
@@ -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',
},
);
});
@@ -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/,
);
});
@@ -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": [
@@ -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<LocalDeploymentTargetImage>;
applicationConfigPath: string;
expectedTargetApplicationConfigPath: string;
readonly targetApi?: Readonly<LocalDeploymentTargetApiEntry>;
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',
@@ -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<LocalDeploymentTargetRunCommand>,
): 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<string, unknown> {
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 }),
}),
});
}
@@ -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,
@@ -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',
});
@@ -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" <<EOF
{"schema":"qinglong/local-api-process@v1","deploymentRoot":"$rehearsal_root","applicationConfigFilePath":"$rehearsal_root/local-application.json","ownerPepperKeyringDirectory":"$rehearsal_root/owner-peppers","listener":{"host":"127.0.0.1","port":5700}}
EOF
chmod 0600 "$target_entry_config_path"
target_api_request=',"targetApi":{"configPath":"'"$target_entry_config_path"'","expectedTargetConfigPath":"'"$target_entry_config_path"'"}'
fi
phase 'create exact target container'
target_id=$(docker create --name "$target_name" --restart no \
--read-only --user "$uid:$gid" --network none --cap-drop ALL \
@@ -257,13 +273,13 @@ target_id=$(docker create --name "$target_name" --restart no \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m \
--mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \
--mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \
"$APPLICATION_IMAGE" --cutover-probe --config "$rehearsal_root/local-application.json")
"$APPLICATION_IMAGE" --cutover-probe --config "$target_entry_config_path")
[ "${#target_id}" -eq 64 ] || fail 'target container ID is invalid'
case "$target_id" in *[!0-9a-f]*) fail 'target container ID is invalid' ;; esac
start_ms=$((prepared_ms + 1))
cat >"$rehearsal_root/commands/target-start.json" <<EOF
{"schemaVersion":1,"operation":"local.deployment.cutover.target-start","options":{"deploymentRoot":"$rehearsal_root","dockerExecutable":"/usr/bin/docker","dockerSocketPath":"$operator_docker_socket","allowRootService":$allow_root_service},"request":{"cutoverId":"alpha-upgrade-cutover","profile":"$profile","instanceId":"alpha-upgrade","activationPath":"$rehearsal_root/sqlite/qinglong3-activation.json","legacySourcePath":"$legacy_root/db/database.sqlite","targetDatabasePath":"$rehearsal_root/sqlite/qinglong3.sqlite","recoveryPath":"$rehearsal_root/sqlite/database.pre-ql3.sqlite","manifestPath":"$rehearsal_root/sqlite/qinglong3-adoption.json","expectedLegacyDatabasePath":"$legacy_root/db/database.sqlite","expectedActivationDigest":"$activation_digest","expectedLegacyCommitmentDigest":"$commitment_digest","expectedLegacyContainerId":"$legacy_id","expectedTargetContainerId":"$target_id","targetImage":{"authority":"local-image-id","reference":"$APPLICATION_IMAGE","imageId":"$APPLICATION_ID"},"applicationConfigPath":"$rehearsal_root/local-application.json","expectedTargetApplicationConfigPath":"$rehearsal_root/local-application.json","expectedTargetCommitmentPath":"$rehearsal_root/service/cutovers/alpha-upgrade-cutover/0002-legacy-stopped.json","generation":1,"requestedAtMs":$start_ms}}
{"schemaVersion":1,"operation":"local.deployment.cutover.target-start","options":{"deploymentRoot":"$rehearsal_root","dockerExecutable":"/usr/bin/docker","dockerSocketPath":"$operator_docker_socket","allowRootService":$allow_root_service},"request":{"cutoverId":"alpha-upgrade-cutover","profile":"$profile","instanceId":"alpha-upgrade","activationPath":"$rehearsal_root/sqlite/qinglong3-activation.json","legacySourcePath":"$legacy_root/db/database.sqlite","targetDatabasePath":"$rehearsal_root/sqlite/qinglong3.sqlite","recoveryPath":"$rehearsal_root/sqlite/database.pre-ql3.sqlite","manifestPath":"$rehearsal_root/sqlite/qinglong3-adoption.json","expectedLegacyDatabasePath":"$legacy_root/db/database.sqlite","expectedActivationDigest":"$activation_digest","expectedLegacyCommitmentDigest":"$commitment_digest","expectedLegacyContainerId":"$legacy_id","expectedTargetContainerId":"$target_id","targetImage":{"authority":"local-image-id","reference":"$APPLICATION_IMAGE","imageId":"$APPLICATION_ID"},"applicationConfigPath":"$rehearsal_root/local-application.json","expectedTargetApplicationConfigPath":"$rehearsal_root/local-application.json"$target_api_request,"expectedTargetCommitmentPath":"$rehearsal_root/service/cutovers/alpha-upgrade-cutover/0002-legacy-stopped.json","generation":1,"requestedAtMs":$start_ms}}
EOF
chmod 0600 "$rehearsal_root/commands/target-start.json"
phase 'start exact target container'
@@ -287,7 +303,7 @@ grep -q '"reconciliation":"rollback_candidate"' "$rehearsal_root/results/target-
[ "$(sha256sum "$legacy_root/db/database.sqlite" | sed 's/ .*//')" = "$legacy_sha256" ] || fail 'legacy database changed during rehearsal'
cat >"$rehearsal_root/cutover-summary.json" <<EOF
{"schemaVersion":1,"schema":"qinglong/local-alpha-upgrade-cutover-summary@v1","status":"rollback_candidate","profile":"$profile","sourceRevision":"$SOURCE_REVISION","architecture":"$ARCHITECTURE","reviewedPlans":{"sqlite":"$sqlite_plan_digest","dataDirectory":"$directory_plan_digest"},"activationDigest":"$activation_digest","transformationDigest":"$transformation_digest","application":{"commitDigest":"$commit_digest","receiptDigest":"$receipt_digest"},"cutover":{"commitmentDigest":"$commitment_digest","bundleDigest":"$bundle_digest","legacyContainerId":"$legacy_id","targetContainerId":"$target_id"},"legacySource":"unchanged","target":"stopped","rollback":"candidate_not_executed"}
{"schemaVersion":1,"schema":"qinglong/local-alpha-upgrade-cutover-summary@v2","status":"rollback_candidate","profile":"$profile","variant":"$VARIANT","targetEntrypoint":"$target_entrypoint","sourceRevision":"$SOURCE_REVISION","architecture":"$ARCHITECTURE","reviewedPlans":{"sqlite":"$sqlite_plan_digest","dataDirectory":"$directory_plan_digest"},"activationDigest":"$activation_digest","transformationDigest":"$transformation_digest","application":{"commitDigest":"$commit_digest","receiptDigest":"$receipt_digest"},"cutover":{"commitmentDigest":"$commitment_digest","bundleDigest":"$bundle_digest","legacyContainerId":"$legacy_id","targetContainerId":"$target_id"},"legacySource":"unchanged","target":"stopped","rollback":"candidate_not_executed"}
EOF
chmod 0600 "$rehearsal_root/cutover-summary.json"
umask "$old_umask"
@@ -259,10 +259,7 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => {
'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');
+3 -3
View File
@@ -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' },
},
);