feat(ql3): add downloadable upgrade readiness

This commit is contained in:
whyour
2026-08-30 14:00:26 +08:00
parent 6e5bfccdfc
commit d6571e4b89
16 changed files with 652 additions and 28 deletions
+12
View File
@@ -763,6 +763,18 @@ jobs:
docker rm "${QUICKSTART_CONTAINER}"
test -s "${QUICKSTART_ROOT}/qinglong3.sqlite"
test -s "${QUICKSTART_ROOT}/owner-delivery/credential-019f8680-143d-4000-8000-000000000021.ready.json"
LEGACY_READINESS_ROOT="${RUNNER_TEMP}/ql3-alpha-legacy-readiness-${{ matrix.image_arch }}"
UPGRADE_EVIDENCE_ROOT="${RUNNER_TEMP}/ql3-alpha-upgrade-evidence-${{ matrix.image_arch }}"
node scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs \
--output="${LEGACY_READINESS_ROOT}"
sh "${BUNDLE_ROOT}/upgrade-readiness.sh" \
edge "${LEGACY_READINESS_ROOT}" "${UPGRADE_EVIDENCE_ROOT}"
grep -q '"status":"inspected"' \
"${UPGRADE_EVIDENCE_ROOT}/results/sqlite-inspect.result.json"
grep -q '"status":"inspected"' \
"${UPGRADE_EVIDENCE_ROOT}/results/data-directory-inspect.result.json"
test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-wal"
test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-journal"
trap - EXIT
- name: Upload the tested native Local Alpha trial kit
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all')
+2
View File
@@ -1,5 +1,7 @@
# QingLong 3.0 Architecture RFC
- D-425/ADR-0520(已实现源码候选,双架构 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`。该切片只是完整 side-by-side 升级与 clean/write-after 回退 rehearsal 的第一阶段,在普通 CI、显式 artifact run 与下载审计完成前不能宣传为已交付实物。
- 全范围 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。
- 低配 Local headless v5 阶段交付已补齐:绑定提交 `d459c3b45c36e856f4a1cb3ce5147905977d939d` 的显式 Local headless milestone run `33258604609` 为 42 success/2 scope skip/0 fail,生成 187,535,348-byte amd64、184,766,964-byte arm64 Trial Kit 与 5,626-byte milestone,保留至 2026-09-28。三个 artifact 的 GitHub ZIP SHA-256 分别为 `4d45e3f0e90159cc683a319a53ffbffc0c3ad95343e37a6f50aa8dca571d8204``21b6c9379531e45ec029ef64ee5ba9d51cc04ffffb50c5e29b90b175209a8c54``ece393032ab97776e9fe961f8e6a36fec2c4d1b6f20b9bf765816a9e3d6659c3`;内部 Docker archive SHA-256 为 `071100133783638cdeb04807ca313a968ca254366745ff9f624de6b62a2da8cc``29b411e31ec31b5ffe38a62cd5248725a03b1c35e1b92c5cbb155745680ede46`。下载后三件套 checksum、两个 Trial Kit auditor 与 milestone auditor 均返回 `compatible=true`,确认 `headless`、source、run/attempt、双架构 manifest/image identity 闭合。该变体不含 listener、Console、示例 Task 或 Console 管理依赖,面向路由/NAS 的默认最小 fresh Alpha;它与 Console 是独立 archive,仍不是公开 release、2.x 生产升级、物理低配容量承诺或长期支持版本。
@@ -0,0 +1,85 @@
# ADR-0520:可下载的 Local Legacy 升级就绪盘点
- 状态:AcceptedD-425 源码候选,双架构 Alpha 实物待生成)
- 日期:2026-08-30
- 对应 RFC 切片:D-425
- 关联 ADRADR-0476ADR-0483、ADR-0503、ADR-0506、ADR-0511、ADR-0514
## 背景
QingLong 3.0 已经形成双架构 Local Trial Kit、Cluster bundle 和跨 Profile stage index,但当前可下载 Local quickstart 只支持 fresh 数据根。
ADR-0476ADR-0483 已在产品 CLI 中完成真实形态 2.x SQLite、完整 data directory、私有暂存、转换、原子应用、部署 lineage、停止态
reconciliation bundle 和有界领域计划;部署用户仍无法从 Trial Kit 直接判断自己的 2.x 数据是否进入支持边界。
不能用“一键升级”掩盖这个缺口。SQLite stage 需要人工审核 `planDigest`,完整 data directory stage 又需要独立目录计划和 activation 双围栏;
在未展示计划前自动复制、迁移或 cutover 会越过现有安全协议。另一方面,仅把长篇运维文档放入源码仓库也不是阶段产物:路由器、NAS 用户需要
从已下载并审计的 exact Operator image 发起只读盘点,且不要求宿主安装 Node.js、jq 或 3.0 workspace。
## 决策
### 1. Trial Kit 增加 canonical `upgrade-readiness.sh`
Local Trial Kit 在既有 Docker archive、quickstart、SBOM 和 verification evidence 之外增加一个受 manifest、SHA256SUMS 和离线 auditor
共同绑定的 POSIX shell 入口:
```sh
sh upgrade-readiness.sh \
edge \
/opt/qinglong/data \
/opt/qinglong3-alpha-upgrade-readiness
```
入口只接受 `edge|standalone`、一个现存 canonical 2.x data root 和一个尚不存在的私有 evidence root。路径字符集有界,legacy 与 evidence
root 必须不同;固定生产布局要求主库位于 `db/database.sqlite`。输出目录以当前 UID、`0700` 创建,command/result 为 `0600`
### 2. 只运行两个正式产品 inspect
脚本通过 Trial Kit 中 exact、source-bound 的 Local Operator image 执行:
1. `local-sqlite.adoption.inspect`
2. `local-data-directory.adoption.inspect`
legacy root 以 Docker read-only bind mount 提供,Operator 使用当前宿主 UID:GID、只读 rootfs、`network=none`、drop-all capabilities、
`no-new-privileges`、128 MiB memory/swap、0.5 CPU、32 PID 和 8 MiB noexec tmpfs。结果只写入独立 evidence root。脚本不解析、改写或代替
operator 审核两个完整结果,也不运行 stage、activation、application、cutover、target stop 或 Legacy rollback。
成功只表示两个 plan 已生成;它不表示 `assessment=reviewable`、不授权复制或升级,也不能从 plan digest 反推内容。操作者必须停止活跃 writer、
审核 SQLite catalog/task inventory 与 data-directory disposition/预算/sidecar,再决定是否进入后续 rehearsal。
### 3. Artifact gate 必须运行将要上传的 exact 脚本
`ql3-ci.yml` 的显式 Local artifact job 在每个原生 amd64/arm64 runner 上:
- 创建包含 2.x `Crontabs``Dependences``Apps``Auths``Envs``Subscriptions``CrontabViews``CrontabStats`
`RunningInstances``PluginOwnedState` 和常见目录的私有生产形态 fixture;
- 运行 bundle 目录中将要上传的 `upgrade-readiness.sh`,而不是源码模板;
- 要求两个结果均为 `status=inspected`,并确认 source 未出现 SQLite WAL/journal
- 只有整个 job 成功才上传 bundle。
verification evidence 升级为 `qinglong/alpha-local-trial-kit-verification@v4` 并增加 `legacyUpgradeReadiness=passed`。Trial Kit 升级为
`qinglong/alpha-local-trial-kit@v6`manifest schemaVersion 7),auditor 升级为 `qinglong/alpha-local-trial-kit-audit@v3`;旧 v5 bundle 不会被
改名冒充 v6。
### 4. 不增加 package 或常驻能力
该切片只增加 artifact template、CI fixture 和文档,不增加 workspace package、第三方依赖、数据库 migration、listener、daemon、timer、
watcher、queue 或 Cluster authority。Operator 继续复用既有 Local adoption product CLICluster 节点继续使用 PostgreSQL 专用升级、备份和
deployment-lock 协议。
## 不采用的方案
1. **在 readiness 脚本中自动 stage/activate**:绕过两个 plan digest 的人工审核和停写围栏,拒绝。
2. **把宿主 Node.js/jq 作为前置**:低配部署未必安装,且会扩大不可审计执行闭包,拒绝。
3. **把 2.x data root 以读写方式挂载以“顺便修复”权限或 sidecar**:readiness 只能报告,不能修改证据现场,拒绝。
4. **新增独立 migration package/image**:既有短生命周期 Operator 已拥有精确 adoption authority,新增交付单元只会重复依赖和扩大碎片化,拒绝。
5. **把 inspect 成功宣传为升级兼容**unknown asset、预算、sidecar、manual task/config/SSH 仍需审核,拒绝。
## 验收与后续
源码候选必须通过 bundle/fixture/milestone 聚焦测试、完整 backend、18-package clean build/test、package/Cluster/Edge boundary、双架构普通 CI 与
显式 Local artifact run。只有 v6 amd64/arm64 bundle 被同 run Local milestone 收录并通过下载后 checksum/auditorD-425 才能从源码候选升级为
阶段实物。
D-425 是完整升级 rehearsal 的第一阶段,不重新定义最终目标。下一切片继续以两个已审核 plan digest 为显式输入,建立 side-by-side
SQLite/data-directory stage、verify、activation、adopted start、clean `rollback_candidate` 与写后 `reconciliation_required` 证据;在该门完成前,
Trial Kit 仍不得用于生产 cutover。
+1
View File
@@ -523,6 +523,7 @@
| [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-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 实物待生成) |
## 规则
+7 -3
View File
@@ -30,6 +30,7 @@
| D-422 Console Task 安全编辑切片 | 强认证完整定义读取、10 分钟一次性 authoring lease、第二份 exact save proof 与 revision/content/credential fence 已完成;Console 可无损编辑内建 argv command Task;同源双架构 Console v5 Trial Kit 与 milestone 已生成并验真 | Cluster 不复用 Local proof;尚无通用 workflow 编辑器、2.x 升级或生产远程管理;仍不是正式发布 |
| 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-425 2.x 升级就绪盘点 | v6 Trial Kit 源码候选新增 canonical `upgrade-readiness.sh`2.x root 只读挂载,正式 Operator 在 128 MiB/无网络边界内生成 SQLite 与完整目录两个计划;bundle/milestone/stage 聚焦测试通过 | 双架构 artifact 尚未生成;只完成 inspect,不授权 stage、activation、cutover 或 rollback |
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 创建能力已进入新的阶段实物,而不再只是候选源码。
@@ -39,6 +40,8 @@ D-423 已从“源码候选”升级为阶段实物:18-package clean build/tes
D-424 已从“源码候选”升级为阶段实物:本地 18-package clean build/test 为 `3,052 total / 3,030 pass / 22 conditional skip / 0 fail`Local SQLite `250/250`、Local API `76/76`,默认 Edge 与 opt-in Console 资源门、Edge benchmark、package/source boundary 和 Cluster dependency audit 均通过。它证明 Secret metadata 不泄漏 custody 字段、create/rotate 必须 exact local presence、SQLite 只保存密文、Task 只保存 pinned `SecretRef`。提交 `f46fb44ac9534315b6965865bb3e990715bb2417` 的普通主 CI [run 33250825989](https://github.com/whyour/qinglong/actions/runs/33250825989) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33250826046](https://github.com/whyour/qinglong/actions/runs/33250826046) 与三节点 Security Administration [run 33250825974](https://github.com/whyour/qinglong/actions/runs/33250825974) 成功;显式 Local Console milestone [run 33252179178](https://github.com/whyour/qinglong/actions/runs/33252179178) 为 42 success/2 scope skip/0 fail。首次 artifact run [33251389615](https://github.com/whyour/qinglong/actions/runs/33251389615) 触发 x64 router 绝对 RSS 门;同源码 ordinary run 的 peak process RSS 为 98,852,864 bytes,距 96 MiB 上限仅约 1.73 MiB,表现与 runner 基线波动一致。没有提高预算、改代码或复用 failed attempt,第二次全新 run 对同一 source 通过该门并闭合产物。
D-425 当前是严格区分的源码候选:Trial Kit、Local milestone 与 stage index 聚焦回归 `30/30`,其中将要上传的 bundle 文件集新增由 manifest、checksum 和 auditor 共同绑定的 `upgrade-readiness.sh`。脚本只接受独立 evidence root,把实际 2.x data root 只读挂载给 exact Operator,运行 `local-sqlite.adoption.inspect``local-data-directory.adoption.inspect`,不运行任何写入或切换。`ql3-ci.yml` 已要求显式 artifact job 在每个原生架构上创建生产形态 fixture 并实跑 bundle 内 exact 脚本;在普通 CI、双架构 artifact run 和下载审计完成前,不能把 v6 源码候选宣传为已交付实物。
默认低配 headless v5 也已从“可生成”升级为独立阶段实物。绑定提交 `d459c3b45c36e856f4a1cb3ce5147905977d939d` 的显式 Local headless milestone [run 33258604609](https://github.com/whyour/qinglong/actions/runs/33258604609) 为 42 success/2 scope skip/0 fail,完整矩阵继续覆盖双架构资源、Local/Cluster image、PostgreSQL HA、CloudNativePG、Secret/provider rotation 与 Local Profiles。该 run 没有复用 Console archive;下载后的两个 `headless` Trial Kit 与 milestone 均通过 `SHA256SUMS` 和仓库 auditor,返回 `compatible=true`
首份跨部署全范围阶段实物绑定提交 `97333da34cce48cdfcfa1bbd5e8d48340802d2ef` 与 [run 33265538836](https://github.com/whyour/qinglong/actions/runs/33265538836),为 `44 success / 0 skip / 0 fail`。它生成 headless Local 双架构、Cluster control/control-ai/admin/worker 双架构、两个 milestone 与 `ql3-alpha-97333da34cce48cdfcfa1bbd5e8d48340802d2ef-stage-index`;三个小索引的 GitHub ZIP digest 分别为 Local `2e3bb8baeeadb40f34c130db68db8b1a7d6cf7a7c92a73a805e84990bf9875dc`、Cluster `292380a72f8b45233f6591624f6073154c2b7d2d00f62908af687193078524e2`、stage `2fbc67d478593df8bbb2ba362beb9f676be1882ac2e5386106057789906adece`,保留至 2026-09-28。下载后三个 `SHA256SUMS` 与仓库 auditor 全部 `compatible=true`stage auditor 确认 `3.0.0-alpha.2`、同一 source/run/attempt、三种 Profile 和 10 个可选择 artifact。
@@ -86,7 +89,7 @@ D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完
该本地 archive 不是新的 v2 Local Alpha Trial Kit。它在 ADR-0506 前生成,manifest v1 会无条件写入 `passed`,且 macOS Docker Desktop 因 bind-mount UID 映射无法对 exact 本地 archive 完成 Owner pepper 旅程;原生 CI 证明同源码实现,不自动证明另一个 archive 的 exact image bytes。它因此保留为工程候选,不冒充已获 workflow evidence 的用户 Alpha。
ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verificationADR-0511 的 `@v3` 增加 canonical quickstartADR-0513 的 `@v4` 再把 `headless|console` 变体绑定到 image、SBOM、verification、milestone 和 stage indexADR-0514 的 `@v5` 增加标准 Owner credential presentation 与首自动化旅程。旧 runtime-only、v1/v2/v3/v4 bundle 均为历史工程证据,不能通过 v5 auditor。当前 headlessConsole v5、Cluster 双架构 milestone 与跨 Profile stage index 均已有真实实物;下一阶段不再补“有没有产物”,而是补长期分发、签名、不可变 registry digest、部署锁和升级/回退证据。Public Release Set 继续受更严格发布门禁约束。
ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verificationADR-0511 的 `@v3` 增加 canonical quickstartADR-0513 的 `@v4` 再把 `headless|console` 变体绑定到 image、SBOM、verification、milestone 和 stage indexADR-0514 的 `@v5` 增加标准 Owner credential presentation 与首自动化旅程ADR-0520 的 `@v6` 增加只读 2.x 升级就绪盘点。旧 runtime-only、v1/v2/v3/v4/v5 bundle 均为历史工程证据,不能通过 v6 auditor。当前真实可下载实物仍是 headless/Console v5、Cluster 双架构 milestone 与跨 Profile stage index;v6 必须经过新的普通 CI、显式 artifact run 和下载后审计才可替代它们。Public Release Set 继续受更严格发布门禁约束。
## 生成
@@ -103,15 +106,16 @@ ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verif
Local artifact 含:
- 一个包含所选 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@v5``manifest.json`,通过 `variant/archive/images/sboms/quickstart/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256
- 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
- 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,它不替代正式签名;
- 与实际只读镜像 inventory 对账过的 CycloneDX SBOM
- 面向 Local 用户的 README 与覆盖全部内容文件的 `SHA256SUMS`
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@v2` 三文件闭包,绑定一个 variant 的双架构 Trial Kit。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@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。
任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。
+2 -2
View File
@@ -20,7 +20,7 @@
```
2. 打开 `manifest.json`,确认:
- `schema``qinglong/alpha-local-milestone@v2`
- `schema``qinglong/alpha-local-milestone@v3`
- `variant``headless``console`,且两个架构记录都使用同一变体;
- `sourceRevision` 是准备试用的完整 40 位提交;
- `workflow.event``workflow_dispatch``workflow.job``local-alpha-milestone`
@@ -28,7 +28,7 @@
- `artifacts` 恰好包含 `amd64``arm64`
3. 根据主机架构下载 `artifacts.<architecture>.artifactName` 指向的 Trial Kit。
4. 对 Trial Kit 先执行其 `SHA256SUMS`,再确认其中 `manifest.json` 的 SHA-256 与 milestone 的 `bundleManifest.sha256` 完全一致。
5. 按 Trial Kit 自带 `README.md` 完成 Docker archive 加载、镜像 ID 对账和受限资源 smoke
5. 确认 milestone 的 `upgradeReadinessSha256` 与 Trial Kit manifest 中同名入口摘要一致,再按 Trial Kit 自带 `README.md` 完成 fresh smoke 或只读 2.x 升级就绪盘点
若持有同一版本源码与 Node.js 24,可额外审计 milestone 索引:
+33 -3
View File
@@ -25,7 +25,7 @@ sha256sum --check SHA256SUMS
`manifest.json` 必须满足:
- `schema``qinglong/alpha-local-trial-kit@v5`
- `schema``qinglong/alpha-local-trial-kit@v6`
- `variant``headless``console`,并与 milestone、application SBOM 和 artifact 名一致;
- `sourceRevision` 是你准备试用的完整 40 位 commit;
- `architecture` 与主机相同;
@@ -45,7 +45,7 @@ node scripts/ql3-local-alpha-trial-kit-bundle.cjs \
## 一条命令完成 Fresh 试运行
v5 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX
v6 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX
shell、`sha256sum` 和已启动的 Docker。必须选择一个尚不存在、与 2.x/生产数据完全
隔离的绝对路径:
@@ -84,6 +84,36 @@ SecretRef 或 Trigger。D-420 后选择该 Run 还会经既有 `artifact.read`
Attempt 首个 32 KiB 日志;后续内容继续使用 API 分页,页面不会自动轮询或下载整份日志。
headless 不创建示例 Task,因此低配默认档没有示例数据或稳态开销。
## 只读检查现有 2.x 升级就绪度
v6 bundle 还包含 canonical `upgrade-readiness.sh`。它让现有部署用户先回答“这份 2.x SQLite 和完整 data directory 是否能形成可审核
计划”,不会把 inspect 成功冒充自动升级。建议停止 2.x、同步器和下载器,确认主库位于 `db/database.sqlite`,再选择一个尚不存在且不在
2.x data root 内的 evidence 路径:
```sh
sh upgrade-readiness.sh \
edge \
/opt/qinglong/data \
/opt/qinglong3-alpha-upgrade-readiness
```
NAS/较大单机使用 `standalone`。脚本继续只依赖 POSIX shell、`sha256sum` 和 Docker;它先验证全包 checksum、exact Operator image ID、源码和
架构,再把 2.x root 以 read-only bind mount 提供给当前 UID:GID 的短生命周期 Operator。Operator 固定无网络、只读 rootfs、128 MiB、
0.5 CPU 和 32 PID,分别执行正式的 `local-sqlite.adoption.inspect``local-data-directory.adoption.inspect`
两个完整结果保存在 evidence root 的:
- `results/sqlite-inspect.result.json`
- `results/data-directory-inspect.result.json`
不要只保存终端输出或手工抄写 digest。审核 SQLite catalog/task inventory、完整目录的 `assessment`、disposition、预算、unsafe/unknown 条目、主库
计数和 active sidecar,并保存两个 exact `planDigest`。脚本不会运行 stage、activation、transform/apply、cutover、target stop 或 Legacy
rollback,也不会修改 source、修权限或删除 sidecar。readiness 成功不授权下一阶段;需要继续演练时按仓库运维协议显式提交审核后的 digest。
artifact job 必须在原生 amd64/arm64 上使用生产形态 2.x fixture 运行将要上传的 exact `upgrade-readiness.sh`,两个正式 Operator 结果都为
`inspected` 后才能记录 `verification-evidence.json.gates.legacyUpgradeReadiness=passed` 并上传。该证明仍不是用户实际磁盘、停机窗口、I/O 峰值或
生产数据内容兼容性承诺。
## 手工加载与最小 smoke
`manifest.json.archive.file` 找到 archive 后加载:
@@ -111,7 +141,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。v5 artifact job 必须从将要上传的目录实际执行 `quickstart.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。v6 artifact job 必须从将要上传的目录实际执行 `quickstart.sh` 和 read-only `upgrade-readiness.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 的容量承诺。
+2 -5
View File
@@ -240,7 +240,7 @@ function validateMilestoneRecord(record, product, sourceRevision, variant) {
: 'cluster_integration_candidate_not_public_release';
const expectedSchema =
product === 'local'
? 'qinglong/alpha-local-milestone@v2'
? 'qinglong/alpha-local-milestone@v3'
: 'qinglong/alpha-cluster-milestone@v1';
if (
!exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) ||
@@ -382,10 +382,7 @@ function auditAlphaStageIndex(options) {
sourceRevision: manifest.sourceRevision,
workflowRunId: manifest.workflow.runId,
workflowRunAttempt: manifest.workflow.runAttempt,
profiles: [
...manifest.deploymentSelections.local.profiles,
'cluster',
],
profiles: [...manifest.deploymentSelections.local.profiles, 'cluster'],
artifactCount: 10,
compatible: true,
});
+12 -5
View File
@@ -12,7 +12,7 @@ const {
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-local-milestone@v2';
const SCHEMA = 'qinglong/alpha-local-milestone@v3';
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const FILES = Object.freeze({
readme: 'README.md',
@@ -219,6 +219,7 @@ function bundleRecord(options, architecture) {
applicationImageId: report.applicationImageId,
operatorImageId: report.operatorImageId,
verificationSha256: report.verificationSha256,
upgradeReadinessSha256: report.upgradeReadinessSha256,
});
}
@@ -232,6 +233,7 @@ function validateArtifactRecord(record, architecture, manifest) {
'applicationImageId',
'operatorImageId',
'verificationSha256',
'upgradeReadinessSha256',
]) ||
record.artifactName !==
artifactName(manifest.sourceRevision, architecture, manifest.variant) ||
@@ -245,6 +247,7 @@ function validateArtifactRecord(record, architecture, manifest) {
!SHA256_PATTERN.test(record.applicationImageId || '') ||
!SHA256_PATTERN.test(record.operatorImageId || '') ||
!SHA256_PATTERN.test(record.verificationSha256 || '') ||
!SHA256_PATTERN.test(record.upgradeReadinessSha256 || '') ||
record.applicationImageId === record.operatorImageId
) {
fail(`${architecture} milestone artifact record is incompatible`);
@@ -313,7 +316,7 @@ function auditLocalAlphaMilestone(options) {
'artifacts',
'readme',
]) ||
manifest.schemaVersion !== 2 ||
manifest.schemaVersion !== 3 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' ||
@@ -356,6 +359,8 @@ function auditLocalAlphaMilestone(options) {
new Set(records.map((record) => record.archiveSha256)).size !==
ARCHITECTURES.length ||
new Set(records.map((record) => record.verificationSha256)).size !==
ARCHITECTURES.length ||
new Set(records.map((record) => record.upgradeReadinessSha256)).size !==
ARCHITECTURES.length
) {
fail('milestone architecture subjects are not distinct');
@@ -383,7 +388,7 @@ function auditLocalAlphaMilestone(options) {
}
return Object.freeze({
schemaVersion: 1,
schema: 'qinglong/alpha-local-milestone-audit@v2',
schema: 'qinglong/alpha-local-milestone-audit@v3',
sourceRevision: manifest.sourceRevision,
version: manifest.version,
variant: manifest.variant,
@@ -420,7 +425,9 @@ function finalizeLocalAlphaMilestone(options) {
if (
new Set(allImageIds).size !== allImageIds.length ||
artifacts.amd64.archiveSha256 === artifacts.arm64.archiveSha256 ||
artifacts.amd64.verificationSha256 === artifacts.arm64.verificationSha256
artifacts.amd64.verificationSha256 === artifacts.arm64.verificationSha256 ||
artifacts.amd64.upgradeReadinessSha256 ===
artifacts.arm64.upgradeReadinessSha256
) {
fail('milestone architecture subjects must be distinct');
}
@@ -433,7 +440,7 @@ function finalizeLocalAlphaMilestone(options) {
path.join(normalized.outputRoot, FILES.readme),
);
const manifest = {
schemaVersion: 2,
schemaVersion: 3,
schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release',
product: 'local',
+69 -5
View File
@@ -10,18 +10,23 @@ 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@v5';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v3';
const SCHEMA = 'qinglong/alpha-local-trial-kit@v6';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v4';
const QUICKSTART_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-quickstart.sh',
);
const UPGRADE_READINESS_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-upgrade-readiness.sh',
);
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const VARIANTS = Object.freeze(['headless', 'console']);
const ARCHIVE_MIN_BYTES = 1024;
const MAX_JSON_BYTES = 4 * 1024 * 1024;
const MAX_README_BYTES = 512 * 1024;
const MAX_QUICKSTART_BYTES = 256 * 1024;
const MAX_UPGRADE_READINESS_BYTES = 256 * 1024;
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
const FILES = Object.freeze({
@@ -29,6 +34,7 @@ const FILES = Object.freeze({
operatorSbom: 'qinglong3-local-operator.cdx.json',
verificationEvidence: 'verification-evidence.json',
quickstart: 'quickstart.sh',
upgradeReadiness: 'upgrade-readiness.sh',
readme: 'README.md',
manifest: 'manifest.json',
checksums: 'SHA256SUMS',
@@ -44,6 +50,7 @@ const VERIFICATION = Object.freeze({
edgeFreshLifecycle: 'passed',
standaloneFreshLifecycle: 'passed',
localApiCancellation: 'passed',
legacyUpgradeReadiness: 'passed',
});
function verificationGates(variant) {
@@ -447,6 +454,32 @@ function renderQuickstart(identity) {
return rendered;
}
function renderUpgradeReadiness(identity) {
const template = fs.readFileSync(
assertCanonicalFile(
UPGRADE_READINESS_TEMPLATE,
MAX_UPGRADE_READINESS_BYTES,
'upgrade readiness 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 readiness template contains an unresolved token');
}
return rendered;
}
function fileRecord(bundleRoot, name) {
const filePath = path.join(bundleRoot, name);
const stat = fs.lstatSync(filePath);
@@ -622,8 +655,13 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
renderQuickstart(manifestIdentity),
0o700,
);
writeExclusive(
path.join(normalized.outputRoot, FILES.upgradeReadiness),
renderUpgradeReadiness(manifestIdentity),
0o700,
);
const manifest = {
schemaVersion: 6,
schemaVersion: 7,
schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release',
product: 'local',
@@ -638,6 +676,10 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
operator: fileRecord(normalized.outputRoot, FILES.operatorSbom),
},
quickstart: fileRecord(normalized.outputRoot, FILES.quickstart),
upgradeReadiness: fileRecord(
normalized.outputRoot,
FILES.upgradeReadiness,
),
readme: fileRecord(normalized.outputRoot, FILES.readme),
verification: fileRecord(
normalized.outputRoot,
@@ -654,6 +696,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
FILES.readme,
FILES.manifest,
];
@@ -723,10 +766,11 @@ function auditLocalAlphaTrialKit(options) {
'images',
'sboms',
'quickstart',
'upgradeReadiness',
'readme',
'verification',
]) ||
manifest.schemaVersion !== 6 ||
manifest.schemaVersion !== 7 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' ||
@@ -773,6 +817,23 @@ function auditLocalAlphaTrialKit(options) {
if (actualQuickstart !== expectedQuickstart) {
fail('quickstart differs from the canonical deployment journey');
}
validateFileRecord(
manifest.upgradeReadiness,
FILES.upgradeReadiness,
bundleRoot,
);
const expectedUpgradeReadiness = renderUpgradeReadiness(manifest);
const actualUpgradeReadiness = fs.readFileSync(
assertCanonicalFile(
path.join(bundleRoot, FILES.upgradeReadiness),
MAX_UPGRADE_READINESS_BYTES,
'upgrade readiness',
),
'utf8',
);
if (actualUpgradeReadiness !== expectedUpgradeReadiness) {
fail('upgrade readiness differs from the canonical inspection journey');
}
validateFileRecord(manifest.readme, FILES.readme, bundleRoot);
validateOfflineSbom(
readBoundedJson(
@@ -809,6 +870,7 @@ function auditLocalAlphaTrialKit(options) {
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
expectedArchive,
].sort();
const actualFiles = fs
@@ -829,6 +891,7 @@ function auditLocalAlphaTrialKit(options) {
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
FILES.readme,
FILES.manifest,
];
@@ -842,7 +905,7 @@ function auditLocalAlphaTrialKit(options) {
}
return Object.freeze({
schemaVersion: 1,
schema: 'qinglong/alpha-local-trial-kit-audit@v2',
schema: 'qinglong/alpha-local-trial-kit-audit@v3',
sourceRevision: manifest.sourceRevision,
version: manifest.version,
architecture: manifest.architecture,
@@ -851,6 +914,7 @@ function auditLocalAlphaTrialKit(options) {
applicationImageId: manifest.images.application.id,
operatorImageId: manifest.images.operator.id,
quickstartSha256: manifest.quickstart.sha256,
upgradeReadinessSha256: manifest.upgradeReadiness.sha256,
verificationSha256: manifest.verification.sha256,
workflowRunId: verificationEvidence.workflow.runId,
workflowRunAttempt: verificationEvidence.workflow.runAttempt,
@@ -0,0 +1,160 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
function fail(message) {
throw new Error(
`QingLong Local Alpha upgrade readiness fixture failed: ${message}`,
);
}
function parseArguments(argv) {
if (argv.length !== 1) fail('usage: --output=/absolute/new/data-root');
const match = /^--output=(\/.+)$/u.exec(argv[0]);
if (!match) fail('output must be an absolute path');
const output = path.resolve(match[1]);
if (output !== match[1] || path.parse(output).root === output) {
fail('output must be a normalized absolute non-root path');
}
return output;
}
function writePrivate(filePath, contents) {
fs.writeFileSync(filePath, contents, {
encoding: 'utf8',
mode: 0o600,
flag: 'wx',
});
}
function createLegacyDatabase(databasePath) {
const database = new DatabaseSync(databasePath);
try {
database.exec(`
CREATE TABLE "Crontabs" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), command VARCHAR(255),
schedule VARCHAR(255), timestamp VARCHAR(255), saved TINYINT(1), status DECIMAL,
isSystem DECIMAL, pid DECIMAL, isDisabled DECIMAL, isPinned DECIMAL,
log_path VARCHAR(255), labels JSON, last_running_time DECIMAL,
last_execution_time DECIMAL, sub_id DECIMAL, extra_schedules JSON,
task_before VARCHAR(255), task_after VARCHAR(255), log_name VARCHAR(255),
allow_multiple_instances DECIMAL, work_dir VARCHAR(255),
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Dependences" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), type DECIMAL,
timestamp VARCHAR(255), status DECIMAL, log JSON, remark VARCHAR(255),
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Apps" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), scopes JSON,
client_id VARCHAR(255), client_secret VARCHAR(255), tokens JSON,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Auths" (
id INTEGER PRIMARY KEY AUTOINCREMENT, ip VARCHAR(255), type VARCHAR(255),
info JSON, createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Envs" (
id INTEGER PRIMARY KEY AUTOINCREMENT, value VARCHAR(255), timestamp VARCHAR(255),
status DECIMAL, position DECIMAL, name VARCHAR(255), remarks VARCHAR(255),
isPinned DECIMAL, labels JSON, createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
CREATE TABLE "Subscriptions" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), url VARCHAR(255),
schedule VARCHAR(255), interval_schedule JSON, type VARCHAR(255),
whitelist VARCHAR(255), blacklist VARCHAR(255), status DECIMAL,
dependences VARCHAR(255), extensions VARCHAR(255), sub_before VARCHAR(255),
sub_after VARCHAR(255), branch VARCHAR(255), pull_type VARCHAR(255),
pull_option JSON, pid DECIMAL, is_disabled DECIMAL, log_path VARCHAR(255),
schedule_type VARCHAR(255), alias VARCHAR(255), proxy VARCHAR(255),
autoAddCron DECIMAL, autoDelCron DECIMAL, createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
CREATE TABLE "CrontabViews" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), position DECIMAL,
isDisabled DECIMAL, filters JSON, sorts JSON, filterRelation VARCHAR(255),
type DECIMAL, createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "CrontabStats" (
id INTEGER PRIMARY KEY AUTOINCREMENT, ref_id DECIMAL NOT NULL,
date VARCHAR(255) NOT NULL, run_count DECIMAL, success_count DECIMAL,
fail_count DECIMAL, total_time DECIMAL, max_time DECIMAL,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "RunningInstances" (
id INTEGER PRIMARY KEY AUTOINCREMENT, cron_id DECIMAL NOT NULL,
run_id VARCHAR(36), attempt_id VARCHAR(36), pid DECIMAL,
log_path VARCHAR(255), started_at DECIMAL NOT NULL, finished_at DECIMAL,
status DECIMAL NOT NULL, exit_code DECIMAL,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "PluginOwnedState" (id INTEGER PRIMARY KEY, payload TEXT NOT NULL);
INSERT INTO "Crontabs" (
id, name, command, schedule, status, isDisabled, isPinned, createdAt, updatedAt
) VALUES (
1, 'Alpha upgrade readiness fixture', 'task /ql/scripts/alpha.sh',
'0 0 * * *', 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO "Envs" (
id, name, value, status, position, createdAt, updatedAt
) VALUES (
1, 'ALPHA_READINESS_VALUE', 'synthetic-only', 0, 100,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO "PluginOwnedState" (id, payload)
VALUES (1, '{"synthetic":true}');
`);
} finally {
database.close();
}
fs.chmodSync(databasePath, 0o600);
}
function createFixture(output) {
if (fs.existsSync(output)) fail('output must not already exist');
fs.mkdirSync(output, { mode: 0o700 });
for (const directory of ['config', 'scripts', 'db', 'upload', 'ssh.d']) {
fs.mkdirSync(path.join(output, directory), { mode: 0o700 });
}
createLegacyDatabase(path.join(output, 'db', 'database.sqlite'));
writePrivate(
path.join(output, 'config', 'config.sh'),
"export ALPHA_READINESS_CONFIG='synthetic-only'\n",
);
writePrivate(
path.join(output, 'scripts', 'alpha.sh'),
"#!/bin/sh\nprintf '%s\\n' 'synthetic alpha readiness'\n",
);
writePrivate(
path.join(output, 'upload', 'README.txt'),
'synthetic fixture\n',
);
return Object.freeze({
output,
database: path.join(output, 'db', 'database.sqlite'),
});
}
function runCli(argv) {
const result = createFixture(parseArguments(argv));
process.stdout.write(`${JSON.stringify({ status: 'created', ...result })}\n`);
}
if (require.main === module) {
try {
runCli(process.argv.slice(2));
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : 'unknown failure'}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({ createFixture, parseArguments });
@@ -0,0 +1,107 @@
#!/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 readiness failed: $*" >&2
exit 1
}
usage() {
printf '%s\n' \
'usage: sh upgrade-readiness.sh edge|standalone /absolute/legacy-data-root /absolute/new/evidence-root' >&2
exit 2
}
safe_absolute_path() {
case "$1" in
/|*[!A-Za-z0-9_./-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/)
return 1
;;
/*) return 0 ;;
*) return 1 ;;
esac
}
[ "$#" -eq 3 ] || usage
profile=$1
legacy_root=$2
evidence_root=$3
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 "$evidence_root" || fail 'evidence root is not a safe canonical absolute path'
[ "$legacy_root" != "$evidence_root" ] || fail 'legacy and evidence roots must be distinct'
[ -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 "$evidence_root" ] || fail 'evidence root must not already exist'
evidence_parent=${evidence_root%/*}
[ -n "$evidence_parent" ] || evidence_parent=/
[ -d "$evidence_parent" ] || fail 'evidence root parent does not exist'
evidence_parent_real=$(CDPATH= cd -- "$evidence_parent" && pwd -P)
[ "$evidence_parent_real/${evidence_root##*/}" = "$evidence_root" ] || fail 'evidence 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'
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 "$evidence_root"
mkdir -m 0700 "$evidence_root/results"
cat >"$evidence_root/sqlite-inspect.json" <<EOF
{"schemaVersion":1,"operation":"local-sqlite.adoption.inspect","options":{"deploymentRoot":"/var/lib/qinglong3","profile":"$profile","sourcePath":"/var/lib/qinglong2/db/database.sqlite"}}
EOF
cat >"$evidence_root/data-directory-inspect.json" <<EOF
{"schemaVersion":1,"operation":"local-data-directory.adoption.inspect","options":{"dataRoot":"/var/lib/qinglong2","profile":"$profile"}}
EOF
chmod 0600 "$evidence_root/sqlite-inspect.json" "$evidence_root/data-directory-inspect.json"
uid=$(id -u)
gid=$(id -g)
run_inspect() {
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=$evidence_root,dst=/var/lib/qinglong3" \
"$OPERATOR_IMAGE" adoption run \
--command-file "/var/lib/qinglong3/$command_file" \
>"$evidence_root/results/$result_file"
}
run_inspect sqlite-inspect.json sqlite-inspect.result.json
grep -q '"status":"inspected"' "$evidence_root/results/sqlite-inspect.result.json" || fail 'SQLite inspect did not report inspected'
run_inspect data-directory-inspect.json data-directory-inspect.result.json
grep -q '"status":"inspected"' "$evidence_root/results/data-directory-inspect.result.json" || fail 'data-directory inspect did not report inspected'
umask "$old_umask"
printf '%s\n' \
"QingLong 2.x upgrade readiness inspection completed ($profile, $ARCHITECTURE)." \
"SQLite plan: $evidence_root/results/sqlite-inspect.result.json" \
"Data-directory plan: $evidence_root/results/data-directory-inspect.result.json" \
'The legacy data root was mounted read-only; no stage, activation, cutover or rollback was authorized.' \
'Review both complete results and preserve their exact plan digests before any later rehearsal phase.'
+4 -2
View File
@@ -80,8 +80,8 @@ function writeMilestone(directory, manifest) {
function localManifest(attempt = runAttempt, variant = 'headless') {
return {
schemaVersion: 2,
schema: 'qinglong/alpha-local-milestone@v2',
schemaVersion: 3,
schema: 'qinglong/alpha-local-milestone@v3',
maturity: 'alpha_candidate_not_public_release',
product: 'local',
variant,
@@ -101,6 +101,7 @@ function localManifest(attempt = runAttempt, variant = 'headless') {
applicationImageId: digest('3'),
operatorImageId: digest('4'),
verificationSha256: digest('5'),
upgradeReadinessSha256: digest('b'),
},
arm64: {
artifactName: `ql3-alpha-${revision}-local-${variant}-arm64`,
@@ -114,6 +115,7 @@ function localManifest(attempt = runAttempt, variant = 'headless') {
applicationImageId: digest('8'),
operatorImageId: digest('9'),
verificationSha256: digest('a'),
upgradeReadinessSha256: digest('c'),
},
},
readme: null,
+11 -2
View File
@@ -29,7 +29,12 @@ const revision = 'a'.repeat(40);
const runId = '33047425710';
const runAttempt = '2';
function imageInspection(role, architecture, idCharacter, variant = 'headless') {
function imageInspection(
role,
architecture,
idCharacter,
variant = 'headless',
) {
return {
Id: `sha256:${idCharacter.repeat(64)}`,
Os: 'linux',
@@ -201,7 +206,11 @@ function finalizeOptions(paths) {
test('finalizes two exact native trial kits into one closed milestone index', (t) => {
const paths = fixture(t);
const manifest = finalizeLocalAlphaMilestone(finalizeOptions(paths));
assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v2');
assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v3');
assert.match(
manifest.artifacts.amd64.upgradeReadinessSha256,
/^sha256:[0-9a-f]{64}$/,
);
assert.equal(manifest.variant, 'headless');
assert.equal(manifest.sourceRevision, revision);
assert.deepEqual(Object.keys(manifest.artifacts), ['amd64', 'arm64']);
+85 -1
View File
@@ -166,7 +166,7 @@ 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@v5');
assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v6');
assert.equal(manifest.variant, 'headless');
assert.equal(manifest.sourceRevision, revision);
assert.equal(manifest.architecture, 'arm64');
@@ -175,6 +175,7 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => {
assert.notEqual(manifest.images.application.id, manifest.images.operator.id);
assert.equal(manifest.verification.file, 'verification-evidence.json');
assert.equal(manifest.quickstart.file, 'quickstart.sh');
assert.equal(manifest.upgradeReadiness.file, 'upgrade-readiness.sh');
const quickstart = path.join(paths.outputRoot, 'quickstart.sh');
const syntax = spawnSync('sh', ['-n', quickstart], { encoding: 'utf8' });
assert.equal(syntax.status, 0, syntax.stderr);
@@ -197,6 +198,7 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => {
'qinglong3-local-operator.cdx.json',
'qinglong3-local-trial-kit-arm64.docker.tar',
'quickstart.sh',
'upgrade-readiness.sh',
'verification-evidence.json',
]);
});
@@ -229,6 +231,7 @@ test('materializes a distinct loopback Console trial kit without widening the he
assert.equal(verification.gates.consoleLiveJourney, 'passed');
assert.equal(verification.gates.firstAutomationJourney, 'passed');
assert.equal(verification.gates.ownerCredentialPresentation, 'passed');
assert.equal(verification.gates.legacyUpgradeReadiness, 'passed');
const quickstartContents = fs.readFileSync(
path.join(paths.outputRoot, 'quickstart.sh'),
'utf8',
@@ -251,6 +254,7 @@ test('materializes a distinct loopback Console trial kit without widening the he
'qinglong3-local-console-trial-kit-arm64.docker.tar',
'qinglong3-local-operator.cdx.json',
'quickstart.sh',
'upgrade-readiness.sh',
'verification-evidence.json',
]);
});
@@ -283,6 +287,7 @@ test('offline audit rejects archive, file-set, SBOM and verification mutation',
'archive',
'extra',
'quickstart',
'upgrade-readiness',
'sbom',
'verification',
]) {
@@ -304,6 +309,11 @@ test('offline audit rejects archive, file-set, SBOM and verification mutation',
path.join(paths.outputRoot, 'quickstart.sh'),
'# drift\n',
);
} else if (mutation === 'upgrade-readiness') {
fs.appendFileSync(
path.join(paths.outputRoot, 'upgrade-readiness.sh'),
'# drift\n',
);
} else if (mutation === 'sbom') {
fs.copyFileSync(
path.join(paths.outputRoot, 'qinglong3-local-application.cdx.json'),
@@ -342,6 +352,7 @@ test('offline audit rejects a rehashed non-canonical quickstart', (t) => {
'qinglong3-local-operator.cdx.json',
'verification-evidence.json',
'quickstart.sh',
'upgrade-readiness.sh',
'README.md',
'manifest.json',
];
@@ -450,6 +461,79 @@ exit 1
assert.doesNotMatch(calls, /--network (?!none)/);
});
test('generated upgrade readiness drives two read-only legacy inspections', (t) => {
const paths = fixture(t);
createLocalAlphaTrialKit(createOptions(paths), adapters());
const fakeBin = path.join(paths.fixtureRoot, 'readiness-fake-bin');
fs.mkdirSync(fakeBin);
const dockerLog = path.join(paths.fixtureRoot, 'readiness-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-inspect.json'*) printf '%s\\n' '{"status":"inspected","evidence":{"planDigest":"${'a'.repeat(
64,
)}"}}'; exit 0 ;;
*'/data-directory-inspect.json'*) printf '%s\\n' '{"status":"inspected","evidence":{"planDigest":"${'b'.repeat(
64,
)}"}}'; exit 0 ;;
esac
exit 1
`,
{ mode: 0o755 },
);
const legacyRoot = path.join(paths.fixtureRoot, 'legacy-data');
fs.mkdirSync(path.join(legacyRoot, 'db'), { recursive: true, mode: 0o700 });
fs.writeFileSync(path.join(legacyRoot, 'db', 'database.sqlite'), 'legacy', {
mode: 0o600,
});
const evidenceRoot = path.join(paths.fixtureRoot, 'upgrade-readiness');
const run = spawnSync(
'sh',
[
path.join(paths.outputRoot, 'upgrade-readiness.sh'),
'edge',
legacyRoot,
evidenceRoot,
],
{
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, /upgrade readiness inspection completed/);
assert.equal(
JSON.parse(
fs.readFileSync(
path.join(evidenceRoot, 'results', 'sqlite-inspect.result.json'),
'utf8',
),
).status,
'inspected',
);
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, /adoption\.stage|activation\.prepare|cutover/);
});
test('create rejects an image reference that could alter the shell journey', (t) => {
const paths = fixture(t);
const options = createOptions(paths);
@@ -0,0 +1,60 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const test = require('node:test');
const {
createFixture,
parseArguments,
} = require('../../scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs');
test('creates one private production-shaped QingLong 2.x readiness fixture', (t) => {
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-alpha-upgrade-readiness-')),
);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const output = path.join(root, 'legacy-data');
const result = createFixture(output);
assert.equal(result.output, output);
assert.equal(fs.statSync(output).mode & 0o777, 0o700);
assert.equal(fs.statSync(result.database).mode & 0o777, 0o600);
const database = new DatabaseSync(result.database, { readOnly: true });
try {
const tables = database
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name",
)
.all()
.map(({ name }) => name);
assert.deepEqual(tables, [
'Apps',
'Auths',
'CrontabStats',
'CrontabViews',
'Crontabs',
'Dependences',
'Envs',
'PluginOwnedState',
'RunningInstances',
'Subscriptions',
'sqlite_sequence',
]);
} finally {
database.close();
}
assert.throws(() => createFixture(output), /must not already exist/);
});
test('requires one normalized absolute output path', () => {
assert.equal(parseArguments(['--output=/tmp/ql3-legacy']), '/tmp/ql3-legacy');
assert.throws(() => parseArguments([]), /usage/);
assert.throws(() => parseArguments(['--output=relative']), /absolute path/);
assert.throws(
() => parseArguments(['--output=/tmp/../tmp/legacy']),
/normalized absolute non-root path/,
);
});