From d6571e4b89eaf29ed6277dd08bbd7ffb57a3705d Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 30 Aug 2026 14:00:26 +0800 Subject: [PATCH] feat(ql3): add downloadable upgrade readiness --- .github/workflows/ql3-ci.yml | 12 ++ docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 + ...loadable-local-legacy-upgrade-readiness.md | 85 ++++++++++ docs/adr/README.md | 1 + docs/operations/ql3-alpha-candidate.md | 10 +- docs/operations/ql3-local-alpha-milestone.md | 4 +- docs/operations/ql3-local-alpha-trial-kit.md | 36 +++- scripts/ql3-alpha-stage-index.cjs | 7 +- scripts/ql3-local-alpha-milestone.cjs | 17 +- scripts/ql3-local-alpha-trial-kit-bundle.cjs | 74 +++++++- ...-local-alpha-upgrade-readiness-fixture.cjs | 160 ++++++++++++++++++ .../ql3-local-alpha-upgrade-readiness.sh | 107 ++++++++++++ test/back/ql3AlphaStageIndex.test.cjs | 6 +- test/back/ql3LocalAlphaMilestone.test.cjs | 13 +- .../back/ql3LocalAlphaTrialKitBundle.test.cjs | 86 +++++++++- ...LocalAlphaUpgradeReadinessFixture.test.cjs | 60 +++++++ 16 files changed, 652 insertions(+), 28 deletions(-) create mode 100644 docs/adr/ADR-0520-downloadable-local-legacy-upgrade-readiness.md create mode 100644 scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs create mode 100644 scripts/templates/ql3-local-alpha-upgrade-readiness.sh create mode 100644 test/back/ql3LocalAlphaUpgradeReadinessFixture.test.cjs diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 6d62f596..73532362 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -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') diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index b4cd503d..b48355e9 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,5 +1,7 @@ # QingLong 3.0 Architecture RFC +- D-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/attempt;stage index 为 Edge、Standalone、Cluster 精确选择 10 个 artifact。低配设备只需目标架构 Local 包且稳态仅运行 Application;集群节点只需目标架构 control/admin/worker,control-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 生产升级、物理低配容量承诺或长期支持版本。 diff --git a/docs/adr/ADR-0520-downloadable-local-legacy-upgrade-readiness.md b/docs/adr/ADR-0520-downloadable-local-legacy-upgrade-readiness.md new file mode 100644 index 00000000..7584f335 --- /dev/null +++ b/docs/adr/ADR-0520-downloadable-local-legacy-upgrade-readiness.md @@ -0,0 +1,85 @@ +# ADR-0520:可下载的 Local Legacy 升级就绪盘点 + +- 状态:Accepted(D-425 源码候选,双架构 Alpha 实物待生成) +- 日期:2026-08-30 +- 对应 RFC 切片:D-425 +- 关联 ADR:ADR-0476~ADR-0483、ADR-0503、ADR-0506、ADR-0511、ADR-0514 + +## 背景 + +QingLong 3.0 已经形成双架构 Local Trial Kit、Cluster bundle 和跨 Profile stage index,但当前可下载 Local quickstart 只支持 fresh 数据根。 +ADR-0476~ADR-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 CLI;Cluster 节点继续使用 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/auditor,D-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。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 144a92dc..79d31ec3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -523,6 +523,7 @@ | [ADR-0517](./ADR-0517-strong-local-console-task-authoring-lease.md) | 强认证 Local Console Task authoring lease | Accepted(D-422 双架构 milestone 已交付) | | [ADR-0518](./ADR-0518-local-console-cron-trigger-administration.md) | Local Console cron Trigger 管理 | Accepted(D-423 双架构 milestone 已交付) | | [ADR-0519](./ADR-0519-local-console-secret-backed-automation.md) | Local Console Secret-backed 自动化 | Accepted(D-424 双架构 milestone 已交付) | +| [ADR-0520](./ADR-0520-downloadable-local-legacy-upgrade-readiness.md) | 可下载的 Local Legacy 升级就绪盘点 | Accepted(D-425 源码候选,双架构 Alpha 实物待生成) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index 257c87aa..307f4794 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -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 authority;Console/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 verification,ADR-0511 的 `@v3` 增加 canonical quickstart,ADR-0513 的 `@v4` 再把 `headless|console` 变体绑定到 image、SBOM、verification、milestone 和 stage index,ADR-0514 的 `@v5` 增加标准 Owner credential presentation 与首自动化旅程。旧 runtime-only、v1/v2/v3/v4 bundle 均为历史工程证据,不能通过 v5 auditor。当前 headless、Console v5、Cluster 双架构 milestone 与跨 Profile stage index 均已有真实实物;下一阶段不再补“有没有产物”,而是补长期分发、签名、不可变 registry digest、部署锁和升级/回退证据。Public Release Set 继续受更严格发布门禁约束。 +ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verification,ADR-0511 的 `@v3` 增加 canonical quickstart,ADR-0513 的 `@v4` 再把 `headless|console` 变体绑定到 image、SBOM、verification、milestone 和 stage index,ADR-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 的 archive;headless 为 `qinglong3-local-trial-kit-.docker.tar`,Console 为 `qinglong3-local-console-trial-kit-.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。 diff --git a/docs/operations/ql3-local-alpha-milestone.md b/docs/operations/ql3-local-alpha-milestone.md index 8a0fa916..a71b70d3 100644 --- a/docs/operations/ql3-local-alpha-milestone.md +++ b/docs/operations/ql3-local-alpha-milestone.md @@ -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..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 索引: diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index 07188199..1516b3f9 100644 --- a/docs/operations/ql3-local-alpha-trial-kit.md +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -25,7 +25,7 @@ sha256sum --check SHA256SUMS `manifest.json` 必须满足: -- `schema` 为 `qinglong/alpha-local-trial-kit@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 PID;Standalone 为 256 MiB、0.5 CPU、256 PID;operator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。 diff --git a/scripts/ql3-alpha-stage-index.cjs b/scripts/ql3-alpha-stage-index.cjs index a6bbe854..b7353183 100644 --- a/scripts/ql3-alpha-stage-index.cjs +++ b/scripts/ql3-alpha-stage-index.cjs @@ -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, }); diff --git a/scripts/ql3-local-alpha-milestone.cjs b/scripts/ql3-local-alpha-milestone.cjs index 634eb20e..a58d1318 100644 --- a/scripts/ql3-local-alpha-milestone.cjs +++ b/scripts/ql3-local-alpha-milestone.cjs @@ -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', diff --git a/scripts/ql3-local-alpha-trial-kit-bundle.cjs b/scripts/ql3-local-alpha-trial-kit-bundle.cjs index 1a6772c8..949724fa 100644 --- a/scripts/ql3-local-alpha-trial-kit-bundle.cjs +++ b/scripts/ql3-local-alpha-trial-kit-bundle.cjs @@ -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, diff --git a/scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs b/scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs new file mode 100644 index 00000000..f377cae2 --- /dev/null +++ b/scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs @@ -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 }); diff --git a/scripts/templates/ql3-local-alpha-upgrade-readiness.sh b/scripts/templates/ql3-local-alpha-upgrade-readiness.sh new file mode 100644 index 00000000..f2f34985 --- /dev/null +++ b/scripts/templates/ql3-local-alpha-upgrade-readiness.sh @@ -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" <"$evidence_root/data-directory-inspect.json" <"$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.' diff --git a/test/back/ql3AlphaStageIndex.test.cjs b/test/back/ql3AlphaStageIndex.test.cjs index 41ce187d..ee78e415 100644 --- a/test/back/ql3AlphaStageIndex.test.cjs +++ b/test/back/ql3AlphaStageIndex.test.cjs @@ -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, diff --git a/test/back/ql3LocalAlphaMilestone.test.cjs b/test/back/ql3LocalAlphaMilestone.test.cjs index d5998954..07355107 100644 --- a/test/back/ql3LocalAlphaMilestone.test.cjs +++ b/test/back/ql3LocalAlphaMilestone.test.cjs @@ -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']); diff --git a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs index 62c2defe..826d97c5 100644 --- a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs +++ b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs @@ -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); diff --git a/test/back/ql3LocalAlphaUpgradeReadinessFixture.test.cjs b/test/back/ql3LocalAlphaUpgradeReadinessFixture.test.cjs new file mode 100644 index 00000000..862b5f0f --- /dev/null +++ b/test/back/ql3LocalAlphaUpgradeReadinessFixture.test.cjs @@ -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/, + ); +});