mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): capture post-cutover reconciliation evidence
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <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、连接池或稳态资源;默认低配路由设备仍选择 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。
|
||||
|
||||
@@ -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、生产停机窗口与演练过的恢复责任人。
|
||||
@@ -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 已闭合;双架构实物待交付) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -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-<arch>.docker.tar`,Console 为 `qinglong3-local-console-trial-kit-<arch>.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,它不替代正式签名;
|
||||
|
||||
@@ -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 \
|
||||
<reviewed-sqlite-plan-digest> \
|
||||
<reviewed-data-directory-plan-digest> \
|
||||
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 保存 `<captureId>/{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 的容量承诺。
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <reviewed-sqlite-plan-digest> <reviewed-data-directory-plan-digest> [legacy-container-name] [target-container-name]' >&2
|
||||
'usage: sh upgrade-cutover-rehearsal.sh edge|standalone /absolute/legacy-data-root /absolute/new/rehearsal-root <reviewed-sqlite-plan-digest> <reviewed-data-directory-plan-digest> [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" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.setup.prepare","options":{"deploymentRoot":"$rehearsal_root","databasePath":"$rehearsal_root/sqlite/qinglong3.sqlite","profile":"$profile","ownerPepperKeyringDirectory":"$rehearsal_root/owner-peppers","ownerPepperBackupDirectory":"$rehearsal_root/owner-pepper-backup","ownerPepperKeyId":"owner-v1","localSecretKeyringPath":"$rehearsal_root/local-secret-keyring.json","busyTimeoutMs":100},"request":{"registerMutationId":"019f8680-143d-4000-8000-000000000111","activateMutationId":"019f8680-143d-4000-8000-000000000112","registeredAtMs":1785254400100,"activatedAtMs":1785254400101}}
|
||||
EOF
|
||||
@@ -287,21 +351,87 @@ run_deploy cutover-target-start target-start.json target-start.result.json
|
||||
printf '%s' 'QingLong Local Alpha target-start result: ' >&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" <<EOF
|
||||
{"schemaVersion":1,"operation":"task.put","options":{"deploymentRoot":"$rehearsal_root","databasePath":"$rehearsal_root/sqlite/qinglong3.sqlite","profile":"$profile","ownerPepperKeyringDirectory":"$rehearsal_root/owner-peppers","credentialFilePath":"$rehearsal_root/owner-credential.json","busyTimeoutMs":100},"request":{"projectId":"default","taskId":"alpha-post-cutover-write","expectedRevision":null,"mutationId":"019f8680-143d-4000-8000-000000000151","requestId":"alpha-upgrade-post-cutover-write","failureAuditEventId":"019f8680-143d-4000-8000-000000000152","name":"QingLong 3.0 post-cutover write proof","description":"A bounded offline Task proving the adopted target database changed after target activation","kind":"command","spec":{"schema":"qinglong/command@v1","config":{"command":{"kind":"argv","file":"/bin/echo","args":["qinglong3-alpha-post-cutover-write"]}}},"labels":{"qinglong.alpha.reconciliation":"true"},"enabled":true,"occurredAtMs":$write_ms}}
|
||||
EOF
|
||||
chmod 0600 "$rehearsal_root/commands/post-cutover-task.json"
|
||||
phase 'commit one Owner-authorized post-cutover business write'
|
||||
run_operator task post-cutover-task.json post-cutover-task.result.json
|
||||
grep -q '"status":"created"' "$rehearsal_root/results/post-cutover-task.result.json" || fail 'post-cutover Task write did not commit'
|
||||
fi
|
||||
stop_ms=$((start_ms + 1))
|
||||
[ "$capture_after_write" = false ] || stop_ms=$((write_ms + 1))
|
||||
sed "s/\"operation\":\"local.deployment.cutover.target-start\"/\"operation\":\"local.deployment.cutover.target-stop\"/;s/\"requestedAtMs\":$start_ms/\"requestedAtMs\":$stop_ms/" \
|
||||
"$rehearsal_root/commands/target-start.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" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.capture.prepare","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","allowRootService":$allow_root_service},"request":{"captureId":"$capture_id","stoppedAuthority":"docker","profile":"$profile","instanceId":"alpha-upgrade","cutoverId":"alpha-upgrade-cutover","generation":1,"applicationConfigPath":"$rehearsal_root/local-application.json","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","expectedActivationDigest":"$activation_digest","expectedHeadDigest":"$stopped_head_digest","expectedStoppedRecordDigest":"$stopped_record_digest","preparedAtMs":$capture_prepare_ms}}
|
||||
EOF
|
||||
chmod 0600 "$rehearsal_root/commands/reconciliation-capture-prepare.json"
|
||||
phase 'prepare immutable post-write reconciliation capture'
|
||||
run_reconciliation_offline reconciliation-capture-prepare reconciliation-capture-prepare.json reconciliation-capture-prepare.result.json
|
||||
grep -q '"state":"reconciliation_capture_prepared"' "$rehearsal_root/results/reconciliation-capture-prepare.result.json" || fail 'reconciliation capture was not prepared'
|
||||
preparation_digest=$(extract_digest "$rehearsal_root/results/reconciliation-capture-prepare.result.json" preparationDigest)
|
||||
capture_commit_ms=$((capture_prepare_ms + 1))
|
||||
cat >"$rehearsal_root/commands/reconciliation-capture-commit.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.capture.commit","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","allowRootService":$allow_root_service},"request":{"captureId":"$capture_id","expectedPreparationDigest":"$preparation_digest","committedAtMs":$capture_commit_ms}}
|
||||
EOF
|
||||
chmod 0600 "$rehearsal_root/commands/reconciliation-capture-commit.json"
|
||||
phase 'commit immutable post-write reconciliation capture'
|
||||
run_reconciliation_offline reconciliation-capture-commit reconciliation-capture-commit.json reconciliation-capture-commit.result.json
|
||||
grep -q '"state":"reconciliation_captured"' "$rehearsal_root/results/reconciliation-capture-commit.result.json" || fail 'reconciliation capture did not commit'
|
||||
capture_bundle_digest=$(extract_digest "$rehearsal_root/results/reconciliation-capture-commit.result.json" bundleDigest)
|
||||
cat >"$rehearsal_root/commands/reconciliation-capture-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.capture.verify","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","allowRootService":$allow_root_service},"request":{"captureId":"$capture_id","expectedBundleDigest":"$capture_bundle_digest"}}
|
||||
EOF
|
||||
chmod 0600 "$rehearsal_root/commands/reconciliation-capture-verify.json"
|
||||
phase 'verify immutable post-write reconciliation capture'
|
||||
run_reconciliation_offline reconciliation-capture-verify reconciliation-capture-verify.json reconciliation-capture-verify.result.json
|
||||
grep -q '"status":"verified"' "$rehearsal_root/results/reconciliation-capture-verify.result.json" || fail 'reconciliation capture verification failed'
|
||||
capture_head_digest=$(extract_digest "$rehearsal_root/results/reconciliation-capture-verify.result.json" instanceHeadDigest)
|
||||
capture_asset_count=$(extract_unsigned "$rehearsal_root/results/reconciliation-capture-verify.result.json" assetCount)
|
||||
capture_total_bytes=$(extract_unsigned "$rehearsal_root/results/reconciliation-capture-verify.result.json" totalBytes)
|
||||
cat >"$rehearsal_root/reconciliation-capture-summary.json" <<EOF
|
||||
{"schemaVersion":1,"schema":"qinglong/local-alpha-upgrade-reconciliation-capture-summary@v1","status":"reconciliation_captured","profile":"$profile","variant":"$VARIANT","targetEntrypoint":"$target_entrypoint","sourceRevision":"$SOURCE_REVISION","architecture":"$ARCHITECTURE","reviewedPlans":{"sqlite":"$sqlite_plan_digest","dataDirectory":"$directory_plan_digest"},"activationDigest":"$activation_digest","write":{"operation":"task.put","taskId":"alpha-post-cutover-write","status":"created"},"cutover":{"commitmentDigest":"$commitment_digest","bundleDigest":"$bundle_digest","legacyContainerId":"$legacy_id","targetContainerId":"$target_id","stoppedRecordDigest":"$stopped_record_digest"},"capture":{"captureId":"$capture_id","bundleDigest":"$capture_bundle_digest","assetCount":$capture_asset_count,"totalBytes":$capture_total_bytes,"instanceHeadDigest":"$capture_head_digest"},"legacySource":"unchanged","target":"stopped","rollback":"not_authorized","next":"review_required"}
|
||||
EOF
|
||||
chmod 0600 "$rehearsal_root/reconciliation-capture-summary.json"
|
||||
umask "$old_umask"
|
||||
cleanup_required=0
|
||||
trap - EXIT HUP INT TERM
|
||||
printf '%s\n' \
|
||||
"QingLong 3.0 isolated post-write reconciliation capture completed ($profile, $ARCHITECTURE)." \
|
||||
"Summary: $rehearsal_root/reconciliation-capture-summary.json" \
|
||||
"Sealed capture: $capture_root/$capture_id" \
|
||||
"Stopped legacy container: $legacy_name ($legacy_id)" \
|
||||
"Stopped target container: $target_name ($target_id)" \
|
||||
"Remove containers after review: docker rm $target_name $legacy_name" \
|
||||
'The original legacy SQLite remained unchanged. Rollback and automatic reconciliation were not authorized.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
grep -q '"reconciliation":"rollback_candidate"' "$rehearsal_root/results/target-stop.result.json" || fail 'target stop did not produce a clean rollback candidate'
|
||||
cat >"$rehearsal_root/cutover-summary.json" <<EOF
|
||||
{"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
|
||||
|
||||
@@ -166,7 +166,8 @@ function adapters(overrides = {}, variant = 'headless') {
|
||||
test('materializes and offline-audits one closed two-image trial kit', (t) => {
|
||||
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" <<EOF\n([^\n]+)\nEOF/,
|
||||
);
|
||||
assert.ok(postWriteTemplate, 'post-cutover Task command template is missing');
|
||||
const postWriteCommand = JSON.parse(
|
||||
postWriteTemplate[1]
|
||||
.replaceAll('$write_ms', '1')
|
||||
.replace(/\$[A-Za-z_][A-Za-z0-9_]*/g, 'fixture'),
|
||||
);
|
||||
assert.equal(postWriteCommand.operation, 'task.put');
|
||||
assert.equal(postWriteCommand.request.taskId, 'alpha-post-cutover-write');
|
||||
assert.equal(postWriteCommand.request.expectedRevision, null);
|
||||
const capturePrepareTemplate = cutoverRehearsalContents.match(
|
||||
/cat >"\$rehearsal_root\/commands\/reconciliation-capture-prepare\.json" <<EOF\n([^\n]+)\nEOF/,
|
||||
);
|
||||
assert.ok(
|
||||
capturePrepareTemplate,
|
||||
'reconciliation capture prepare command template is missing',
|
||||
);
|
||||
const capturePrepareCommand = JSON.parse(
|
||||
capturePrepareTemplate[1]
|
||||
.replaceAll('$allow_root_service', 'false')
|
||||
.replaceAll('$capture_prepare_ms', '1')
|
||||
.replace(/\$[A-Za-z_][A-Za-z0-9_]*/g, 'fixture'),
|
||||
);
|
||||
assert.equal(
|
||||
capturePrepareCommand.operation,
|
||||
'local.deployment.reconciliation.capture.prepare',
|
||||
);
|
||||
assert.deepEqual(Object.keys(capturePrepareCommand.request).sort(), [
|
||||
'activationPath',
|
||||
'applicationConfigPath',
|
||||
'captureId',
|
||||
'cutoverId',
|
||||
'expectedActivationDigest',
|
||||
'expectedHeadDigest',
|
||||
'expectedStoppedRecordDigest',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacySourcePath',
|
||||
'preparedAtMs',
|
||||
'profile',
|
||||
'recoveryPath',
|
||||
'stoppedAuthority',
|
||||
'targetDatabasePath',
|
||||
]);
|
||||
const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot });
|
||||
assert.equal(report.compatible, true);
|
||||
assert.equal(report.sourceRevision, revision);
|
||||
@@ -329,6 +394,7 @@ test('materializes a distinct loopback Console trial kit without widening the he
|
||||
assert.equal(verification.gates.legacyUpgradeReadiness, 'passed');
|
||||
assert.equal(verification.gates.legacyUpgradeStage, 'passed');
|
||||
assert.equal(verification.gates.legacyUpgradeCutover, 'passed');
|
||||
assert.equal(verification.gates.legacyUpgradeReconciliationCapture, 'passed');
|
||||
const quickstartContents = fs.readFileSync(
|
||||
path.join(paths.outputRoot, 'quickstart.sh'),
|
||||
'utf8',
|
||||
@@ -784,6 +850,20 @@ test('create rejects verification detached from the reviewed workflow', (t) => {
|
||||
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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user