feat(ql3): add reviewed upgrade staging

This commit is contained in:
whyour
2026-08-30 18:14:30 +08:00
parent 157b1d9afd
commit 7a8acacb6c
14 changed files with 437 additions and 22 deletions
+14
View File
@@ -773,6 +773,20 @@ jobs:
"${UPGRADE_EVIDENCE_ROOT}/results/sqlite-inspect.result.json" "${UPGRADE_EVIDENCE_ROOT}/results/sqlite-inspect.result.json"
grep -q '"status":"inspected"' \ grep -q '"status":"inspected"' \
"${UPGRADE_EVIDENCE_ROOT}/results/data-directory-inspect.result.json" "${UPGRADE_EVIDENCE_ROOT}/results/data-directory-inspect.result.json"
SQLITE_PLAN_DIGEST="$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).evidence.planDigest)' \
"${UPGRADE_EVIDENCE_ROOT}/results/sqlite-inspect.result.json")"
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-rehearsal-${{ matrix.image_arch }}"
sh "${BUNDLE_ROOT}/upgrade-rehearsal.sh" \
edge "${LEGACY_READINESS_ROOT}" "${UPGRADE_REHEARSAL_ROOT}" \
"${SQLITE_PLAN_DIGEST}" "${DIRECTORY_PLAN_DIGEST}"
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"
test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-wal" test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-wal"
test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-journal" test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-journal"
trap - EXIT trap - EXIT
+2
View File
@@ -1,5 +1,7 @@
# QingLong 3.0 Architecture RFC # QingLong 3.0 Architecture RFC
- D-426a/ADR-0521(已实现源码候选,双架构实物待 CI):二十天研发的阶段产物继续从“只读盘点”推进到仍不触碰生产切换的 side-by-side 暂存。Trial Kit 新增 canonical `upgrade-rehearsal.sh`,只接受操作者从 D-425 完整 evidence 中审核并显式提交的 SQLite/data-directory plan digest;它核对整包与 exact Operator identity 后,在新的 `0700/0600` rehearsal root 内以 read-only legacy bind、无网络、只读 rootfs、drop-all、128 MiB/0.5 CPU/32 PID 顺序执行 SQLite stage/verify/activation 和完整目录 stage/verify。`stage-summary.json` 绑定 source/architecture、两个 reviewed plan、SQLite manifest/activation 与目录 manifest digest,并固定 `legacySource=read_only``cutover=not_authorized`。Trial Kit/verification/auditor 升为 `@v7/@v5/@v4`Local milestone 升为 `@v4` 并绑定双架构 `upgradeRehearsalSha256`;显式 artifact job 必须在将要上传的 exact bundle 上从 readiness plan 接续实跑 rehearsal。没有 transform/apply、Owner/Secret authority、2.x stop、3.0 target start、cutover、rollback、migration、依赖、package、daemon、listener、timer 或稳态资源增量;D-426b/c 才分别闭合 adopted start/clean rollback 与 write-after reconciliation。
- D-425/ADR-0520(已交付同源双架构 headless Alpha 阶段实物):二十天研发的 Local 阶段产物开始服务现有 2.x 部署,而不再只支持 fresh 数据根。Trial Kit 新增 manifest/checksum/auditor 共同绑定的 canonical `upgrade-readiness.sh`;它只接受 `edge|standalone`、canonical 2.x data root 和尚不存在的 evidence root,先核对 exact Operator image/source/architecture,再以当前 UID:GID、read-only legacy bind mount、只读 rootfs、`network=none`、drop-all capabilities、128 MiB memory/swap、0.5 CPU、32 PID 运行正式 `local-sqlite.adoption.inspect``local-data-directory.adoption.inspect`。两个完整 plan 写入独立 `0700/0600` evidence,不解析或代替人工审核,不执行 stage、activation、transform/apply、cutover 或 rollback。Trial Kit/verification/auditor 升级为 `@v6/@v4/@v3`Local milestone 升级为 `@v3` 并直接绑定双架构 readiness script digest;显式 artifact job 必须在原生 amd64/arm64 上创建生产形态 2.x fixture,实跑将要上传的 exact 脚本并要求两个结果均为 `inspected`。无新 package、依赖、migration、listener、daemon、timer、watcher 或 Cluster authority;聚焦 bundle/milestone/stage/fixture 回归为 `30/30`。提交 `d6571e4b89eaf29ed6277dd08bbd7ffb57a3705d` 的普通 CI [run 33295923855](https://github.com/whyour/qinglong/actions/runs/33295923855) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33295923822](https://github.com/whyour/qinglong/actions/runs/33295923822) 成功;显式 Local headless [run 33300121149](https://github.com/whyour/qinglong/actions/runs/33300121149) 为 42 success/2 scope skip/0 fail,两个原生架构均实跑 exact readiness 并由 bundle auditor 返回 `compatible=true`,下载后的 milestone v3 再确认 source/run/attempt 与两个不同 readiness digest 闭合。amd64/arm64/milestone artifact 保留至 2026-09-29;它们仍为 `alpha_candidate_not_public_release`,该切片也仍只是完整 side-by-side 升级与 clean/write-after 回退 rehearsal 的第一阶段。 - D-425/ADR-0520(已交付同源双架构 headless Alpha 阶段实物):二十天研发的 Local 阶段产物开始服务现有 2.x 部署,而不再只支持 fresh 数据根。Trial Kit 新增 manifest/checksum/auditor 共同绑定的 canonical `upgrade-readiness.sh`;它只接受 `edge|standalone`、canonical 2.x data root 和尚不存在的 evidence root,先核对 exact Operator image/source/architecture,再以当前 UID:GID、read-only legacy bind mount、只读 rootfs、`network=none`、drop-all capabilities、128 MiB memory/swap、0.5 CPU、32 PID 运行正式 `local-sqlite.adoption.inspect``local-data-directory.adoption.inspect`。两个完整 plan 写入独立 `0700/0600` evidence,不解析或代替人工审核,不执行 stage、activation、transform/apply、cutover 或 rollback。Trial Kit/verification/auditor 升级为 `@v6/@v4/@v3`Local milestone 升级为 `@v3` 并直接绑定双架构 readiness script digest;显式 artifact job 必须在原生 amd64/arm64 上创建生产形态 2.x fixture,实跑将要上传的 exact 脚本并要求两个结果均为 `inspected`。无新 package、依赖、migration、listener、daemon、timer、watcher 或 Cluster authority;聚焦 bundle/milestone/stage/fixture 回归为 `30/30`。提交 `d6571e4b89eaf29ed6277dd08bbd7ffb57a3705d` 的普通 CI [run 33295923855](https://github.com/whyour/qinglong/actions/runs/33295923855) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33295923822](https://github.com/whyour/qinglong/actions/runs/33295923822) 成功;显式 Local headless [run 33300121149](https://github.com/whyour/qinglong/actions/runs/33300121149) 为 42 success/2 scope skip/0 fail,两个原生架构均实跑 exact readiness 并由 bundle auditor 返回 `compatible=true`,下载后的 milestone v3 再确认 source/run/attempt 与两个不同 readiness digest 闭合。amd64/arm64/milestone artifact 保留至 2026-09-29;它们仍为 `alpha_candidate_not_public_release`,该切片也仍只是完整 side-by-side 升级与 clean/write-after 回退 rehearsal 的第一阶段。
- 全范围 Alpha 阶段交付已形成真实实物:提交 `97333da34cce48cdfcfa1bbd5e8d48340802d2ef` 的显式 `all + headless` run `33265538836` 为 44/44 success,首次同时生成 Local headless 双架构 Trial Kit、Cluster control/control-ai/admin/worker 双架构 bundle、Local/Cluster milestone 和 `qinglong/alpha-stage-index@v2`。下载后的三个小索引均通过 `SHA256SUMS` 与仓库离线 auditor,绑定同一 source、workflow SHA/ref、run/attemptstage index 为 Edge、Standalone、Cluster 精确选择 10 个 artifact。低配设备只需目标架构 Local 包且稳态仅运行 Application;集群节点只需目标架构 control/admin/workercontrol-ai 显式可选。该交付保留至 2026-09-28,成熟度仍为 Alpha Integration Candidate,不等同于长期下载、受保护 tag、不可变 GHCR digest、签名、生产 deployment lock、2.x 升级/回退或 LTS。 - 全范围 Alpha 阶段交付已形成真实实物:提交 `97333da34cce48cdfcfa1bbd5e8d48340802d2ef` 的显式 `all + headless` run `33265538836` 为 44/44 success,首次同时生成 Local headless 双架构 Trial Kit、Cluster control/control-ai/admin/worker 双架构 bundle、Local/Cluster milestone 和 `qinglong/alpha-stage-index@v2`。下载后的三个小索引均通过 `SHA256SUMS` 与仓库离线 auditor,绑定同一 source、workflow SHA/ref、run/attemptstage index 为 Edge、Standalone、Cluster 精确选择 10 个 artifact。低配设备只需目标架构 Local 包且稳态仅运行 Application;集群节点只需目标架构 control/admin/workercontrol-ai 显式可选。该交付保留至 2026-09-28,成熟度仍为 Alpha Integration Candidate,不等同于长期下载、受保护 tag、不可变 GHCR digest、签名、生产 deployment lock、2.x 升级/回退或 LTS。
@@ -0,0 +1,43 @@
# ADR-0521:受审核计划驱动的 Local Side-by-side 升级暂存
- 状态:Proposed(源码候选;原生 amd64/arm64 Trial Kit 实物待 CI 生成)
- 日期:2026-08-30
- 决策:D-426a
## 上下文
D-425 让既有 2.x 部署可以用可下载 Trial Kit 只读生成 SQLite 与完整数据目录计划,但二十天研发不能长期停留在“能盘点、不能形成下一阶段实物”。另一方面,直接把 inspect 接成自动切换会绕过计划审核,并把复制、转换、运行新目标和回退混成一个不可审计动作。
## 决策
Local Alpha Trial Kit 增加 canonical `upgrade-rehearsal.sh`。操作者必须显式提供 D-425 两个完整结果中已经审核的 64 位 `planDigest`;脚本不从终端输出猜测或替操作者批准计划。它只接受 `edge|standalone`、canonical 2.x data root 和尚不存在且与旧根互不包含的 rehearsal root。
脚本核对整包 checksum、exact Operator image ID/source/architecture 后,以当前 POSIX UID/GID、无网络、只读 rootfs、drop-all capabilities、128 MiB、0.5 CPU、32 PID 的短生命周期 Operator 顺序执行:
1. `local-sqlite.adoption.stage`
2. `local-sqlite.adoption.verify`
3. `local-sqlite.activation.prepare`
4. `local-data-directory.adoption.stage`
5. `local-data-directory.adoption.verify`
2.x root 在全部命令中始终是 read-only bind mount;目标、recovery、manifest、activation、完整目录 staging 和每次产品命令结果只写入新的 `0700/0600` rehearsal root。最终 `stage-summary.json` 精确绑定 source、架构、两个 reviewed plan digest、SQLite manifest/activation digest 和目录 manifest digest,并固定声明 `legacySource=read_only``cutover=not_authorized`
该阶段不执行 data-directory transform/apply,不建立 Owner/Secret authority,不停止 2.x,不创建或启动 3.0 Application 容器,也不授权 target start、cutover、Legacy rollback 或生产写入。失败后的 rehearsal root 作为诊断证据保留,不自动删除或重用。
Trial Kit/verification/auditor 升级为 `qinglong/alpha-local-trial-kit@v7``qinglong/alpha-local-trial-kit-verification@v5``qinglong/alpha-local-trial-kit-audit@v4`,新增 `legacyUpgradeStage=passed``upgradeRehearsalSha256`。Local milestone 升为 `qinglong/alpha-local-milestone@v4`,直接绑定两个原生架构不同的 rehearsal script digestStage index 只接受该新 Local milestone schema。
## 阶段实物门
普通 push/PR 只验证源码,不生成可交付实物。显式 Local Alpha artifact job 必须在将要上传的 exact bundle 上:
1. 创建生产形态 2.x fixture
2. 实跑 bundle 内 `upgrade-readiness.sh` 并读取两个 plan digest
3. 把这两个 exact digest 交给 bundle 内 `upgrade-rehearsal.sh`
4. 要求 `stage-summary.json.status=verified``legacySource=read_only``cutover=not_authorized`
5. 再由闭合 bundle auditor 和双架构 milestone finalizer 审计后上传。
缺少任一原生架构、完整 CI、milestone v4 或 exact script digest 的 archive 都只是中间文件。
## 后续
D-426b 才处理受认证的 data-directory transform/apply、adopted deployment bundle、目标启动/停止和 clean `rollback_candidate`;D-426c 再闭合目标产生新事实后的 `reconciliation_required`。Public prerelease/release 仍需维护者显式授权。
+2 -1
View File
@@ -523,7 +523,8 @@
| [ADR-0517](./ADR-0517-strong-local-console-task-authoring-lease.md) | 强认证 Local Console Task authoring lease | AcceptedD-422 双架构 milestone 已交付) | | [ADR-0517](./ADR-0517-strong-local-console-task-authoring-lease.md) | 强认证 Local Console Task authoring lease | AcceptedD-422 双架构 milestone 已交付) |
| [ADR-0518](./ADR-0518-local-console-cron-trigger-administration.md) | Local Console cron Trigger 管理 | AcceptedD-423 双架构 milestone 已交付) | | [ADR-0518](./ADR-0518-local-console-cron-trigger-administration.md) | Local Console cron Trigger 管理 | AcceptedD-423 双架构 milestone 已交付) |
| [ADR-0519](./ADR-0519-local-console-secret-backed-automation.md) | Local Console Secret-backed 自动化 | AcceptedD-424 双架构 milestone 已交付) | | [ADR-0519](./ADR-0519-local-console-secret-backed-automation.md) | Local Console Secret-backed 自动化 | AcceptedD-424 双架构 milestone 已交付) |
| [ADR-0520](./ADR-0520-downloadable-local-legacy-upgrade-readiness.md) | 可下载的 Local Legacy 升级就绪盘点 | AcceptedD-425 源码候选,双架构 Alpha 实物待生成 | | [ADR-0520](./ADR-0520-downloadable-local-legacy-upgrade-readiness.md) | 可下载的 Local Legacy 升级就绪盘点 | AcceptedD-425 双架构 Alpha 实物已交付 |
| [ADR-0521](./ADR-0521-reviewed-side-by-side-local-upgrade-stage.md) | 受审核计划驱动的 Local Side-by-side 升级暂存 | ProposedD-426a 源码候选,双架构 Alpha 实物待生成) |
## 规则 ## 规则
+3 -2
View File
@@ -31,6 +31,7 @@
| D-423 Console cron Trigger 管理切片 | 已复用既有 immutable Trigger、Task pin、durable schedule 与原子 audit authorityConsole/API 可 list/read/create/update/enable/disable `qinglong/cron@v1`,真实 SQLite/loopback 与同源双架构 Console milestone 已通过 | Cluster 不复用 Local proof;不提供删除或通用 Trigger schema 编辑;仍不是正式发布或生产升级 | | D-423 Console cron Trigger 管理切片 | 已复用既有 immutable Trigger、Task pin、durable schedule 与原子 audit authorityConsole/API 可 list/read/create/update/enable/disable `qinglong/cron@v1`,真实 SQLite/loopback 与同源双架构 Console milestone 已通过 | Cluster 不复用 Local proof;不提供删除或通用 Trigger schema 编辑;仍不是正式发布或生产升级 |
| D-424 Console Secret-backed 自动化切片 | current-only metadata、强认证 AES-256-GCM create/rotate 与 Task pinned `SecretRef` 绑定已完成;真实 SQLite/loopback、本地与远端门、同源双架构 Console milestone 及离线 auditor 均通过 | Cluster 不复用 Local proof/custody;不提供明文读取、删除或历史浏览;仍不是正式发布或生产升级 | | D-424 Console Secret-backed 自动化切片 | current-only metadata、强认证 AES-256-GCM create/rotate 与 Task pinned `SecretRef` 绑定已完成;真实 SQLite/loopback、本地与远端门、同源双架构 Console milestone 及离线 auditor 均通过 | Cluster 不复用 Local proof/custody;不提供明文读取、删除或历史浏览;仍不是正式发布或生产升级 |
| 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-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 源码候选新增 reviewed-plan `upgrade-rehearsal.sh`,在新的私有 root 中执行 SQLite stage/verify/activation 与完整目录 stage/verifylegacy root 始终只读,summary 固定 `cutover=not_authorized` | 原生 amd64/arm64 artifact 与 milestone v4 待生成;不执行 transform/apply、目标启动或回退 |
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 创建能力已进入新的阶段实物,而不再只是候选源码。 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 创建能力已进入新的阶段实物,而不再只是候选源码。
@@ -106,7 +107,7 @@ ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verif
Local artifact 含: Local artifact 含:
- 一个包含所选 Application 与短生命周期 operator 的 archiveheadless 为 `qinglong3-local-trial-kit-<arch>.docker.tar`Console 为 `qinglong3-local-console-trial-kit-<arch>.docker.tar`,共享 Node 基础层在 archive 中去重; - 一个包含所选 Application 与短生命周期 operator 的 archiveheadless 为 `qinglong3-local-trial-kit-<arch>.docker.tar`Console 为 `qinglong3-local-console-trial-kit-<arch>.docker.tar`,共享 Node 基础层在 archive 中去重;
- schema 为 `qinglong/alpha-local-trial-kit@v6``manifest.json`,通过 `variant/archive/images/sboms/quickstart/upgradeReadiness/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256 - schema 为 `qinglong/alpha-local-trial-kit@v7``manifest.json`,通过 `variant/archive/images/sboms/quickstart/upgradeReadiness/upgradeRehearsal/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 `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 - 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,它不替代正式签名; - `verification-evidence.json` 绑定 `workflow_dispatch` 的 workflow ref/SHA、run ID/attempt、同架构两个 exact image ID 和完整 gate 集;下载者仍须到 GitHub 交叉检查 run,它不替代正式签名;
@@ -115,7 +116,7 @@ Local artifact 含:
Cluster artifact 是每角色/架构一个六文件闭包:native Docker archive、精确 CycloneDX SBOM、workflow-bound verification evidence、README、`qinglong/alpha-cluster-image@v1` manifest 和覆盖全部内容文件的 `SHA256SUMS`。完整 CI 成功后,八个 bundle 由 `qinglong/alpha-cluster-milestone@v1` 小型索引闭合;索引本身不重复存放大 archive。 Cluster artifact 是每角色/架构一个六文件闭包:native Docker archive、精确 CycloneDX SBOM、workflow-bound verification evidence、README、`qinglong/alpha-cluster-image@v1` manifest 和覆盖全部内容文件的 `SHA256SUMS`。完整 CI 成功后,八个 bundle 由 `qinglong/alpha-cluster-milestone@v1` 小型索引闭合;索引本身不重复存放大 archive。
Local milestone 是 `qinglong/alpha-local-milestone@v3` 三文件闭包,绑定一个 variant 的双架构 Trial Kit,并直接记录两个架构的 `upgradeReadinessSha256`。Stage index 是 `qinglong/alpha-stage-index@v2` 三文件闭包;它重新审计两个 milestone,要求 version/source/workflow SHA/ref/run/attempt 一致,并把 Local variant/Profile 与 Cluster 的 control/admin/worker 最小集、可选 control-ai 写为机器可读选择;它不重复存放任何镜像 archive。 Local milestone 是 `qinglong/alpha-local-milestone@v4` 三文件闭包,绑定一个 variant 的双架构 Trial Kit,并直接记录两个架构的 `upgradeReadinessSha256``upgradeRehearsalSha256`。Stage index 是 `qinglong/alpha-stage-index@v2` 三文件闭包;它重新审计两个 milestone,要求 version/source/workflow SHA/ref/run/attempt 一致,并把 Local variant/Profile 与 Cluster 的 control/admin/worker 最小集、可选 control-ai 写为机器可读选择;它不重复存放任何镜像 archive。
任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。 任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。
+2 -2
View File
@@ -20,7 +20,7 @@
``` ```
2. 打开 `manifest.json`,确认: 2. 打开 `manifest.json`,确认:
- `schema` 为 `qinglong/alpha-local-milestone@v3` - `schema` 为 `qinglong/alpha-local-milestone@v4`
- `variant` 为 `headless` 或 `console`,且两个架构记录都使用同一变体; - `variant` 为 `headless` 或 `console`,且两个架构记录都使用同一变体;
- `sourceRevision` 是准备试用的完整 40 位提交; - `sourceRevision` 是准备试用的完整 40 位提交;
- `workflow.event` 为 `workflow_dispatch``workflow.job` 为 `local-alpha-milestone` - `workflow.event` 为 `workflow_dispatch``workflow.job` 为 `local-alpha-milestone`
@@ -28,7 +28,7 @@
- `artifacts` 恰好包含 `amd64` 与 `arm64`。 - `artifacts` 恰好包含 `amd64` 与 `arm64`。
3. 根据主机架构下载 `artifacts.<architecture>.artifactName` 指向的 Trial Kit。 3. 根据主机架构下载 `artifacts.<architecture>.artifactName` 指向的 Trial Kit。
4. 对 Trial Kit 先执行其 `SHA256SUMS`,再确认其中 `manifest.json` 的 SHA-256 与 milestone 的 `bundleManifest.sha256` 完全一致。 4. 对 Trial Kit 先执行其 `SHA256SUMS`,再确认其中 `manifest.json` 的 SHA-256 与 milestone 的 `bundleManifest.sha256` 完全一致。
5. 确认 milestone 的 `upgradeReadinessSha256` 与 Trial Kit manifest 中同名入口摘要一致,再按 Trial Kit 自带 `README.md` 完成 fresh smoke只读 2.x 升级就绪盘点。 5. 确认 milestone 的 `upgradeReadinessSha256`、`upgradeRehearsalSha256` 与 Trial Kit manifest 中同名入口摘要一致,再按 Trial Kit 自带 `README.md` 完成 fresh smoke只读 2.x 升级就绪盘点或受审核计划的 side-by-side 暂存
若持有同一版本源码与 Node.js 24,可额外审计 milestone 索引: 若持有同一版本源码与 Node.js 24,可额外审计 milestone 索引:
+23 -2
View File
@@ -25,7 +25,7 @@ sha256sum --check SHA256SUMS
`manifest.json` 必须满足: `manifest.json` 必须满足:
- `schema``qinglong/alpha-local-trial-kit@v6` - `schema``qinglong/alpha-local-trial-kit@v7`
- `variant``headless``console`,并与 milestone、application SBOM 和 artifact 名一致; - `variant``headless``console`,并与 milestone、application SBOM 和 artifact 名一致;
- `sourceRevision` 是你准备试用的完整 40 位 commit; - `sourceRevision` 是你准备试用的完整 40 位 commit;
- `architecture` 与主机相同; - `architecture` 与主机相同;
@@ -114,6 +114,27 @@ artifact job 必须在原生 amd64/arm64 上使用生产形态 2.x fixture 运
`inspected` 后才能记录 `verification-evidence.json.gates.legacyUpgradeReadiness=passed` 并上传。该证明仍不是用户实际磁盘、停机窗口、I/O 峰值或 `inspected` 后才能记录 `verification-evidence.json.gates.legacyUpgradeReadiness=passed` 并上传。该证明仍不是用户实际磁盘、停机窗口、I/O 峰值或
生产数据内容兼容性承诺。 生产数据内容兼容性承诺。
## 受审核计划的 Side-by-side 暂存
审核上一节两个完整结果后,把其中 exact `evidence.planDigest` 作为显式参数交给 v7 bundle 的 canonical `upgrade-rehearsal.sh`
```sh
sh upgrade-rehearsal.sh \
edge \
/opt/qinglong/data \
/opt/qinglong3-alpha-upgrade-stage \
<reviewed-sqlite-plan-digest> \
<reviewed-data-directory-plan-digest>
```
脚本在新的私有 rehearsal root 中完成 SQLite stage/verify/activation 与完整 data-directory stage/verify2.x root 全程只读,Operator 仍为无网络、
128 MiB、0.5 CPU、32 PID 的一次性容器。成功后保存 `stage-summary.json`、全部 command result、SQLite target/recovery/manifest/activation 和完整目录
staging manifest。summary 必须是 `status=verified``legacySource=read_only``cutover=not_authorized`
这不是升级完成:本阶段不执行 transform/apply,不安装 Owner/Secret,不停止 2.x,不启动 3.0,也不授权 cutover 或 Legacy rollback。不要编辑、移动、
复用或当作生产数据根;后续 adopted start 必须精确消费这里的 evidence,并走独立的 D-426b 门。artifact job 必须对将要上传的 exact 脚本使用同一个
生产形态 fixture 实跑,并记录 `verification-evidence.json.gates.legacyUpgradeStage=passed`
## 手工加载与最小 smoke ## 手工加载与最小 smoke
`manifest.json.archive.file` 找到 archive 后加载: `manifest.json.archive.file` 找到 archive 后加载:
@@ -141,7 +162,7 @@ docker run --rm --read-only --network none --cap-drop ALL \
## Fresh 试运行边界 ## 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。v6 artifact job 必须从将要上传的目录实际执行 `quickstart.sh`read-only `upgrade-readiness.sh`,并完成 graceful stop。实际部署时仍必须使用独立目录,并让 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。v7 artifact job 必须从将要上传的目录实际执行 `quickstart.sh`read-only `upgrade-readiness.sh` 和 reviewed-plan `upgrade-rehearsal.sh`,并完成 graceful stop。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。
Edge 的验证上限为 Application 128 MiB、0.5 CPU、64 PIDStandalone 为 256 MiB、0.5 CPU、256 PIDoperator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。 Edge 的验证上限为 Application 128 MiB、0.5 CPU、64 PIDStandalone 为 256 MiB、0.5 CPU、256 PIDoperator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。
+1 -1
View File
@@ -240,7 +240,7 @@ function validateMilestoneRecord(record, product, sourceRevision, variant) {
: 'cluster_integration_candidate_not_public_release'; : 'cluster_integration_candidate_not_public_release';
const expectedSchema = const expectedSchema =
product === 'local' product === 'local'
? 'qinglong/alpha-local-milestone@v3' ? 'qinglong/alpha-local-milestone@v4'
: 'qinglong/alpha-cluster-milestone@v1'; : 'qinglong/alpha-cluster-milestone@v1';
if ( if (
!exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) || !exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) ||
+12 -5
View File
@@ -12,7 +12,7 @@ const {
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..'); const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-local-milestone@v3'; const SCHEMA = 'qinglong/alpha-local-milestone@v4';
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const FILES = Object.freeze({ const FILES = Object.freeze({
readme: 'README.md', readme: 'README.md',
@@ -220,6 +220,7 @@ function bundleRecord(options, architecture) {
operatorImageId: report.operatorImageId, operatorImageId: report.operatorImageId,
verificationSha256: report.verificationSha256, verificationSha256: report.verificationSha256,
upgradeReadinessSha256: report.upgradeReadinessSha256, upgradeReadinessSha256: report.upgradeReadinessSha256,
upgradeRehearsalSha256: report.upgradeRehearsalSha256,
}); });
} }
@@ -234,6 +235,7 @@ function validateArtifactRecord(record, architecture, manifest) {
'operatorImageId', 'operatorImageId',
'verificationSha256', 'verificationSha256',
'upgradeReadinessSha256', 'upgradeReadinessSha256',
'upgradeRehearsalSha256',
]) || ]) ||
record.artifactName !== record.artifactName !==
artifactName(manifest.sourceRevision, architecture, manifest.variant) || artifactName(manifest.sourceRevision, architecture, manifest.variant) ||
@@ -248,6 +250,7 @@ function validateArtifactRecord(record, architecture, manifest) {
!SHA256_PATTERN.test(record.operatorImageId || '') || !SHA256_PATTERN.test(record.operatorImageId || '') ||
!SHA256_PATTERN.test(record.verificationSha256 || '') || !SHA256_PATTERN.test(record.verificationSha256 || '') ||
!SHA256_PATTERN.test(record.upgradeReadinessSha256 || '') || !SHA256_PATTERN.test(record.upgradeReadinessSha256 || '') ||
!SHA256_PATTERN.test(record.upgradeRehearsalSha256 || '') ||
record.applicationImageId === record.operatorImageId record.applicationImageId === record.operatorImageId
) { ) {
fail(`${architecture} milestone artifact record is incompatible`); fail(`${architecture} milestone artifact record is incompatible`);
@@ -316,7 +319,7 @@ function auditLocalAlphaMilestone(options) {
'artifacts', 'artifacts',
'readme', 'readme',
]) || ]) ||
manifest.schemaVersion !== 3 || manifest.schemaVersion !== 4 ||
manifest.schema !== SCHEMA || manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' || manifest.product !== 'local' ||
@@ -361,6 +364,8 @@ function auditLocalAlphaMilestone(options) {
new Set(records.map((record) => record.verificationSha256)).size !== new Set(records.map((record) => record.verificationSha256)).size !==
ARCHITECTURES.length || ARCHITECTURES.length ||
new Set(records.map((record) => record.upgradeReadinessSha256)).size !== new Set(records.map((record) => record.upgradeReadinessSha256)).size !==
ARCHITECTURES.length ||
new Set(records.map((record) => record.upgradeRehearsalSha256)).size !==
ARCHITECTURES.length ARCHITECTURES.length
) { ) {
fail('milestone architecture subjects are not distinct'); fail('milestone architecture subjects are not distinct');
@@ -388,7 +393,7 @@ function auditLocalAlphaMilestone(options) {
} }
return Object.freeze({ return Object.freeze({
schemaVersion: 1, schemaVersion: 1,
schema: 'qinglong/alpha-local-milestone-audit@v3', schema: 'qinglong/alpha-local-milestone-audit@v4',
sourceRevision: manifest.sourceRevision, sourceRevision: manifest.sourceRevision,
version: manifest.version, version: manifest.version,
variant: manifest.variant, variant: manifest.variant,
@@ -427,7 +432,9 @@ function finalizeLocalAlphaMilestone(options) {
artifacts.amd64.archiveSha256 === artifacts.arm64.archiveSha256 || artifacts.amd64.archiveSha256 === artifacts.arm64.archiveSha256 ||
artifacts.amd64.verificationSha256 === artifacts.arm64.verificationSha256 || artifacts.amd64.verificationSha256 === artifacts.arm64.verificationSha256 ||
artifacts.amd64.upgradeReadinessSha256 === artifacts.amd64.upgradeReadinessSha256 ===
artifacts.arm64.upgradeReadinessSha256 artifacts.arm64.upgradeReadinessSha256 ||
artifacts.amd64.upgradeRehearsalSha256 ===
artifacts.arm64.upgradeRehearsalSha256
) { ) {
fail('milestone architecture subjects must be distinct'); fail('milestone architecture subjects must be distinct');
} }
@@ -440,7 +447,7 @@ function finalizeLocalAlphaMilestone(options) {
path.join(normalized.outputRoot, FILES.readme), path.join(normalized.outputRoot, FILES.readme),
); );
const manifest = { const manifest = {
schemaVersion: 3, schemaVersion: 4,
schema: SCHEMA, schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release', maturity: 'alpha_candidate_not_public_release',
product: 'local', product: 'local',
+69 -5
View File
@@ -10,8 +10,8 @@ const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..'); const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-local-trial-kit@v6'; const SCHEMA = 'qinglong/alpha-local-trial-kit@v7';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v4'; const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v5';
const QUICKSTART_TEMPLATE = path.join( const QUICKSTART_TEMPLATE = path.join(
DEFAULT_ROOT, DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-quickstart.sh', 'scripts/templates/ql3-local-alpha-quickstart.sh',
@@ -20,6 +20,10 @@ const UPGRADE_READINESS_TEMPLATE = path.join(
DEFAULT_ROOT, DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-upgrade-readiness.sh', 'scripts/templates/ql3-local-alpha-upgrade-readiness.sh',
); );
const UPGRADE_REHEARSAL_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-upgrade-rehearsal.sh',
);
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const VARIANTS = Object.freeze(['headless', 'console']); const VARIANTS = Object.freeze(['headless', 'console']);
const ARCHIVE_MIN_BYTES = 1024; const ARCHIVE_MIN_BYTES = 1024;
@@ -27,6 +31,7 @@ const MAX_JSON_BYTES = 4 * 1024 * 1024;
const MAX_README_BYTES = 512 * 1024; const MAX_README_BYTES = 512 * 1024;
const MAX_QUICKSTART_BYTES = 256 * 1024; const MAX_QUICKSTART_BYTES = 256 * 1024;
const MAX_UPGRADE_READINESS_BYTES = 256 * 1024; const MAX_UPGRADE_READINESS_BYTES = 256 * 1024;
const MAX_UPGRADE_REHEARSAL_BYTES = 256 * 1024;
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
const REVISION_PATTERN = /^[0-9a-f]{40}$/u; const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
const FILES = Object.freeze({ const FILES = Object.freeze({
@@ -35,6 +40,7 @@ const FILES = Object.freeze({
verificationEvidence: 'verification-evidence.json', verificationEvidence: 'verification-evidence.json',
quickstart: 'quickstart.sh', quickstart: 'quickstart.sh',
upgradeReadiness: 'upgrade-readiness.sh', upgradeReadiness: 'upgrade-readiness.sh',
upgradeRehearsal: 'upgrade-rehearsal.sh',
readme: 'README.md', readme: 'README.md',
manifest: 'manifest.json', manifest: 'manifest.json',
checksums: 'SHA256SUMS', checksums: 'SHA256SUMS',
@@ -51,6 +57,7 @@ const VERIFICATION = Object.freeze({
standaloneFreshLifecycle: 'passed', standaloneFreshLifecycle: 'passed',
localApiCancellation: 'passed', localApiCancellation: 'passed',
legacyUpgradeReadiness: 'passed', legacyUpgradeReadiness: 'passed',
legacyUpgradeStage: 'passed',
}); });
function verificationGates(variant) { function verificationGates(variant) {
@@ -480,6 +487,32 @@ function renderUpgradeReadiness(identity) {
return rendered; return rendered;
} }
function renderUpgradeRehearsal(identity) {
const template = fs.readFileSync(
assertCanonicalFile(
UPGRADE_REHEARSAL_TEMPLATE,
MAX_UPGRADE_REHEARSAL_BYTES,
'upgrade rehearsal template',
),
'utf8',
);
const replacements = Object.freeze({
'@@OPERATOR_IMAGE@@': identity.images.operator.reference,
'@@OPERATOR_ID@@': identity.images.operator.id,
'@@ARCHITECTURE@@': identity.architecture,
'@@SOURCE_REVISION@@': identity.sourceRevision,
'@@ARCHIVE@@': identity.archive.file,
});
let rendered = template;
for (const [token, value] of Object.entries(replacements)) {
rendered = rendered.replaceAll(token, value);
}
if (/@@[A-Z_]+@@/u.test(rendered)) {
fail('upgrade rehearsal template contains an unresolved token');
}
return rendered;
}
function fileRecord(bundleRoot, name) { function fileRecord(bundleRoot, name) {
const filePath = path.join(bundleRoot, name); const filePath = path.join(bundleRoot, name);
const stat = fs.lstatSync(filePath); const stat = fs.lstatSync(filePath);
@@ -660,8 +693,13 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
renderUpgradeReadiness(manifestIdentity), renderUpgradeReadiness(manifestIdentity),
0o700, 0o700,
); );
writeExclusive(
path.join(normalized.outputRoot, FILES.upgradeRehearsal),
renderUpgradeRehearsal(manifestIdentity),
0o700,
);
const manifest = { const manifest = {
schemaVersion: 7, schemaVersion: 8,
schema: SCHEMA, schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release', maturity: 'alpha_candidate_not_public_release',
product: 'local', product: 'local',
@@ -680,6 +718,10 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
normalized.outputRoot, normalized.outputRoot,
FILES.upgradeReadiness, FILES.upgradeReadiness,
), ),
upgradeRehearsal: fileRecord(
normalized.outputRoot,
FILES.upgradeRehearsal,
),
readme: fileRecord(normalized.outputRoot, FILES.readme), readme: fileRecord(normalized.outputRoot, FILES.readme),
verification: fileRecord( verification: fileRecord(
normalized.outputRoot, normalized.outputRoot,
@@ -697,6 +739,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
FILES.verificationEvidence, FILES.verificationEvidence,
FILES.quickstart, FILES.quickstart,
FILES.upgradeReadiness, FILES.upgradeReadiness,
FILES.upgradeRehearsal,
FILES.readme, FILES.readme,
FILES.manifest, FILES.manifest,
]; ];
@@ -767,10 +810,11 @@ function auditLocalAlphaTrialKit(options) {
'sboms', 'sboms',
'quickstart', 'quickstart',
'upgradeReadiness', 'upgradeReadiness',
'upgradeRehearsal',
'readme', 'readme',
'verification', 'verification',
]) || ]) ||
manifest.schemaVersion !== 7 || manifest.schemaVersion !== 8 ||
manifest.schema !== SCHEMA || manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' || manifest.product !== 'local' ||
@@ -834,6 +878,23 @@ function auditLocalAlphaTrialKit(options) {
if (actualUpgradeReadiness !== expectedUpgradeReadiness) { if (actualUpgradeReadiness !== expectedUpgradeReadiness) {
fail('upgrade readiness differs from the canonical inspection journey'); fail('upgrade readiness differs from the canonical inspection journey');
} }
validateFileRecord(
manifest.upgradeRehearsal,
FILES.upgradeRehearsal,
bundleRoot,
);
const expectedUpgradeRehearsal = renderUpgradeRehearsal(manifest);
const actualUpgradeRehearsal = fs.readFileSync(
assertCanonicalFile(
path.join(bundleRoot, FILES.upgradeRehearsal),
MAX_UPGRADE_REHEARSAL_BYTES,
'upgrade rehearsal',
),
'utf8',
);
if (actualUpgradeRehearsal !== expectedUpgradeRehearsal) {
fail('upgrade rehearsal differs from the canonical staging journey');
}
validateFileRecord(manifest.readme, FILES.readme, bundleRoot); validateFileRecord(manifest.readme, FILES.readme, bundleRoot);
validateOfflineSbom( validateOfflineSbom(
readBoundedJson( readBoundedJson(
@@ -871,6 +932,7 @@ function auditLocalAlphaTrialKit(options) {
FILES.verificationEvidence, FILES.verificationEvidence,
FILES.quickstart, FILES.quickstart,
FILES.upgradeReadiness, FILES.upgradeReadiness,
FILES.upgradeRehearsal,
expectedArchive, expectedArchive,
].sort(); ].sort();
const actualFiles = fs const actualFiles = fs
@@ -892,6 +954,7 @@ function auditLocalAlphaTrialKit(options) {
FILES.verificationEvidence, FILES.verificationEvidence,
FILES.quickstart, FILES.quickstart,
FILES.upgradeReadiness, FILES.upgradeReadiness,
FILES.upgradeRehearsal,
FILES.readme, FILES.readme,
FILES.manifest, FILES.manifest,
]; ];
@@ -905,7 +968,7 @@ function auditLocalAlphaTrialKit(options) {
} }
return Object.freeze({ return Object.freeze({
schemaVersion: 1, schemaVersion: 1,
schema: 'qinglong/alpha-local-trial-kit-audit@v3', schema: 'qinglong/alpha-local-trial-kit-audit@v4',
sourceRevision: manifest.sourceRevision, sourceRevision: manifest.sourceRevision,
version: manifest.version, version: manifest.version,
architecture: manifest.architecture, architecture: manifest.architecture,
@@ -915,6 +978,7 @@ function auditLocalAlphaTrialKit(options) {
operatorImageId: manifest.images.operator.id, operatorImageId: manifest.images.operator.id,
quickstartSha256: manifest.quickstart.sha256, quickstartSha256: manifest.quickstart.sha256,
upgradeReadinessSha256: manifest.upgradeReadiness.sha256, upgradeReadinessSha256: manifest.upgradeReadiness.sha256,
upgradeRehearsalSha256: manifest.upgradeRehearsal.sha256,
verificationSha256: manifest.verification.sha256, verificationSha256: manifest.verification.sha256,
workflowRunId: verificationEvidence.workflow.runId, workflowRunId: verificationEvidence.workflow.runId,
workflowRunAttempt: verificationEvidence.workflow.runAttempt, workflowRunAttempt: verificationEvidence.workflow.runAttempt,
@@ -0,0 +1,166 @@
#!/bin/sh
set -eu
OPERATOR_IMAGE='@@OPERATOR_IMAGE@@'
OPERATOR_ID='@@OPERATOR_ID@@'
ARCHITECTURE='@@ARCHITECTURE@@'
SOURCE_REVISION='@@SOURCE_REVISION@@'
ARCHIVE='@@ARCHIVE@@'
fail() {
printf '%s\n' "QingLong Local Alpha upgrade rehearsal failed: $*" >&2
exit 1
}
usage() {
printf '%s\n' \
'usage: sh upgrade-rehearsal.sh edge|standalone /absolute/legacy-data-root /absolute/new/rehearsal-root <reviewed-sqlite-plan-digest> <reviewed-data-directory-plan-digest>' >&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
}
extract_digest() {
result_file=$1
field=$2
digest=$(sed -n "s/^.*\"$field\":\"\([0-9a-f][0-9a-f]*\)\".*$/\1/p" "$result_file")
valid_digest "$digest" || fail "$field is missing or invalid in $result_file"
printf '%s' "$digest"
}
[ "$#" -eq 5 ] || usage
profile=$1
legacy_root=$2
rehearsal_root=$3
sqlite_plan_digest=$4
directory_plan_digest=$5
case "$profile" in
edge|standalone) ;;
*) usage ;;
esac
safe_absolute_path "$legacy_root" || fail 'legacy data root is not a safe canonical absolute path'
safe_absolute_path "$rehearsal_root" || fail 'rehearsal root is not a safe canonical absolute path'
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'
[ "$legacy_root" != "$rehearsal_root" ] || fail 'legacy and rehearsal roots must be distinct'
case "$rehearsal_root/" in
"$legacy_root"/*) fail 'rehearsal root must not be inside the legacy data root' ;;
esac
case "$legacy_root/" in
"$rehearsal_root"/*) fail 'legacy data root must not be inside the rehearsal root' ;;
esac
[ -d "$legacy_root" ] || fail 'legacy data root does not exist'
[ -f "$legacy_root/db/database.sqlite" ] || fail 'legacy db/database.sqlite does not exist'
legacy_real=$(CDPATH= cd -- "$legacy_root" && pwd -P)
[ "$legacy_real" = "$legacy_root" ] || fail 'legacy data root is not canonical'
[ ! -e "$rehearsal_root" ] || fail 'rehearsal root must not already exist'
rehearsal_parent=${rehearsal_root%/*}
[ -n "$rehearsal_parent" ] || rehearsal_parent=/
[ -d "$rehearsal_parent" ] || fail 'rehearsal root parent does not exist'
rehearsal_parent_real=$(CDPATH= cd -- "$rehearsal_parent" && pwd -P)
[ "$rehearsal_parent_real/${rehearsal_root##*/}" = "$rehearsal_root" ] || fail 'rehearsal root parent is not canonical'
command -v docker >/dev/null 2>&1 || fail 'docker is required'
command -v sha256sum >/dev/null 2>&1 || fail 'sha256sum is required'
command -v grep >/dev/null 2>&1 || fail 'grep is required'
command -v sed >/dev/null 2>&1 || fail 'sed is required'
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
(CDPATH= cd -- "$script_dir" && sha256sum --check SHA256SUMS)
docker info >/dev/null 2>&1 || fail 'docker daemon is unavailable'
docker load --input "$script_dir/$ARCHIVE" >/dev/null
operator_identity=$(docker image inspect --format '{{.Id}}|{{.Architecture}}|{{.Config.User}}|{{index .Config.Labels "org.opencontainers.image.revision"}}|{{index .Config.Labels "io.qinglong.lifecycle"}}|{{index .Config.Labels "io.qinglong.network"}}' "$OPERATOR_IMAGE")
[ "$operator_identity" = "$OPERATOR_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION|short-lived|none-by-default" ] || fail 'operator image identity is incompatible'
old_umask=$(umask)
umask 077
mkdir -m 0700 "$rehearsal_root"
for directory in commands results sqlite data-directory; do
mkdir -m 0700 "$rehearsal_root/$directory"
done
cat >"$rehearsal_root/commands/sqlite-stage.json" <<EOF
{"schemaVersion":1,"operation":"local-sqlite.adoption.stage","options":{"deploymentRoot":"/var/lib/qinglong3","profile":"$profile","sourcePath":"/var/lib/qinglong2/db/database.sqlite","targetPath":"/var/lib/qinglong3/sqlite/qinglong3.sqlite","recoveryPath":"/var/lib/qinglong3/sqlite/database.pre-ql3.sqlite","manifestPath":"/var/lib/qinglong3/sqlite/qinglong3-adoption.json","expectedPlanDigest":"$sqlite_plan_digest"}}
EOF
cat >"$rehearsal_root/commands/sqlite-verify.json" <<EOF
{"schemaVersion":1,"operation":"local-sqlite.adoption.verify","options":{"deploymentRoot":"/var/lib/qinglong3","profile":"$profile","targetPath":"/var/lib/qinglong3/sqlite/qinglong3.sqlite","recoveryPath":"/var/lib/qinglong3/sqlite/database.pre-ql3.sqlite","manifestPath":"/var/lib/qinglong3/sqlite/qinglong3-adoption.json"}}
EOF
chmod 0600 "$rehearsal_root/commands/sqlite-stage.json" "$rehearsal_root/commands/sqlite-verify.json"
uid=$(id -u)
gid=$(id -g)
run_adoption() {
command_file=$1
result_file=$2
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=/var/lib/qinglong2,readonly" \
--mount "type=bind,src=$rehearsal_root,dst=/var/lib/qinglong3" \
"$OPERATOR_IMAGE" adoption run \
--command-file "/var/lib/qinglong3/commands/$command_file" \
>"$rehearsal_root/results/$result_file"
}
run_adoption sqlite-stage.json sqlite-stage.result.json
grep -q '"status":"staged"' "$rehearsal_root/results/sqlite-stage.result.json" || fail 'SQLite stage did not report staged'
run_adoption sqlite-verify.json sqlite-verify.result.json
grep -q '"status":"verified"' "$rehearsal_root/results/sqlite-verify.result.json" || fail 'SQLite verify did not report verified'
sqlite_manifest_digest=$(extract_digest "$rehearsal_root/results/sqlite-verify.result.json" manifestDigest)
cat >"$rehearsal_root/commands/sqlite-activation.json" <<EOF
{"schemaVersion":1,"operation":"local-sqlite.activation.prepare","options":{"deploymentRoot":"/var/lib/qinglong3","profile":"$profile","sourcePath":"/var/lib/qinglong2/db/database.sqlite","targetPath":"/var/lib/qinglong3/sqlite/qinglong3.sqlite","recoveryPath":"/var/lib/qinglong3/sqlite/database.pre-ql3.sqlite","manifestPath":"/var/lib/qinglong3/sqlite/qinglong3-adoption.json","activationPath":"/var/lib/qinglong3/sqlite/qinglong3-activation.json","expectedManifestDigest":"$sqlite_manifest_digest"}}
EOF
chmod 0600 "$rehearsal_root/commands/sqlite-activation.json"
run_adoption sqlite-activation.json sqlite-activation.result.json
grep -q '"status":"prepared"' "$rehearsal_root/results/sqlite-activation.result.json" || fail 'SQLite activation did not report prepared'
activation_digest=$(extract_digest "$rehearsal_root/results/sqlite-activation.result.json" activationDigest)
cat >"$rehearsal_root/commands/data-directory-stage.json" <<EOF
{"schemaVersion":1,"operation":"local-data-directory.adoption.stage","options":{"deploymentRoot":"/var/lib/qinglong3","dataRoot":"/var/lib/qinglong2","stagingRoot":"/var/lib/qinglong3/data-directory/staged","profile":"$profile","expectedPlanDigest":"$directory_plan_digest","sqlite":{"sourcePath":"/var/lib/qinglong2/db/database.sqlite","targetPath":"/var/lib/qinglong3/sqlite/qinglong3.sqlite","recoveryPath":"/var/lib/qinglong3/sqlite/database.pre-ql3.sqlite","manifestPath":"/var/lib/qinglong3/sqlite/qinglong3-adoption.json","activationPath":"/var/lib/qinglong3/sqlite/qinglong3-activation.json","expectedActivationDigest":"$activation_digest"}}}
EOF
chmod 0600 "$rehearsal_root/commands/data-directory-stage.json"
run_adoption data-directory-stage.json data-directory-stage.result.json
grep -q '"status":"staged"' "$rehearsal_root/results/data-directory-stage.result.json" || fail 'data-directory stage did not report staged'
directory_manifest_digest=$(extract_digest "$rehearsal_root/results/data-directory-stage.result.json" manifestDigest)
cat >"$rehearsal_root/commands/data-directory-verify.json" <<EOF
{"schemaVersion":1,"operation":"local-data-directory.adoption.verify","options":{"deploymentRoot":"/var/lib/qinglong3","dataRoot":"/var/lib/qinglong2","stagingRoot":"/var/lib/qinglong3/data-directory/staged","profile":"$profile","expectedManifestDigest":"$directory_manifest_digest","sqlite":{"sourcePath":"/var/lib/qinglong2/db/database.sqlite","targetPath":"/var/lib/qinglong3/sqlite/qinglong3.sqlite","recoveryPath":"/var/lib/qinglong3/sqlite/database.pre-ql3.sqlite","manifestPath":"/var/lib/qinglong3/sqlite/qinglong3-adoption.json","activationPath":"/var/lib/qinglong3/sqlite/qinglong3-activation.json","expectedActivationDigest":"$activation_digest"}}}
EOF
chmod 0600 "$rehearsal_root/commands/data-directory-verify.json"
run_adoption data-directory-verify.json data-directory-verify.result.json
grep -q '"status":"verified"' "$rehearsal_root/results/data-directory-verify.result.json" || fail 'data-directory verify did not report verified'
cat >"$rehearsal_root/stage-summary.json" <<EOF
{"schemaVersion":1,"schema":"qinglong/local-alpha-upgrade-stage-summary@v1","status":"verified","profile":"$profile","sourceRevision":"$SOURCE_REVISION","architecture":"$ARCHITECTURE","reviewedPlans":{"sqlite":"$sqlite_plan_digest","dataDirectory":"$directory_plan_digest"},"sqlite":{"manifestDigest":"$sqlite_manifest_digest","activationDigest":"$activation_digest"},"dataDirectory":{"manifestDigest":"$directory_manifest_digest"},"legacySource":"read_only","cutover":"not_authorized"}
EOF
chmod 0600 "$rehearsal_root/stage-summary.json"
umask "$old_umask"
printf '%s\n' \
"QingLong 2.x side-by-side upgrade stage completed ($profile, $ARCHITECTURE)." \
"Stage summary: $rehearsal_root/stage-summary.json" \
"SQLite activation evidence: $rehearsal_root/sqlite/qinglong3-activation.json" \
"Data-directory manifest: $rehearsal_root/data-directory/staged/manifest.json" \
'The legacy root remained read-only. No transform/apply, target start, cutover or Legacy rollback was authorized.' \
'Preserve the complete rehearsal root. Do not edit or reuse it as a production data root.'
+4 -2
View File
@@ -80,8 +80,8 @@ function writeMilestone(directory, manifest) {
function localManifest(attempt = runAttempt, variant = 'headless') { function localManifest(attempt = runAttempt, variant = 'headless') {
return { return {
schemaVersion: 3, schemaVersion: 4,
schema: 'qinglong/alpha-local-milestone@v3', schema: 'qinglong/alpha-local-milestone@v4',
maturity: 'alpha_candidate_not_public_release', maturity: 'alpha_candidate_not_public_release',
product: 'local', product: 'local',
variant, variant,
@@ -102,6 +102,7 @@ function localManifest(attempt = runAttempt, variant = 'headless') {
operatorImageId: digest('4'), operatorImageId: digest('4'),
verificationSha256: digest('5'), verificationSha256: digest('5'),
upgradeReadinessSha256: digest('b'), upgradeReadinessSha256: digest('b'),
upgradeRehearsalSha256: digest('d'),
}, },
arm64: { arm64: {
artifactName: `ql3-alpha-${revision}-local-${variant}-arm64`, artifactName: `ql3-alpha-${revision}-local-${variant}-arm64`,
@@ -116,6 +117,7 @@ function localManifest(attempt = runAttempt, variant = 'headless') {
operatorImageId: digest('9'), operatorImageId: digest('9'),
verificationSha256: digest('a'), verificationSha256: digest('a'),
upgradeReadinessSha256: digest('c'), upgradeReadinessSha256: digest('c'),
upgradeRehearsalSha256: digest('e'),
}, },
}, },
readme: null, readme: null,
+5 -1
View File
@@ -206,11 +206,15 @@ function finalizeOptions(paths) {
test('finalizes two exact native trial kits into one closed milestone index', (t) => { test('finalizes two exact native trial kits into one closed milestone index', (t) => {
const paths = fixture(t); const paths = fixture(t);
const manifest = finalizeLocalAlphaMilestone(finalizeOptions(paths)); const manifest = finalizeLocalAlphaMilestone(finalizeOptions(paths));
assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v3'); assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v4');
assert.match( assert.match(
manifest.artifacts.amd64.upgradeReadinessSha256, manifest.artifacts.amd64.upgradeReadinessSha256,
/^sha256:[0-9a-f]{64}$/, /^sha256:[0-9a-f]{64}$/,
); );
assert.match(
manifest.artifacts.amd64.upgradeRehearsalSha256,
/^sha256:[0-9a-f]{64}$/,
);
assert.equal(manifest.variant, 'headless'); assert.equal(manifest.variant, 'headless');
assert.equal(manifest.sourceRevision, revision); assert.equal(manifest.sourceRevision, revision);
assert.deepEqual(Object.keys(manifest.artifacts), ['amd64', 'arm64']); assert.deepEqual(Object.keys(manifest.artifacts), ['amd64', 'arm64']);
+91 -1
View File
@@ -166,7 +166,7 @@ function adapters(overrides = {}, variant = 'headless') {
test('materializes and offline-audits one closed two-image trial kit', (t) => { test('materializes and offline-audits one closed two-image trial kit', (t) => {
const paths = fixture(t); const paths = fixture(t);
const manifest = createLocalAlphaTrialKit(createOptions(paths), adapters()); const manifest = createLocalAlphaTrialKit(createOptions(paths), adapters());
assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v6'); assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v7');
assert.equal(manifest.variant, 'headless'); assert.equal(manifest.variant, 'headless');
assert.equal(manifest.sourceRevision, revision); assert.equal(manifest.sourceRevision, revision);
assert.equal(manifest.architecture, 'arm64'); assert.equal(manifest.architecture, 'arm64');
@@ -176,6 +176,7 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => {
assert.equal(manifest.verification.file, 'verification-evidence.json'); assert.equal(manifest.verification.file, 'verification-evidence.json');
assert.equal(manifest.quickstart.file, 'quickstart.sh'); assert.equal(manifest.quickstart.file, 'quickstart.sh');
assert.equal(manifest.upgradeReadiness.file, 'upgrade-readiness.sh'); assert.equal(manifest.upgradeReadiness.file, 'upgrade-readiness.sh');
assert.equal(manifest.upgradeRehearsal.file, 'upgrade-rehearsal.sh');
const quickstart = path.join(paths.outputRoot, 'quickstart.sh'); const quickstart = path.join(paths.outputRoot, 'quickstart.sh');
const syntax = spawnSync('sh', ['-n', quickstart], { encoding: 'utf8' }); const syntax = spawnSync('sh', ['-n', quickstart], { encoding: 'utf8' });
assert.equal(syntax.status, 0, syntax.stderr); assert.equal(syntax.status, 0, syntax.stderr);
@@ -199,6 +200,7 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => {
'qinglong3-local-trial-kit-arm64.docker.tar', 'qinglong3-local-trial-kit-arm64.docker.tar',
'quickstart.sh', 'quickstart.sh',
'upgrade-readiness.sh', 'upgrade-readiness.sh',
'upgrade-rehearsal.sh',
'verification-evidence.json', 'verification-evidence.json',
]); ]);
}); });
@@ -232,6 +234,7 @@ test('materializes a distinct loopback Console trial kit without widening the he
assert.equal(verification.gates.firstAutomationJourney, 'passed'); assert.equal(verification.gates.firstAutomationJourney, 'passed');
assert.equal(verification.gates.ownerCredentialPresentation, 'passed'); assert.equal(verification.gates.ownerCredentialPresentation, 'passed');
assert.equal(verification.gates.legacyUpgradeReadiness, 'passed'); assert.equal(verification.gates.legacyUpgradeReadiness, 'passed');
assert.equal(verification.gates.legacyUpgradeStage, 'passed');
const quickstartContents = fs.readFileSync( const quickstartContents = fs.readFileSync(
path.join(paths.outputRoot, 'quickstart.sh'), path.join(paths.outputRoot, 'quickstart.sh'),
'utf8', 'utf8',
@@ -255,6 +258,7 @@ test('materializes a distinct loopback Console trial kit without widening the he
'qinglong3-local-operator.cdx.json', 'qinglong3-local-operator.cdx.json',
'quickstart.sh', 'quickstart.sh',
'upgrade-readiness.sh', 'upgrade-readiness.sh',
'upgrade-rehearsal.sh',
'verification-evidence.json', 'verification-evidence.json',
]); ]);
}); });
@@ -288,6 +292,7 @@ test('offline audit rejects archive, file-set, SBOM and verification mutation',
'extra', 'extra',
'quickstart', 'quickstart',
'upgrade-readiness', 'upgrade-readiness',
'upgrade-rehearsal',
'sbom', 'sbom',
'verification', 'verification',
]) { ]) {
@@ -314,6 +319,11 @@ test('offline audit rejects archive, file-set, SBOM and verification mutation',
path.join(paths.outputRoot, 'upgrade-readiness.sh'), path.join(paths.outputRoot, 'upgrade-readiness.sh'),
'# drift\n', '# drift\n',
); );
} else if (mutation === 'upgrade-rehearsal') {
fs.appendFileSync(
path.join(paths.outputRoot, 'upgrade-rehearsal.sh'),
'# drift\n',
);
} else if (mutation === 'sbom') { } else if (mutation === 'sbom') {
fs.copyFileSync( fs.copyFileSync(
path.join(paths.outputRoot, 'qinglong3-local-application.cdx.json'), path.join(paths.outputRoot, 'qinglong3-local-application.cdx.json'),
@@ -353,6 +363,7 @@ test('offline audit rejects a rehashed non-canonical quickstart', (t) => {
'verification-evidence.json', 'verification-evidence.json',
'quickstart.sh', 'quickstart.sh',
'upgrade-readiness.sh', 'upgrade-readiness.sh',
'upgrade-rehearsal.sh',
'README.md', 'README.md',
'manifest.json', 'manifest.json',
]; ];
@@ -534,6 +545,85 @@ exit 1
assert.doesNotMatch(calls, /adoption\.stage|activation\.prepare|cutover/); assert.doesNotMatch(calls, /adoption\.stage|activation\.prepare|cutover/);
}); });
test('generated upgrade rehearsal stages and verifies reviewed legacy plans without cutover', (t) => {
const paths = fixture(t);
createLocalAlphaTrialKit(createOptions(paths), adapters());
const fakeBin = path.join(paths.fixtureRoot, 'rehearsal-fake-bin');
fs.mkdirSync(fakeBin);
const dockerLog = path.join(paths.fixtureRoot, 'rehearsal-docker.log');
const fakeDocker = path.join(fakeBin, 'docker');
fs.writeFileSync(
fakeDocker,
`#!/bin/sh
printf '%s\n' "$*" >>"$FAKE_DOCKER_LOG"
case "$1:$2" in
info:|load:*) exit 0 ;;
image:inspect)
printf '%s\n' 'sha256:${'2'.repeat(
64,
)}|arm64|65532:65532|${revision}|short-lived|none-by-default'
exit 0
;;
esac
case " $* " in
*'/sqlite-stage.json'*) printf '%s\n' '{"status":"staged"}'; exit 0 ;;
*'/sqlite-verify.json'*) printf '%s\n' '{"status":"verified","evidence":{"manifestDigest":"${'c'.repeat(
64,
)}"}}'; exit 0 ;;
*'/sqlite-activation.json'*) printf '%s\n' '{"status":"prepared","evidence":{"activationDigest":"${'d'.repeat(
64,
)}"}}'; exit 0 ;;
*'/data-directory-stage.json'*) printf '%s\n' '{"status":"staged","evidence":{"manifestDigest":"${'e'.repeat(
64,
)}"}}'; exit 0 ;;
*'/data-directory-verify.json'*) printf '%s\n' '{"status":"verified"}'; exit 0 ;;
esac
exit 1
`,
{ mode: 0o755 },
);
const legacyRoot = path.join(paths.fixtureRoot, 'rehearsal-legacy-data');
fs.mkdirSync(path.join(legacyRoot, 'db'), { recursive: true, mode: 0o700 });
const legacyDatabase = path.join(legacyRoot, 'db', 'database.sqlite');
fs.writeFileSync(legacyDatabase, 'legacy-unchanged', { mode: 0o600 });
const rehearsalRoot = path.join(paths.fixtureRoot, 'upgrade-rehearsal');
const run = spawnSync(
'sh',
[
path.join(paths.outputRoot, 'upgrade-rehearsal.sh'),
'edge',
legacyRoot,
rehearsalRoot,
'a'.repeat(64),
'b'.repeat(64),
],
{
encoding: 'utf8',
env: {
...process.env,
FAKE_DOCKER_LOG: dockerLog,
PATH: `${fakeBin}:${process.env.PATH}`,
},
},
);
assert.equal(run.status, 0, `${run.stderr}\n${run.stdout}`);
assert.match(run.stdout, /side-by-side upgrade stage completed/);
assert.equal(fs.readFileSync(legacyDatabase, 'utf8'), 'legacy-unchanged');
const summary = JSON.parse(
fs.readFileSync(path.join(rehearsalRoot, 'stage-summary.json'), 'utf8'),
);
assert.equal(summary.status, 'verified');
assert.equal(summary.legacySource, 'read_only');
assert.equal(summary.cutover, 'not_authorized');
assert.equal(summary.sqlite.activationDigest, 'd'.repeat(64));
assert.equal(summary.dataDirectory.manifestDigest, 'e'.repeat(64));
const calls = fs.readFileSync(dockerLog, 'utf8');
assert.match(calls, /dst=\/var\/lib\/qinglong2,readonly/);
assert.match(calls, /--network none/);
assert.match(calls, /--memory 128m --memory-swap 128m/);
assert.doesNotMatch(calls, /cutover|target-start|legacy-rollback/);
});
test('create rejects an image reference that could alter the shell journey', (t) => { test('create rejects an image reference that could alter the shell journey', (t) => {
const paths = fixture(t); const paths = fixture(t);
const options = createOptions(paths); const options = createOptions(paths);