From 367ec49e4a6abf26d4cdbaaecbca1bee3541a7a4 Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 1 Sep 2026 11:46:40 +0800 Subject: [PATCH] feat(ql3): capture post-cutover reconciliation evidence --- .github/workflows/ql3-ci.yml | 25 +++- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 + ...exact-post-write-reconciliation-capture.md | 47 ++++++ docs/adr/README.md | 1 + docs/operations/ql3-alpha-candidate.md | 3 +- docs/operations/ql3-local-alpha-trial-kit.md | 34 ++++- scripts/ql3-local-alpha-trial-kit-bundle.cjs | 11 +- scripts/ql3-local-operator-image-audit.cjs | 5 + ...3-local-alpha-upgrade-cutover-rehearsal.sh | 138 +++++++++++++++++- .../back/ql3LocalAlphaTrialKitBundle.test.cjs | 82 ++++++++++- test/back/ql3LocalOperatorImageAudit.test.cjs | 39 +++++ 11 files changed, 371 insertions(+), 16 deletions(-) create mode 100644 docs/adr/ADR-0526-exact-post-write-reconciliation-capture.md diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 96218342..a2d0c243 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -784,9 +784,13 @@ jobs: DIRECTORY_PLAN_DIGEST="$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).evidence.planDigest)' \ "${UPGRADE_EVIDENCE_ROOT}/results/data-directory-inspect.result.json")" UPGRADE_REHEARSAL_ROOT="${RUNNER_TEMP}/ql3-alpha-upgrade-cutover-rehearsal-${{ matrix.image_arch }}" + RECONCILIATION_REHEARSAL_ROOT="${RUNNER_TEMP}/ql3-alpha-upgrade-reconciliation-rehearsal-${{ matrix.image_arch }}" + RECONCILIATION_CAPTURE_ROOT="${RUNNER_TEMP}/ql3-alpha-upgrade-reconciliation-capture-${{ matrix.image_arch }}" 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 + RECONCILIATION_LEGACY_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-reconciliation-legacy-${{ matrix.image_arch }}" + RECONCILIATION_TARGET_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-reconciliation-target-${{ matrix.image_arch }}" + trap 'docker rm --force "${QUICKSTART_CONTAINER}" "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}" "${RECONCILIATION_TARGET_CONTAINER}" "${RECONCILIATION_LEGACY_CONTAINER}" >/dev/null 2>&1 || true' EXIT sh "${BUNDLE_ROOT}/upgrade-cutover-rehearsal.sh" \ edge "${LEGACY_READINESS_ROOT}" "${UPGRADE_REHEARSAL_ROOT}" \ "${SQLITE_PLAN_DIGEST}" "${DIRECTORY_PLAN_DIGEST}" \ @@ -806,6 +810,25 @@ jobs: grep -q '"target":"stopped"' \ "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" docker rm "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}" + sh "${BUNDLE_ROOT}/upgrade-cutover-rehearsal.sh" \ + edge "${LEGACY_READINESS_ROOT}" "${RECONCILIATION_REHEARSAL_ROOT}" \ + "${SQLITE_PLAN_DIGEST}" "${DIRECTORY_PLAN_DIGEST}" \ + "${RECONCILIATION_LEGACY_CONTAINER}" "${RECONCILIATION_TARGET_CONTAINER}" \ + --capture-after-write "${RECONCILIATION_CAPTURE_ROOT}" + grep -q '"status":"reconciliation_captured"' \ + "${RECONCILIATION_REHEARSAL_ROOT}/reconciliation-capture-summary.json" + grep -q '"operation":"task.put"' \ + "${RECONCILIATION_REHEARSAL_ROOT}/reconciliation-capture-summary.json" + grep -q '"rollback":"not_authorized"' \ + "${RECONCILIATION_REHEARSAL_ROOT}/reconciliation-capture-summary.json" + grep -q '"next":"review_required"' \ + "${RECONCILIATION_REHEARSAL_ROOT}/reconciliation-capture-summary.json" + grep -q '"status":"verified"' \ + "${RECONCILIATION_REHEARSAL_ROOT}/results/reconciliation-capture-verify.result.json" + test -s "${RECONCILIATION_CAPTURE_ROOT}/019f8680-143d-4000-8000-000000000161/manifest.json" + test -s "${RECONCILIATION_CAPTURE_ROOT}/019f8680-143d-4000-8000-000000000161/receipt.json" + test -d "${RECONCILIATION_CAPTURE_ROOT}/019f8680-143d-4000-8000-000000000161/assets" + docker rm "${RECONCILIATION_TARGET_CONTAINER}" "${RECONCILIATION_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 369c2f90..8a65f595 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-426c1/ADR-0526(源码与 exact artifact gate 已闭合,双架构阶段实物待交付):downloadable `upgrade-cutover-rehearsal.sh` 保留既有 clean rollback 默认路径,并新增显式 `--capture-after-write <独立私有根>`。同一 reviewed stage/Owner/apply/target-active 链先通过正式 `task.put` 提交固定、无网络/Secret 的业务 Task,再要求 target stop 返回 `reconciliation_required`;随后以现有短生命周期 Operator 串行执行 reconciliation capture prepare/commit/verify,把 legacy、target、recovery、Application config、activation 与 exact stopped head/record 密封到外置 root。成功 summary 固定 `reconciliation_captured`、`legacySource=unchanged`、`target=stopped`、`rollback=not_authorized`、`next=review_required`,不自动回退或应用。Trial Kit/verification/auditor 升为 `@v9/@v7/@v6`、manifest schemaVersion 10,并增加 required `legacyUpgradeReconciliationCapture=passed`;原生 artifact job 必须在保留 clean rollback 演练的同时,用独立目录/容器第二次实跑写后 capture 并验证 manifest/receipt/assets 后才能上传。该切片不修改核心 classifier、不新增 package/依赖/daemon/listener/timer/watcher/连接或稳态资源;低配路由器默认 headless 不变。它证明的是 active target 数据权威经 Owner 产品入口发生写入后的 fail-closed capture,不冒充普通 Local API listener、2.x 老面板、自动 reconciliation、生产升级或 Public Release。 + - D-426b2c/ADR-0525(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。提交 `229c3cb4e826866a0c7c4d81cb5e52cdc3975eec` 的普通主 CI run `33462165722` 与 Kubernetes live run `33462165834` 成功;显式 Local Console artifact run `33463415938` 交付 amd64/arm64/milestone(artifact `9784212784`/`9784111987`/`9784288018`),三个重新下载的离线 auditor 均为 `compatible=true`,保留至 2026-10-01。正常 `ql3-local-api --config` 提供现有 3.0 Console,`--cutover-probe` 仅用于无写入的升级证据;这不声明 2.x 老面板 API 零改动兼容。 - 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。 diff --git a/docs/adr/ADR-0526-exact-post-write-reconciliation-capture.md b/docs/adr/ADR-0526-exact-post-write-reconciliation-capture.md new file mode 100644 index 00000000..1394812d --- /dev/null +++ b/docs/adr/ADR-0526-exact-post-write-reconciliation-capture.md @@ -0,0 +1,47 @@ +# ADR-0526:Exact 写后 Reconciliation Capture + +- 状态:Accepted(源码与 artifact gate 已闭合;同源双架构阶段实物待独立 workflow 交付) +- 日期:2026-09-01 +- 决策:D-426c1 +- 关联:ADR-0476、ADR-0482、ADR-0483、ADR-0523、ADR-0524、ADR-0525 + +## 上下文 + +ADR-0524/0525 已让 downloadable Trial Kit 在 headless 与 Console 两种变体上完成 exact adopted target probe,并证明 target 未产生业务写入时可以得到 `rollback_candidate`。这仍没有闭合另一个必须失败关闭的分支:target 已经 active 后,3.0 数据权威接受一条业务写入,target stop 必须拒绝 clean rollback,并把 legacy、target、recovery 与 cutover lineage 密封到独立 capture,供后续 plan/review/application 使用。 + +底层 reconciliation capture、plan、review、application、Automation、Secret/Config、Run History 与 completion authority 已由 ADR-0482 至 ADR-0493 实现,但之前没有进入用户实际下载的 Trial Kit。仅靠包级测试无法证明 exact Application/operator image、canonical shell、Docker target evidence、私有目录和 artifact workflow 能从一次真实写入贯通到 `reconciliation_captured`。 + +## 决策 + +1. canonical `upgrade-cutover-rehearsal.sh` 保持现有 5–7 参数 clean rollback 路径不变;仅在操作者显式提供完整参数 `--capture-after-write /absolute/new/capture-root` 时进入 D-426c1。capture root 必须是尚不存在、父目录 canonical、与 legacy/rehearsal root 双向不重叠的安全绝对路径。 +2. 脚本仍先运行同一个 reviewed readiness/stage、Owner ceremony、transform/apply、synthetic Legacy stop、adopted bundle 与 exact target probe。target 进入 `target_active` 后,短生命周期 Operator 使用现有 `task.put` 产品入口和已交付 Owner credential 向 target SQLite 提交固定、无网络、无 Secret 的 `qinglong/command@v1` Task。该写入证明 active target 数据权威发生业务变化;它不是普通 Local API listener 或浏览器写入证据,不能冒充生产流量接管。 +3. target stop 必须返回 `reconciliation_required`,并给出 exact stopped record 与 instance head digest。任何 `rollback_candidate`、`manual_review`、legacy source 漂移或缺失证据都使 capture 演练失败;脚本不调用 Legacy rollback prepare/commit,也不启动 Legacy。 +4. capture 使用已存在的 `ql3-local-deploy reconciliation-capture-prepare|commit|verify`,运行于 128 MiB、0.5 CPU、32 PID、无网络、只读 rootfs 的一次性 Operator。legacy root 继续只读;rehearsal 与独立 capture root 是仅有的可写 bind。prepare 绑定 stopped authority、Profile、instance/cutover/generation、Application config、activation、legacy/target/recovery 路径以及 stop/head digest;commit 发布 sealed assets、manifest 与 receipt;verify 只读复算 bundle/head。 +5. 成功后生成 `qinglong/local-alpha-upgrade-reconciliation-capture-summary@v1`,只记录 source/architecture/Profile/variant、固定 synthetic Task identity、cutover/capture digest、asset count/bytes 与 `legacySource=unchanged`、`target=stopped`、`rollback=not_authorized`、`next=review_required`。真实 capture 可能包含数据库、配置和 Secret 密文,必须继续保存在操作者指定的 owner-private root,不能上传为普通低敏 CI summary。 +6. Trial Kit、verification 与 offline auditor 分别升级为 `qinglong/alpha-local-trial-kit@v9`、verification `@v7` 与 audit `@v6`;manifest schemaVersion 升为 10,并增加 required gate `legacyUpgradeReconciliationCapture=passed`。旧 v8/v6/v5 bundle 不会被新 auditor 静默接受。 +7. 原生 amd64/arm64 artifact job 在上传前先保留既有 clean rollback 演练,再从同一 unchanged Legacy fixture、同一 exact bundle 用独立 rehearsal/capture root 与容器名实跑写后 capture。workflow 必须验证 summary、terminal verify、manifest/receipt/assets、legacy 无 WAL/journal,并删除四个 synthetic 容器;任一步失败都不得形成 Local milestone。 + +## Profile 与资源边界 + +- Edge/低配路由设备:默认仍选择 headless,稳态 Application 不增加依赖、listener、timer、watcher、连接或常驻内存;D-426c1 只增加一次显式升级演练的第二轮短生命周期操作。 +- Standalone:沿用既有 Profile 上限;capture 大小由现有有界资产集合决定,但仍要求操作者预留独立持久空间。 +- Console:capture probe 仍不启动 HTTP listener;fresh Console 产品旅程继续独立证明页面、credential 与 API。 +- Cluster:不复用 Local SQLite/POSIX/Docker capture;PostgreSQL/Kubernetes recovery 与 deployment lock 不受本 ADR 授权。 + +## 被拒绝的方案 + +- 在发生写入后继续执行 clean rollback:会丢失 3.0 新事实,拒绝。 +- 把 capture 放进 rehearsal root:根被误删时会同时丢失 source、target 与唯一恢复证据,拒绝。 +- 修改核心 target stop classifier:ADR-0523 已正确区分 baseline/write/manual,本切片只消费结果,不扩大 HIGH/CRITICAL 状态机风险。 +- 用 SQL 或文件追加制造漂移:只能证明字节变化,不能证明正式产品 mutation authority、credential fence 与 audit 生效。 +- 一次性自动执行 plan/review/application/completion:review 需要人类对 exact fact set 作强认证选择;D-426c1 必须停在 `review_required`。 + +## 验证与交付状态 + +- 编辑前 GitNexus:Trial Kit verification/create 为 LOW,offline auditor 为 MEDIUM(5 direct、12 total、0 process),Operator workflow auditor 为 LOW;没有 HIGH/CRITICAL 编辑目标。Shell 模板未被索引,使用 backward-compatible 参数、`sh -n`、静态 contract 与原生 Docker artifact gate 约束。 +- 聚焦 bundle/operator 静态回归为 18/18,证明 v9/v7/v6 schema、canonical script、required gate、exact `task.put`/capture command 与 workflow order 闭合。全部 18 个 `packages/ql3-*` 已重新编译并通过自身契约测试;后端脚本层全量回归为 1661 total / 1659 pass / 2 conditional skip / 0 fail。Package boundary(18 个 package、无 single/shallow source package)、Edge import、Cluster dependency 与 Local Operator image audit 全部 `compatible=true`。 +- exact Docker 正向证据必须来自新的 workflow source commit;在该 run 与双架构 milestone 实际成功、重新下载并离线复核前,本 ADR 不宣称 D-426c1 已形成可下载阶段实物。 + +## 后续 + +D-426c2 将把同一 exact capture 接续到 bounded plan、强认证 review 与逐域 application/rollback;D-426c3 再证明 completion/restart 或人工恢复。Public Release 仍需受保护 tag、immutable multi-arch digest、签名/attestation、deployment lock、生产停机窗口与演练过的恢复责任人。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 46de2105..d23c67a2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -529,6 +529,7 @@ | [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(exact Console 双架构阶段实物已交付并完成离线审计) | +| [ADR-0526](./ADR-0526-exact-post-write-reconciliation-capture.md) | Exact 写后 Reconciliation Capture | Accepted(源码与 artifact gate 已闭合;双架构实物待交付) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index d85512de..f862fb8c 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -34,6 +34,7 @@ | 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 | 同源 exact amd64/arm64 Console Trial Kit 与 milestone 已交付;Local API cutover probe 不启动 listener/credential/mutation,controller 绑定双层配置、exact command/mount,原生 CI 完整演练且三个下载产物离线审计通过 | 仅授权 fresh/隔离数据演练;正常 Console 启动与只读 cutover probe 是不同模式;不承诺 2.x 老面板 API 零改动兼容、真实生产停机或写后回退 | +| D-426c1 写后 reconciliation capture | Trial Kit v9 源码与 exact artifact gate 已闭合:active target 数据权威经 Owner `task.put` 发生业务写入后必须分类为 `reconciliation_required`,并在独立私有 root 完成 capture prepare/commit/verify | 同源 amd64/arm64 milestone 尚待显式 artifact run;停在 `review_required`,不自动 plan/review/apply/rollback/restart;不证明普通 Local API 流量接管 | 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 创建能力已进入新的阶段实物,而不再只是候选源码。 @@ -115,7 +116,7 @@ ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verif Local artifact 含: - 一个包含所选 Application 与短生命周期 operator 的 archive;headless 为 `qinglong3-local-trial-kit-.docker.tar`,Console 为 `qinglong3-local-console-trial-kit-.docker.tar`,共享 Node 基础层在 archive 中去重; -- schema 为 `qinglong/alpha-local-trial-kit@v8` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/upgradeReadiness/upgradeRehearsal/upgradeCutoverRehearsal/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; +- schema 为 `qinglong/alpha-local-trial-kit@v9`、`schemaVersion=10` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/upgradeReadiness/upgradeRehearsal/upgradeCutoverRehearsal/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; - canonical `quickstart.sh`,在目标 Linux 设备上只依赖 POSIX shell、`sha256sum` 和 Docker,完成 checksum、load、identity、fresh Owner 与 Profile-bound Application active; - canonical `upgrade-readiness.sh`,把 2.x data root 只读挂载给 128 MiB/无网络 Operator,生成 SQLite 与完整目录两个私有 inspect 计划,不获得 stage/cutover authority; - `verification-evidence.json` 绑定 `workflow_dispatch` 的 workflow ref/SHA、run ID/attempt、同架构两个 exact image ID 和完整 gate 集;下载者仍须到 GitHub 交叉检查 run,它不替代正式签名; diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index 61fe1157..9f50a584 100644 --- a/docs/operations/ql3-local-alpha-trial-kit.md +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -25,7 +25,7 @@ sha256sum --check SHA256SUMS `manifest.json` 必须满足: -- `schema` 为 `qinglong/alpha-local-trial-kit@v8`; +- `schema` 为 `qinglong/alpha-local-trial-kit@v9`,`schemaVersion=10`; - `variant` 为 `headless` 或 `console`,并与 milestone、application SBOM 和 artifact 名一致; - `sourceRevision` 是你准备试用的完整 40 位 commit; - `architecture` 与主机相同; @@ -116,7 +116,7 @@ artifact job 必须在原生 amd64/arm64 上使用生产形态 2.x fixture 运 ## 受审核计划的 Side-by-side 暂存 -审核上一节两个完整结果后,把其中 exact `evidence.planDigest` 作为显式参数交给 v8 bundle 的 canonical `upgrade-rehearsal.sh`: +审核上一节两个完整结果后,把其中 exact `evidence.planDigest` 作为显式参数交给 v9 bundle 的 canonical `upgrade-rehearsal.sh`: ```sh sh upgrade-rehearsal.sh \ @@ -137,7 +137,7 @@ staging manifest。summary 必须是 `status=verified`、`legacySource=read_only ## 隔离的真实切换链演练 -v8 `headless|console` bundle 都提供 `upgrade-cutover-rehearsal.sh`。它只面向 Linux Docker 测试主机,在新的 rehearsal root 和两个专用合成容器上消费上一阶段已审核的两个 plan digest: +v9 `headless|console` bundle 都提供 `upgrade-cutover-rehearsal.sh`。它只面向 Linux Docker 测试主机,在新的 rehearsal root 和两个专用合成容器上消费上一阶段已审核的两个 plan digest: ```sh sh upgrade-cutover-rehearsal.sh \ @@ -156,6 +156,32 @@ sh upgrade-cutover-rehearsal.sh \ 原生 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,两者不能互相冒充。 +### 写后 reconciliation capture + +需要演练 target 接受业务写入后的失败关闭路径时,必须使用另一组全新 rehearsal/capture root 和容器名,并显式追加两个参数: + +```sh +sh upgrade-cutover-rehearsal.sh \ + edge \ + /opt/qinglong/data \ + /opt/qinglong3-alpha-upgrade-reconciliation \ + \ + \ + ql3-alpha-reconciliation-legacy \ + ql3-alpha-reconciliation-target \ + --capture-after-write \ + /opt/qinglong3-alpha-reconciliation-capture +``` + +该模式在 target probe 已 active 后,通过既有 Owner `task.put` 产品入口提交固定的无网络/Secret Task,然后停止 target。只有 classifier 返回 `reconciliation_required` 才继续执行 `reconciliation-capture-prepare|commit|verify`。成功时: + +- rehearsal root 写入 `reconciliation-capture-summary.json`,schema 为 `qinglong/local-alpha-upgrade-reconciliation-capture-summary@v1`; +- summary 固定 `status=reconciliation_captured`、`legacySource=unchanged`、`target=stopped`、`rollback=not_authorized` 与 `next=review_required`; +- 独立 capture root 保存 `/{intent.json,manifest.json,receipt.json,assets/}`,必须作为包含数据库与配置材料的 owner-private 恢复资产保护; +- 两个容器保持 stopped 供人工核对,脚本不执行 Legacy restart、rollback、plan、review、application 或 completion。 + +该写入由短生命周期 Owner Operator 提交到 active target 的数据权威,不经过普通 Local API listener,因此只证明写后分类与 capture,不证明浏览器/生产流量接管。原生 artifact job 会先执行默认 clean rollback,再用同一 unchanged fixture 独立实跑本模式;`verification-evidence.json.gates.legacyUpgradeReconciliationCapture` 必须为 `passed`。 + ## 手工加载与最小 smoke 从 `manifest.json.archive.file` 找到 archive 后加载: @@ -183,7 +209,7 @@ docker run --rm --read-only --network none --cap-drop ALL \ ## Fresh 试运行边界 -完整 fresh setup、首 Owner ceremony、Owner presentation 安装、Application active、SIGTERM drain、SQLite integrity 和原生 cancellation 必须在 `verification-evidence.json` 指向的同架构 milestone job 中验证。Console 还必须证明首页返回 200、未认证 API 返回 401,并用真实 Owner credential 完成 Task read、fenced start、`succeeded` 终态与 bounded log marker。v8 artifact job 必须从将要上传的目录实际执行 `quickstart.sh`、read-only `upgrade-readiness.sh` 和 isolated `upgrade-cutover-rehearsal.sh`,并完成 graceful stop、rollback-candidate 检查与合成容器清理。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 +完整 fresh setup、首 Owner ceremony、Owner presentation 安装、Application active、SIGTERM drain、SQLite integrity 和原生 cancellation 必须在 `verification-evidence.json` 指向的同架构 milestone job 中验证。Console 还必须证明首页返回 200、未认证 API 返回 401,并用真实 Owner credential 完成 Task read、fenced start、`succeeded` 终态与 bounded log marker。v9 artifact job 必须从将要上传的目录实际执行 `quickstart.sh`、read-only `upgrade-readiness.sh` 和 isolated `upgrade-cutover-rehearsal.sh` 的 clean/write-after 两条路径,并完成 graceful stop、rollback-candidate、reconciliation capture、旧 SQLite 未变与四个合成容器清理。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 Edge 的验证上限为 Application 128 MiB、0.5 CPU、64 PID;Standalone 为 256 MiB、0.5 CPU、256 PID;operator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。 diff --git a/scripts/ql3-local-alpha-trial-kit-bundle.cjs b/scripts/ql3-local-alpha-trial-kit-bundle.cjs index 6413116b..94770bd7 100644 --- a/scripts/ql3-local-alpha-trial-kit-bundle.cjs +++ b/scripts/ql3-local-alpha-trial-kit-bundle.cjs @@ -10,8 +10,8 @@ const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); -const SCHEMA = 'qinglong/alpha-local-trial-kit@v8'; -const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v6'; +const SCHEMA = 'qinglong/alpha-local-trial-kit@v9'; +const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v7'; const QUICKSTART_TEMPLATE = path.join( DEFAULT_ROOT, 'scripts/templates/ql3-local-alpha-quickstart.sh', @@ -65,6 +65,7 @@ const VERIFICATION = Object.freeze({ legacyUpgradeReadiness: 'passed', legacyUpgradeStage: 'passed', legacyUpgradeCutover: 'passed', + legacyUpgradeReconciliationCapture: 'passed', }); function verificationGates(variant) { @@ -740,7 +741,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { 0o700, ); const manifest = { - schemaVersion: 9, + schemaVersion: 10, schema: SCHEMA, maturity: 'alpha_candidate_not_public_release', product: 'local', @@ -861,7 +862,7 @@ function auditLocalAlphaTrialKit(options) { 'readme', 'verification', ]) || - manifest.schemaVersion !== 9 || + manifest.schemaVersion !== 10 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || @@ -1037,7 +1038,7 @@ function auditLocalAlphaTrialKit(options) { } return Object.freeze({ schemaVersion: 1, - schema: 'qinglong/alpha-local-trial-kit-audit@v5', + schema: 'qinglong/alpha-local-trial-kit-audit@v6', sourceRevision: manifest.sourceRevision, version: manifest.version, architecture: manifest.architecture, diff --git a/scripts/ql3-local-operator-image-audit.cjs b/scripts/ql3-local-operator-image-audit.cjs index 44614135..f1b35d85 100644 --- a/scripts/ql3-local-operator-image-audit.cjs +++ b/scripts/ql3-local-operator-image-audit.cjs @@ -206,6 +206,10 @@ function auditWorkflow(contents, findings) { 'test -s "${QUICKSTART_ROOT}/qinglong3.sqlite"', 'sh "${BUNDLE_ROOT}/upgrade-cutover-rehearsal.sh"', '"status":"rollback_candidate"', + '--capture-after-write "${RECONCILIATION_CAPTURE_ROOT}"', + '"status":"reconciliation_captured"', + '"rollback":"not_authorized"', + 'reconciliation-capture-verify.result.json', 'docker rm "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}"', '--application-sbom="${APPLICATION_SBOM}"', '--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"', @@ -234,6 +238,7 @@ function auditWorkflow(contents, findings) { '--mode=audit', '/quickstart.sh"', '/upgrade-cutover-rehearsal.sh"', + '--capture-after-write', 'name: Upload the tested native Local Alpha trial kit', ]) { const index = contents.indexOf(value, cursor + 1); diff --git a/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh b/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh index 297f677e..ab02030d 100644 --- a/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh +++ b/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh @@ -22,10 +22,20 @@ phase() { usage() { printf '%s\n' \ - 'usage: sh upgrade-cutover-rehearsal.sh edge|standalone /absolute/legacy-data-root /absolute/new/rehearsal-root [legacy-container-name] [target-container-name]' >&2 + 'usage: sh upgrade-cutover-rehearsal.sh edge|standalone /absolute/legacy-data-root /absolute/new/rehearsal-root [legacy-container-name] [target-container-name] [--capture-after-write /absolute/new/capture-root]' >&2 exit 2 } +safe_absolute_path() { + case "$1" in + /|*[!A-Za-z0-9_./@-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/) + return 1 + ;; + /*) return 0 ;; + *) return 1 ;; + esac +} + valid_digest() { [ "${#1}" -eq 64 ] || return 1 case "$1" in *[!0-9a-f]*) return 1 ;; *) return 0 ;; esac @@ -39,11 +49,30 @@ extract_digest() { printf '%s' "$value" } +extract_unsigned() { + result_file=$1 + field=$2 + value=$(sed -n "s/^.*\"$field\":\([0-9][0-9]*\).*$/\1/p" "$result_file") + case "$value" in ''|*[!0-9]*) fail "$field is missing or invalid in $result_file" ;; esac + printf '%s' "$value" +} + container_name() { case "$1" in ''|[_.-]*|*[!A-Za-z0-9_.-]*) return 1 ;; *) return 0 ;; esac } -[ "$#" -ge 5 ] && [ "$#" -le 7 ] || usage +case "$#" in + 5|6|7) + capture_after_write=false + capture_root= + ;; + 9) + [ "$8" = '--capture-after-write' ] || usage + capture_after_write=true + capture_root=$9 + ;; + *) usage ;; +esac case "$VARIANT" in headless|console) ;; *) fail 'embedded Trial Kit variant is invalid' ;; @@ -66,6 +95,22 @@ container_name "$target_name" || fail 'target container name is invalid' [ "$legacy_name" != "$target_name" ] || fail 'container names must be distinct' valid_digest "$sqlite_plan_digest" || fail 'reviewed SQLite plan digest is invalid' valid_digest "$directory_plan_digest" || fail 'reviewed data-directory plan digest is invalid' +if [ "$capture_after_write" = true ]; then + safe_absolute_path "$capture_root" || fail 'capture root is not a safe canonical absolute path' + [ "$capture_root" != "$legacy_root" ] || fail 'capture and legacy roots must be distinct' + [ "$capture_root" != "$rehearsal_root" ] || fail 'capture and rehearsal roots must be distinct' + case "$capture_root/" in + "$legacy_root"/*|"$rehearsal_root"/*) fail 'capture root must not be inside a data root' ;; + esac + case "$legacy_root/" in "$capture_root"/*) fail 'legacy root must not be inside the capture root' ;; esac + case "$rehearsal_root/" in "$capture_root"/*) fail 'rehearsal root must not be inside the capture root' ;; esac + [ ! -e "$capture_root" ] || fail 'capture root must not already exist' + capture_parent=${capture_root%/*} + [ -n "$capture_parent" ] || capture_parent=/ + [ -d "$capture_parent" ] || fail 'capture root parent does not exist' + capture_parent_real=$(CDPATH= cd -- "$capture_parent" && pwd -P) + [ "$capture_parent_real/${capture_root##*/}" = "$capture_root" ] || fail 'capture root parent is not canonical' +fi [ "$(uname -s)" = Linux ] || fail 'cutover rehearsal requires a Linux Docker host' for tool in docker sha256sum grep sed stat date realpath tr; do command -v "$tool" >/dev/null 2>&1 || fail "$tool is required" @@ -101,6 +146,9 @@ umask 077 for directory in owner-peppers owner-pepper-backup owner-delivery receipts artifacts plugin-staging plugin-activation service service/cutovers service/cutovers/alpha-upgrade-cutover; do [ -d "$rehearsal_root/$directory" ] || mkdir -m 0700 "$rehearsal_root/$directory" done +if [ "$capture_after_write" = true ]; then + mkdir -m 0700 "$capture_root" +fi run_operator() { command_name=$1 @@ -149,6 +197,22 @@ run_deployment_offline() { >"$rehearsal_root/results/$result_file" } +run_reconciliation_offline() { + subcommand=$1 + command_file=$2 + result_file=$3 + docker run --rm --read-only --user "$uid:$gid" --network none \ + --cap-drop ALL --security-opt no-new-privileges \ + --memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \ + --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ + --mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \ + --mount "type=bind,src=$capture_root,dst=$capture_root" \ + "$OPERATOR_IMAGE" deploy "$subcommand" \ + --command-file "$rehearsal_root/commands/$command_file" \ + >"$rehearsal_root/results/$result_file" +} + cat >"$rehearsal_root/commands/setup.json" <&2 sed -n '1p' "$rehearsal_root/results/target-start.result.json" >&2 grep -q '"state":"target_active"' "$rehearsal_root/results/target-start.result.json" || fail 'target did not become active' +if [ "$capture_after_write" = true ]; then + write_ms=$((start_ms + 1)) + cat >"$rehearsal_root/commands/post-cutover-task.json" <"$rehearsal_root/commands/target-stop.json" chmod 0600 "$rehearsal_root/commands/target-stop.json" -phase 'stop target and prove rollback candidate' +if [ "$capture_after_write" = true ]; then + phase 'stop written target and require reconciliation' +else + phase 'stop target and prove rollback candidate' +fi run_deploy cutover-target-stop target-stop.json target-stop.result.json printf '%s' 'QingLong Local Alpha target-stop result: ' >&2 sed -n '1p' "$rehearsal_root/results/target-stop.result.json" >&2 printf '%s' 'QingLong Local Alpha target-stop evidence: ' >&2 tr -d '\n' <"$rehearsal_root/service/cutovers/alpha-upgrade-cutover/0006-target-stop-outcome.json" >&2 printf '\n' >&2 -grep -q '"reconciliation":"rollback_candidate"' "$rehearsal_root/results/target-stop.result.json" || fail 'target stop did not produce a clean rollback candidate' [ "$(sha256sum "$legacy_root/db/database.sqlite" | sed 's/ .*//')" = "$legacy_sha256" ] || fail 'legacy database changed during rehearsal' +if [ "$capture_after_write" = true ]; then + grep -q '"reconciliation":"reconciliation_required"' "$rehearsal_root/results/target-stop.result.json" || fail 'written target was not fenced for reconciliation' + stopped_head_digest=$(extract_digest "$rehearsal_root/results/target-stop.result.json" instanceHeadDigest) + stopped_record_digest=$(extract_digest "$rehearsal_root/results/target-stop.result.json" recordDigest) + capture_id='019f8680-143d-4000-8000-000000000161' + capture_prepare_ms=$((stop_ms + 1)) + cat >"$rehearsal_root/commands/reconciliation-capture-prepare.json" <"$rehearsal_root/commands/reconciliation-capture-commit.json" <"$rehearsal_root/commands/reconciliation-capture-verify.json" <"$rehearsal_root/reconciliation-capture-summary.json" <"$rehearsal_root/cutover-summary.json" < { const paths = fixture(t); const manifest = createLocalAlphaTrialKit(createOptions(paths), adapters()); - assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v8'); + assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v9'); + assert.equal(manifest.schemaVersion, 10); assert.equal(manifest.variant, 'headless'); assert.equal(manifest.sourceRevision, revision); assert.equal(manifest.architecture, 'arm64'); @@ -278,6 +279,70 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { /QingLong Local Alpha target-stop evidence:/, ); assert.match(cutoverRehearsalContents, /tr -d '\\n'/); + assert.match(cutoverRehearsalContents, /--capture-after-write/); + assert.match(cutoverRehearsalContents, /"operation":"task\.put"/); + assert.match( + cutoverRehearsalContents, + /local\.deployment\.reconciliation\.capture\.prepare/, + ); + assert.match( + cutoverRehearsalContents, + /local\.deployment\.reconciliation\.capture\.commit/, + ); + assert.match( + cutoverRehearsalContents, + /local\.deployment\.reconciliation\.capture\.verify/, + ); + assert.match( + cutoverRehearsalContents, + /qinglong\/local-alpha-upgrade-reconciliation-capture-summary@v1/, + ); + const postWriteTemplate = cutoverRehearsalContents.match( + /cat >"\$rehearsal_root\/commands\/post-cutover-task\.json" <"\$rehearsal_root\/commands\/reconciliation-capture-prepare\.json" < { assert.equal(fs.existsSync(paths.outputRoot), false); }); +test('create rejects verification without the exact reconciliation capture gate', (t) => { + const paths = fixture(t); + const evidence = JSON.parse( + fs.readFileSync(paths.verificationEvidence, 'utf8'), + ); + delete evidence.gates.legacyUpgradeReconciliationCapture; + fs.writeFileSync(paths.verificationEvidence, `${JSON.stringify(evidence)}\n`); + assert.throws( + () => createLocalAlphaTrialKit(createOptions(paths), adapters()), + /verification evidence is incompatible/, + ); + assert.equal(fs.existsSync(paths.outputRoot), false); +}); + test('verification recorder rejects non-milestone workflow provenance', (t) => { const paths = fixture(t); const output = path.join(paths.fixtureRoot, 'unreviewed-verification.json'); diff --git a/test/back/ql3LocalOperatorImageAudit.test.cjs b/test/back/ql3LocalOperatorImageAudit.test.cjs index 15ec2b82..34bad8b4 100644 --- a/test/back/ql3LocalOperatorImageAudit.test.cjs +++ b/test/back/ql3LocalOperatorImageAudit.test.cjs @@ -165,3 +165,42 @@ test('rejects Alpha evidence recorded before all native gates', () => { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); + +test('rejects removal of the exact post-write reconciliation capture gate', () => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-local-reconciliation-ci-audit-'), + ); + try { + fs.mkdirSync(path.join(temporaryRoot, 'deploy/containers'), { + recursive: true, + }); + fs.cpSync( + path.join(root, 'deploy/containers/ql3-local-operator'), + path.join(temporaryRoot, 'deploy/containers/ql3-local-operator'), + { recursive: true }, + ); + fs.mkdirSync(path.join(temporaryRoot, '.github/workflows'), { + recursive: true, + }); + const workflow = fs + .readFileSync(path.join(root, '.github/workflows/ql3-ci.yml'), 'utf8') + .replace('--capture-after-write', '--capture-gate-removed'); + fs.writeFileSync( + path.join(temporaryRoot, '.github/workflows/ql3-ci.yml'), + workflow, + ); + fs.copyFileSync( + path.join(root, 'ql3-release.json'), + path.join(temporaryRoot, 'ql3-release.json'), + ); + const report = auditLocalOperatorImageContract(temporaryRoot); + assert.equal(report.compatible, false); + assert.ok( + report.findings.some( + ({ code }) => code === 'LOCAL_OPERATOR_CI_CONTRACT_DRIFT', + ), + ); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +});