From 93b45ca62dd0b38ccd88f5cf74bd1c9da49d273f Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 30 Aug 2026 22:05:04 +0800 Subject: [PATCH] feat(ql3): rehearse exact local upgrade cutover --- .github/workflows/ql3-ci.yml | 33 +- .../containers/ql3-local-operator/Dockerfile | 1 + ...xact-headless-upgrade-cutover-rehearsal.md | 49 +++ docs/adr/README.md | 2 + docs/operations/ql3-alpha-candidate.md | 4 +- docs/operations/ql3-local-alpha-milestone.md | 4 +- docs/operations/ql3-local-alpha-trial-kit.md | 27 +- scripts/ql3-alpha-stage-index.cjs | 2 +- scripts/ql3-local-alpha-milestone.cjs | 19 +- scripts/ql3-local-alpha-trial-kit-bundle.cjs | 81 ++++- scripts/ql3-local-operator-image-audit.cjs | 6 + ...3-local-alpha-upgrade-cutover-rehearsal.sh | 282 ++++++++++++++++++ .../ql3-local-alpha-upgrade-rehearsal.sh | 16 +- test/back/ql3AlphaStageIndex.test.cjs | 6 +- test/back/ql3LocalAlphaMilestone.test.cjs | 7 +- .../back/ql3LocalAlphaTrialKitBundle.test.cjs | 36 ++- 16 files changed, 539 insertions(+), 36 deletions(-) create mode 100644 docs/adr/ADR-0524-exact-headless-upgrade-cutover-rehearsal.md create mode 100644 scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 90f5493d..4fad8013 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -654,6 +654,12 @@ jobs: --pids-limit=32 \ --tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \ "${OPERATOR_IMAGE}" setup --help + docker run --rm --read-only \ + --network none \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --entrypoint /usr/bin/docker \ + "${OPERATOR_IMAGE}" --version - name: Build the short-lived fresh deployment authority working-directory: packages/ql3-local-owner-cli run: node ../../scripts/ql3-build-package-closure.cjs @@ -777,16 +783,35 @@ jobs: "${UPGRADE_EVIDENCE_ROOT}/results/sqlite-inspect.result.json")" DIRECTORY_PLAN_DIGEST="$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).evidence.planDigest)' \ "${UPGRADE_EVIDENCE_ROOT}/results/data-directory-inspect.result.json")" - UPGRADE_REHEARSAL_ROOT="${RUNNER_TEMP}/ql3-alpha-upgrade-rehearsal-${{ matrix.image_arch }}" - sh "${BUNDLE_ROOT}/upgrade-rehearsal.sh" \ - edge "${LEGACY_READINESS_ROOT}" "${UPGRADE_REHEARSAL_ROOT}" \ - "${SQLITE_PLAN_DIGEST}" "${DIRECTORY_PLAN_DIGEST}" + UPGRADE_REHEARSAL_ROOT="${RUNNER_TEMP}/ql3-alpha-upgrade-cutover-rehearsal-${{ matrix.image_arch }}" + LEGACY_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-upgrade-legacy-${{ matrix.image_arch }}" + TARGET_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-upgrade-target-${{ matrix.image_arch }}" + trap 'docker rm --force "${QUICKSTART_CONTAINER}" "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}" >/dev/null 2>&1 || true' EXIT + if [[ "${TRIAL_VARIANT}" == 'headless' ]]; then + sh "${BUNDLE_ROOT}/upgrade-cutover-rehearsal.sh" \ + edge "${LEGACY_READINESS_ROOT}" "${UPGRADE_REHEARSAL_ROOT}" \ + "${SQLITE_PLAN_DIGEST}" "${DIRECTORY_PLAN_DIGEST}" \ + "${LEGACY_CONTAINER}" "${TARGET_CONTAINER}" + else + sh "${BUNDLE_ROOT}/upgrade-rehearsal.sh" \ + edge "${LEGACY_READINESS_ROOT}" "${UPGRADE_REHEARSAL_ROOT}" \ + "${SQLITE_PLAN_DIGEST}" "${DIRECTORY_PLAN_DIGEST}" + fi grep -q '"status":"verified"' \ "${UPGRADE_REHEARSAL_ROOT}/stage-summary.json" grep -q '"legacySource":"read_only"' \ "${UPGRADE_REHEARSAL_ROOT}/stage-summary.json" grep -q '"cutover":"not_authorized"' \ "${UPGRADE_REHEARSAL_ROOT}/stage-summary.json" + if [[ "${TRIAL_VARIANT}" == 'headless' ]]; then + grep -q '"status":"rollback_candidate"' \ + "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" + grep -q '"legacySource":"unchanged"' \ + "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" + grep -q '"target":"stopped"' \ + "${UPGRADE_REHEARSAL_ROOT}/cutover-summary.json" + docker rm "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}" + fi test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-wal" test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-journal" trap - EXIT diff --git a/deploy/containers/ql3-local-operator/Dockerfile b/deploy/containers/ql3-local-operator/Dockerfile index 531125da..dd2f5ee0 100644 --- a/deploy/containers/ql3-local-operator/Dockerfile +++ b/deploy/containers/ql3-local-operator/Dockerfile @@ -124,6 +124,7 @@ RUN rm -rf node_modules/.bin \ FROM node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436 AS runtime RUN apk add --no-cache --upgrade \ + docker-cli=29.5.2-r0 \ libcrypto3=3.5.8-r0 \ libssl3=3.5.8-r0 diff --git a/docs/adr/ADR-0524-exact-headless-upgrade-cutover-rehearsal.md b/docs/adr/ADR-0524-exact-headless-upgrade-cutover-rehearsal.md new file mode 100644 index 00000000..ef4c310f --- /dev/null +++ b/docs/adr/ADR-0524-exact-headless-upgrade-cutover-rehearsal.md @@ -0,0 +1,49 @@ +# ADR-0524:Exact Headless 升级切换链演练 + +- 状态:Accepted(D-426b2b 源码候选;双架构阶段实物待本次 artifact run) +- 日期:2026-08-30 +- 决策:D-426b2b +- 关联:ADR-0520、ADR-0521、ADR-0522、ADR-0523 + +## 上下文 + +ADR-0523 已修正 Apply 后 Adopted Target 的回退基线,但仓库内单元测试不能证明用户下载到的 Docker archive 具备完整控制器能力。阶段产物必须从将要上传的 exact bundle 走通 reviewed stage、Owner 认证、apply、真实 Docker legacy stop、target start/stop 和 clean `rollback_candidate`。 + +短生命周期 Operator 此前不含 Docker client,无法在容器内通过受约束的 Docker socket 执行既有 controller。另一方面,Console 镜像入口是 Local API,而 adopted target 证据当前只接受 `local-application-process@v3/v4`;把 Console fresh journey 标记为已验证切换会形成错误承诺。 + +## 决策 + +1. Operator runtime 增加固定 Alpine 版本的 `docker-cli`,不包含 daemon、Compose、listener、timer 或常驻进程;现有无网络、只读 rootfs、128 MiB、0.5 CPU、32 PID 边界不变。 +2. Trial Kit v8 新增 canonical `upgrade-cutover-rehearsal.sh`,并将其文件摘要绑定到 bundle manifest、offline auditor 与 Local milestone v5。 +3. 脚本仅允许 headless Trial Kit,在全新 rehearsal root 和两个专用合成容器上执行: + - exact bundle checksum/load/image identity; + - reviewed SQLite 与 data-directory stage/verify/activation; + - fresh Owner ceremony、credential presentation、transform/apply/verify; + - Docker socket 上的 legacy stop、offline image target start/stop; + - Application v4 post-apply baseline 与最终 `rollback_candidate`。 +4. Legacy root 在 Operator、合成 Legacy 与 target 中均为只读绑定;演练前后主 SQLite SHA-256 必须一致,WAL/journal 不得出现。 +5. 成功后写入私有 `cutover-summary.json`,固定 `status=rollback_candidate`、`legacySource=unchanged`、`target=stopped`;CI 删除两个已停止合成容器后才可上传产物。 +6. Console 继续执行 exact readiness/stage,`legacyUpgradeCutover=not_applicable`。在 target evidence 支持 Local API 入口前,不得将其写成 `passed`。 + +## 产物闭包 + +- `qinglong/alpha-local-trial-kit@v8` / verification v6 / audit v5; +- `qinglong/alpha-local-milestone@v5` / audit v5; +- Stage Index v2 只接受新的 Local milestone v5; +- headless 原生 amd64、arm64 artifact job 必须执行 exact cutover rehearsal;普通源码 CI、模拟 Docker 或单架构结果都不能替代该门。 + +本 ADR 不授权生产 cutover、真实 2.x 容器停机、Legacy restart/rollback 或数据目录替换。新阶段实物只有在同源双架构 artifact 与 milestone 均由成功终态 workflow 闭合后成立。 + +## 验证 + +- back:1657 total / 1655 pass / 2 conditional skip / 0 fail; +- Local Owner CLI:308 total / 301 pass / 7 conditional skip / 0 fail; +- Trial Kit、Local milestone、Stage Index 聚焦测试:29/29; +- package boundary 保持 18 个 workspace package、无 single/shallow package、零 finding; +- Operator image、milestone workflow、Stage Index workflow auditor 均为 compatible。 + +真实镜像构建、固定 Docker CLI、双架构 exact rehearsal 和新 artifact 摘要将在本提交的 GitHub artifact run 中补充。只有该 run 成功后,才把状态更新为“阶段实物已闭合”。 + +## 后续 + +D-426b2c 评估 Console adopted target 的显式双进程/入口证据模型;D-426c 继续处理 target 写入后的 capture、review、reconciliation 与恢复。两者都不得削弱 headless 已闭合的离线镜像和回退基线。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 4a4b91f5..10a50151 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -526,6 +526,8 @@ | [ADR-0520](./ADR-0520-downloadable-local-legacy-upgrade-readiness.md) | 可下载的 Local Legacy 升级就绪盘点 | Accepted(D-425 双架构 Alpha 实物已交付) | | [ADR-0521](./ADR-0521-reviewed-side-by-side-local-upgrade-stage.md) | 受审核计划驱动的 Local Side-by-side 升级暂存 | Accepted(D-426a 双架构 Alpha 实物已交付) | | [ADR-0522](./ADR-0522-content-bound-offline-docker-adopted-target.md) | 内容绑定的离线 Docker Adopted Target | Accepted(D-426b 源码候选;阶段实物尚未闭合) | +| [ADR-0523](./ADR-0523-post-apply-adopted-target-baseline.md) | Apply 后的 Adopted Target 启动前基线 | Accepted(D-426b2a 源码候选;阶段实物尚未闭合) | +| [ADR-0524](./ADR-0524-exact-headless-upgrade-cutover-rehearsal.md) | Exact Headless 升级切换链演练 | Accepted(D-426b2b 源码候选;双架构阶段实物待闭合) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index 9911c0f2..d61ccf6c 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -110,7 +110,7 @@ ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verif Local artifact 含: - 一个包含所选 Application 与短生命周期 operator 的 archive;headless 为 `qinglong3-local-trial-kit-.docker.tar`,Console 为 `qinglong3-local-console-trial-kit-.docker.tar`,共享 Node 基础层在 archive 中去重; -- schema 为 `qinglong/alpha-local-trial-kit@v7` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/upgradeReadiness/upgradeRehearsal/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; +- schema 为 `qinglong/alpha-local-trial-kit@v8` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/upgradeReadiness/upgradeRehearsal/upgradeCutoverRehearsal/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; - 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,它不替代正式签名; @@ -119,7 +119,7 @@ Local artifact 含: Cluster artifact 是每角色/架构一个六文件闭包:native Docker archive、精确 CycloneDX SBOM、workflow-bound verification evidence、README、`qinglong/alpha-cluster-image@v1` manifest 和覆盖全部内容文件的 `SHA256SUMS`。完整 CI 成功后,八个 bundle 由 `qinglong/alpha-cluster-milestone@v1` 小型索引闭合;索引本身不重复存放大 archive。 -Local milestone 是 `qinglong/alpha-local-milestone@v4` 三文件闭包,绑定一个 variant 的双架构 Trial Kit,并直接记录两个架构的 `upgradeReadinessSha256` 与 `upgradeRehearsalSha256`。Stage index 是 `qinglong/alpha-stage-index@v2` 三文件闭包;它重新审计两个 milestone,要求 version/source/workflow SHA/ref/run/attempt 一致,并把 Local variant/Profile 与 Cluster 的 control/admin/worker 最小集、可选 control-ai 写为机器可读选择;它不重复存放任何镜像 archive。 +Local milestone 是 `qinglong/alpha-local-milestone@v5` 三文件闭包,绑定一个 variant 的双架构 Trial Kit,并直接记录两个架构的 `upgradeReadinessSha256`、`upgradeRehearsalSha256` 与 `upgradeCutoverRehearsalSha256`。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 54e3c560..a89ce445 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@v4`; + - `schema` 为 `qinglong/alpha-local-milestone@v5`; - `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. 确认 milestone 的 `upgradeReadinessSha256`、`upgradeRehearsalSha256` 与 Trial Kit manifest 中同名入口摘要一致,再按 Trial Kit 自带 `README.md` 完成 fresh smoke、只读 2.x 升级就绪盘点或受审核计划的 side-by-side 暂存。 +5. 确认 milestone 的 `upgradeReadinessSha256`、`upgradeRehearsalSha256`、`upgradeCutoverRehearsalSha256` 与 Trial Kit manifest 中同名入口摘要一致,再按 Trial Kit 自带 `README.md` 完成 fresh smoke、只读 2.x 升级就绪盘点、受审核计划的 side-by-side 暂存或隔离切换链演练。 若持有同一版本源码与 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 783fbf62..7ba39173 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@v7`; +- `schema` 为 `qinglong/alpha-local-trial-kit@v8`; - `variant` 为 `headless` 或 `console`,并与 milestone、application SBOM 和 artifact 名一致; - `sourceRevision` 是你准备试用的完整 40 位 commit; - `architecture` 与主机相同; @@ -116,7 +116,7 @@ artifact job 必须在原生 amd64/arm64 上使用生产形态 2.x fixture 运 ## 受审核计划的 Side-by-side 暂存 -审核上一节两个完整结果后,把其中 exact `evidence.planDigest` 作为显式参数交给 v7 bundle 的 canonical `upgrade-rehearsal.sh`: +审核上一节两个完整结果后,把其中 exact `evidence.planDigest` 作为显式参数交给 v8 bundle 的 canonical `upgrade-rehearsal.sh`: ```sh sh upgrade-rehearsal.sh \ @@ -135,6 +135,27 @@ staging manifest。summary 必须是 `status=verified`、`legacySource=read_only 复用或当作生产数据根;后续 adopted start 必须精确消费这里的 evidence,并走独立的 D-426b 门。artifact job 必须对将要上传的 exact 脚本使用同一个 生产形态 fixture 实跑,并记录 `verification-evidence.json.gates.legacyUpgradeStage=passed`。 +## 隔离的真实切换链演练 + +v8 headless bundle 进一步提供 `upgrade-cutover-rehearsal.sh`。它只面向 Linux Docker 测试主机,在新的 rehearsal root 和两个专用合成容器上消费上一阶段已审核的两个 plan digest: + +```sh +sh upgrade-cutover-rehearsal.sh \ + edge \ + /opt/qinglong/data \ + /opt/qinglong3-alpha-upgrade-cutover \ + \ + \ + ql3-alpha-upgrade-legacy \ + ql3-alpha-upgrade-target +``` + +脚本先重跑 canonical stage/verify,再完成 fresh Owner 建立、data-directory transform/apply、真实 Docker socket 上的合成 Legacy 停机和 3.0 target 启停。Operator 镜像仅增加固定版本 Docker CLI,仍不携带 daemon、Compose,也不常驻。Legacy root 在所有容器中均以只读方式挂载;脚本对演练前后的 `db/database.sqlite` 做 SHA-256 闭合校验。 + +成功时 `cutover-summary.json` 必须同时为 `status=rollback_candidate`、`legacySource=unchanged`、`target=stopped`。两个合成容器会保持停止状态供审查,随后按脚本输出显式 `docker rm`;失败时脚本自动清理。该结果证明打包产物能够走通控制器链和 Docker 证据闭环,但不会停止用户真实 2.x 容器、执行 Legacy restart/rollback 或授权生产升级。 + +原生 amd64/arm64 headless artifact job 必须从将要上传的目录执行 exact `upgrade-cutover-rehearsal.sh`,检查 summary 和旧 SQLite 未变,并删除合成容器后才能上传;对应 gate 为 `verification-evidence.json.gates.legacyUpgradeCutover=passed`。Console artifact 继续实跑 canonical stage,但该 gate 固定为 `not_applicable`;在 adopted target 证据正式支持 Local API 入口前,不得把 Console fresh journey 冒充升级切换验证。 + ## 手工加载与最小 smoke 从 `manifest.json.archive.file` 找到 archive 后加载: @@ -162,7 +183,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。v7 artifact job 必须从将要上传的目录实际执行 `quickstart.sh`、read-only `upgrade-readiness.sh` 和 reviewed-plan `upgrade-rehearsal.sh`,并完成 graceful stop。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 +完整 fresh setup、首 Owner ceremony、Owner presentation 安装、Application active、SIGTERM drain、SQLite integrity 和原生 cancellation 必须在 `verification-evidence.json` 指向的同架构 milestone job 中验证。Console 还必须证明首页返回 200、未认证 API 返回 401,并用真实 Owner credential 完成 Task read、fenced start、`succeeded` 终态与 bounded log marker。v8 artifact job 必须从将要上传的目录实际执行 `quickstart.sh`、read-only `upgrade-readiness.sh` 和 isolated `upgrade-cutover-rehearsal.sh`,并完成 graceful stop、rollback-candidate 检查与合成容器清理。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 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 ea589073..4132a12b 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@v4' + ? 'qinglong/alpha-local-milestone@v5' : 'qinglong/alpha-cluster-milestone@v1'; if ( !exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) || diff --git a/scripts/ql3-local-alpha-milestone.cjs b/scripts/ql3-local-alpha-milestone.cjs index 545de543..b327e1ad 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@v4'; +const SCHEMA = 'qinglong/alpha-local-milestone@v5'; const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const FILES = Object.freeze({ readme: 'README.md', @@ -221,6 +221,7 @@ function bundleRecord(options, architecture) { verificationSha256: report.verificationSha256, upgradeReadinessSha256: report.upgradeReadinessSha256, upgradeRehearsalSha256: report.upgradeRehearsalSha256, + upgradeCutoverRehearsalSha256: report.upgradeCutoverRehearsalSha256, }); } @@ -236,6 +237,7 @@ function validateArtifactRecord(record, architecture, manifest) { 'verificationSha256', 'upgradeReadinessSha256', 'upgradeRehearsalSha256', + 'upgradeCutoverRehearsalSha256', ]) || record.artifactName !== artifactName(manifest.sourceRevision, architecture, manifest.variant) || @@ -251,6 +253,7 @@ function validateArtifactRecord(record, architecture, manifest) { !SHA256_PATTERN.test(record.verificationSha256 || '') || !SHA256_PATTERN.test(record.upgradeReadinessSha256 || '') || !SHA256_PATTERN.test(record.upgradeRehearsalSha256 || '') || + !SHA256_PATTERN.test(record.upgradeCutoverRehearsalSha256 || '') || record.applicationImageId === record.operatorImageId ) { fail(`${architecture} milestone artifact record is incompatible`); @@ -319,7 +322,7 @@ function auditLocalAlphaMilestone(options) { 'artifacts', 'readme', ]) || - manifest.schemaVersion !== 4 || + manifest.schemaVersion !== 5 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || @@ -366,7 +369,9 @@ function auditLocalAlphaMilestone(options) { new Set(records.map((record) => record.upgradeReadinessSha256)).size !== ARCHITECTURES.length || new Set(records.map((record) => record.upgradeRehearsalSha256)).size !== - ARCHITECTURES.length + ARCHITECTURES.length || + new Set(records.map((record) => record.upgradeCutoverRehearsalSha256)) + .size !== ARCHITECTURES.length ) { fail('milestone architecture subjects are not distinct'); } @@ -393,7 +398,7 @@ function auditLocalAlphaMilestone(options) { } return Object.freeze({ schemaVersion: 1, - schema: 'qinglong/alpha-local-milestone-audit@v4', + schema: 'qinglong/alpha-local-milestone-audit@v5', sourceRevision: manifest.sourceRevision, version: manifest.version, variant: manifest.variant, @@ -434,7 +439,9 @@ function finalizeLocalAlphaMilestone(options) { artifacts.amd64.upgradeReadinessSha256 === artifacts.arm64.upgradeReadinessSha256 || artifacts.amd64.upgradeRehearsalSha256 === - artifacts.arm64.upgradeRehearsalSha256 + artifacts.arm64.upgradeRehearsalSha256 || + artifacts.amd64.upgradeCutoverRehearsalSha256 === + artifacts.arm64.upgradeCutoverRehearsalSha256 ) { fail('milestone architecture subjects must be distinct'); } @@ -447,7 +454,7 @@ function finalizeLocalAlphaMilestone(options) { path.join(normalized.outputRoot, FILES.readme), ); const manifest = { - schemaVersion: 4, + schemaVersion: 5, 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 599e41b0..7b870aeb 100644 --- a/scripts/ql3-local-alpha-trial-kit-bundle.cjs +++ b/scripts/ql3-local-alpha-trial-kit-bundle.cjs @@ -10,8 +10,8 @@ const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); -const SCHEMA = 'qinglong/alpha-local-trial-kit@v7'; -const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v5'; +const SCHEMA = 'qinglong/alpha-local-trial-kit@v8'; +const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v6'; const QUICKSTART_TEMPLATE = path.join( DEFAULT_ROOT, 'scripts/templates/ql3-local-alpha-quickstart.sh', @@ -24,6 +24,10 @@ const UPGRADE_REHEARSAL_TEMPLATE = path.join( DEFAULT_ROOT, 'scripts/templates/ql3-local-alpha-upgrade-rehearsal.sh', ); +const UPGRADE_CUTOVER_REHEARSAL_TEMPLATE = path.join( + DEFAULT_ROOT, + 'scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh', +); const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const VARIANTS = Object.freeze(['headless', 'console']); const ARCHIVE_MIN_BYTES = 1024; @@ -32,6 +36,7 @@ const MAX_README_BYTES = 512 * 1024; const MAX_QUICKSTART_BYTES = 256 * 1024; const MAX_UPGRADE_READINESS_BYTES = 256 * 1024; const MAX_UPGRADE_REHEARSAL_BYTES = 256 * 1024; +const MAX_UPGRADE_CUTOVER_REHEARSAL_BYTES = 512 * 1024; const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; const REVISION_PATTERN = /^[0-9a-f]{40}$/u; const FILES = Object.freeze({ @@ -41,6 +46,7 @@ const FILES = Object.freeze({ quickstart: 'quickstart.sh', upgradeReadiness: 'upgrade-readiness.sh', upgradeRehearsal: 'upgrade-rehearsal.sh', + upgradeCutoverRehearsal: 'upgrade-cutover-rehearsal.sh', readme: 'README.md', manifest: 'manifest.json', checksums: 'SHA256SUMS', @@ -58,11 +64,13 @@ const VERIFICATION = Object.freeze({ localApiCancellation: 'passed', legacyUpgradeReadiness: 'passed', legacyUpgradeStage: 'passed', + legacyUpgradeCutover: 'passed', }); function verificationGates(variant) { return Object.freeze({ ...VERIFICATION, + legacyUpgradeCutover: variant === 'headless' ? 'passed' : 'not_applicable', consoleLiveJourney: variant === 'console' ? 'passed' : 'not_applicable', firstAutomationJourney: variant === 'console' ? 'passed' : 'not_applicable', }); @@ -513,6 +521,35 @@ function renderUpgradeRehearsal(identity) { return rendered; } +function renderUpgradeCutoverRehearsal(identity) { + const template = fs.readFileSync( + assertCanonicalFile( + UPGRADE_CUTOVER_REHEARSAL_TEMPLATE, + MAX_UPGRADE_CUTOVER_REHEARSAL_BYTES, + 'upgrade cutover rehearsal template', + ), + 'utf8', + ); + const replacements = Object.freeze({ + '@@APPLICATION_IMAGE@@': identity.images.application.reference, + '@@APPLICATION_ID@@': identity.images.application.id, + '@@OPERATOR_IMAGE@@': identity.images.operator.reference, + '@@OPERATOR_ID@@': identity.images.operator.id, + '@@ARCHITECTURE@@': identity.architecture, + '@@SOURCE_REVISION@@': identity.sourceRevision, + '@@ARCHIVE@@': identity.archive.file, + '@@VARIANT@@': identity.variant, + }); + let rendered = template; + for (const [token, value] of Object.entries(replacements)) { + rendered = rendered.replaceAll(token, value); + } + if (/@@[A-Z_]+@@/u.test(rendered)) { + fail('upgrade cutover rehearsal template contains an unresolved token'); + } + return rendered; +} + function fileRecord(bundleRoot, name) { const filePath = path.join(bundleRoot, name); const stat = fs.lstatSync(filePath); @@ -698,8 +735,13 @@ function createLocalAlphaTrialKit(options, adapters = {}) { renderUpgradeRehearsal(manifestIdentity), 0o700, ); + writeExclusive( + path.join(normalized.outputRoot, FILES.upgradeCutoverRehearsal), + renderUpgradeCutoverRehearsal(manifestIdentity), + 0o700, + ); const manifest = { - schemaVersion: 8, + schemaVersion: 9, schema: SCHEMA, maturity: 'alpha_candidate_not_public_release', product: 'local', @@ -722,6 +764,10 @@ function createLocalAlphaTrialKit(options, adapters = {}) { normalized.outputRoot, FILES.upgradeRehearsal, ), + upgradeCutoverRehearsal: fileRecord( + normalized.outputRoot, + FILES.upgradeCutoverRehearsal, + ), readme: fileRecord(normalized.outputRoot, FILES.readme), verification: fileRecord( normalized.outputRoot, @@ -740,6 +786,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { FILES.quickstart, FILES.upgradeReadiness, FILES.upgradeRehearsal, + FILES.upgradeCutoverRehearsal, FILES.readme, FILES.manifest, ]; @@ -811,10 +858,11 @@ function auditLocalAlphaTrialKit(options) { 'quickstart', 'upgradeReadiness', 'upgradeRehearsal', + 'upgradeCutoverRehearsal', 'readme', 'verification', ]) || - manifest.schemaVersion !== 8 || + manifest.schemaVersion !== 9 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || @@ -895,6 +943,26 @@ function auditLocalAlphaTrialKit(options) { if (actualUpgradeRehearsal !== expectedUpgradeRehearsal) { fail('upgrade rehearsal differs from the canonical staging journey'); } + validateFileRecord( + manifest.upgradeCutoverRehearsal, + FILES.upgradeCutoverRehearsal, + bundleRoot, + ); + const expectedUpgradeCutoverRehearsal = + renderUpgradeCutoverRehearsal(manifest); + const actualUpgradeCutoverRehearsal = fs.readFileSync( + assertCanonicalFile( + path.join(bundleRoot, FILES.upgradeCutoverRehearsal), + MAX_UPGRADE_CUTOVER_REHEARSAL_BYTES, + 'upgrade cutover rehearsal', + ), + 'utf8', + ); + if (actualUpgradeCutoverRehearsal !== expectedUpgradeCutoverRehearsal) { + fail( + 'upgrade cutover rehearsal differs from the canonical cutover journey', + ); + } validateFileRecord(manifest.readme, FILES.readme, bundleRoot); validateOfflineSbom( readBoundedJson( @@ -933,6 +1001,7 @@ function auditLocalAlphaTrialKit(options) { FILES.quickstart, FILES.upgradeReadiness, FILES.upgradeRehearsal, + FILES.upgradeCutoverRehearsal, expectedArchive, ].sort(); const actualFiles = fs @@ -955,6 +1024,7 @@ function auditLocalAlphaTrialKit(options) { FILES.quickstart, FILES.upgradeReadiness, FILES.upgradeRehearsal, + FILES.upgradeCutoverRehearsal, FILES.readme, FILES.manifest, ]; @@ -968,7 +1038,7 @@ function auditLocalAlphaTrialKit(options) { } return Object.freeze({ schemaVersion: 1, - schema: 'qinglong/alpha-local-trial-kit-audit@v4', + schema: 'qinglong/alpha-local-trial-kit-audit@v5', sourceRevision: manifest.sourceRevision, version: manifest.version, architecture: manifest.architecture, @@ -979,6 +1049,7 @@ function auditLocalAlphaTrialKit(options) { quickstartSha256: manifest.quickstart.sha256, upgradeReadinessSha256: manifest.upgradeReadiness.sha256, upgradeRehearsalSha256: manifest.upgradeRehearsal.sha256, + upgradeCutoverRehearsalSha256: manifest.upgradeCutoverRehearsal.sha256, verificationSha256: manifest.verification.sha256, workflowRunId: verificationEvidence.workflow.runId, workflowRunAttempt: verificationEvidence.workflow.runAttempt, diff --git a/scripts/ql3-local-operator-image-audit.cjs b/scripts/ql3-local-operator-image-audit.cjs index be4537c0..44614135 100644 --- a/scripts/ql3-local-operator-image-audit.cjs +++ b/scripts/ql3-local-operator-image-audit.cjs @@ -13,6 +13,7 @@ const RUNTIME_NODE_IMAGE = 'node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436'; const RUNTIME_OS_PATCH = 'RUN apk add --no-cache --upgrade \\\n' + + ' docker-cli=29.5.2-r0 \\\n' + ' libcrypto3=3.5.8-r0 \\\n' + ' libssl3=3.5.8-r0'; const BUILD_DEPENDENCIES = Object.freeze({ @@ -194,6 +195,7 @@ function auditWorkflow(contents, findings) { 'ql3-local-operator.cdx.json', 'image-ref: qinglong3-local-operator:ci-${{ matrix.image_arch }}', '"${OPERATOR_IMAGE}" --version', + '--entrypoint /usr/bin/docker \\\n "${OPERATOR_IMAGE}" --version', 'scripts/ql3-local-alpha-trial-kit-live-contract.cjs', 'scripts/ql3-local-alpha-trial-kit-bundle.cjs', '--mode=record-verification', @@ -202,6 +204,9 @@ function auditWorkflow(contents, findings) { 'sh "${BUNDLE_ROOT}/quickstart.sh" \\\n edge "${QUICKSTART_ROOT}" "${QUICKSTART_CONTAINER}"', 'docker stop --time 30 "${QUICKSTART_CONTAINER}"', 'test -s "${QUICKSTART_ROOT}/qinglong3.sqlite"', + 'sh "${BUNDLE_ROOT}/upgrade-cutover-rehearsal.sh"', + '"status":"rollback_candidate"', + 'docker rm "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}"', '--application-sbom="${APPLICATION_SBOM}"', '--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"', '--verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json"', @@ -228,6 +233,7 @@ function auditWorkflow(contents, findings) { '--mode=create', '--mode=audit', '/quickstart.sh"', + '/upgrade-cutover-rehearsal.sh"', 'name: Upload the tested native Local Alpha trial kit', ]) { const index = contents.indexOf(value, cursor + 1); diff --git a/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh b/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh new file mode 100644 index 00000000..6c0b3d2c --- /dev/null +++ b/scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh @@ -0,0 +1,282 @@ +#!/bin/sh + +set -eu + +APPLICATION_IMAGE='@@APPLICATION_IMAGE@@' +APPLICATION_ID='@@APPLICATION_ID@@' +OPERATOR_IMAGE='@@OPERATOR_IMAGE@@' +OPERATOR_ID='@@OPERATOR_ID@@' +ARCHITECTURE='@@ARCHITECTURE@@' +SOURCE_REVISION='@@SOURCE_REVISION@@' +ARCHIVE='@@ARCHIVE@@' +VARIANT='@@VARIANT@@' + +fail() { + printf '%s\n' "QingLong Local Alpha cutover rehearsal failed: $*" >&2 + exit 1 +} + +usage() { + printf '%s\n' \ + 'usage: sh upgrade-cutover-rehearsal.sh edge|standalone /absolute/legacy-data-root /absolute/new/rehearsal-root [legacy-container-name] [target-container-name]' >&2 + exit 2 +} + +valid_digest() { + [ "${#1}" -eq 64 ] || return 1 + case "$1" in *[!0-9a-f]*) return 1 ;; *) return 0 ;; esac +} + +extract_digest() { + result_file=$1 + field=$2 + value=$(sed -n "s/^.*\"$field\":\"\([0-9a-f][0-9a-f]*\)\".*$/\1/p" "$result_file") + valid_digest "$value" || fail "$field is missing or invalid in $result_file" + printf '%s' "$value" +} + +container_name() { + case "$1" in ''|[_.-]*|*[!A-Za-z0-9_.-]*) return 1 ;; *) return 0 ;; esac +} + +[ "$#" -ge 5 ] && [ "$#" -le 7 ] || usage +[ "$VARIANT" = headless ] || fail 'cutover rehearsal is available only in the headless Trial Kit' +profile=$1 +legacy_root=$2 +rehearsal_root=$3 +sqlite_plan_digest=$4 +directory_plan_digest=$5 +legacy_name=${6:-ql3-alpha-upgrade-legacy} +target_name=${7:-ql3-alpha-upgrade-target} + +case "$profile" in + edge) memory=128m; pids=64 ;; + standalone) memory=256m; pids=256 ;; + *) usage ;; +esac +container_name "$legacy_name" || fail 'legacy container name is invalid' +container_name "$target_name" || fail 'target container name is invalid' +[ "$legacy_name" != "$target_name" ] || fail 'container names must be distinct' +valid_digest "$sqlite_plan_digest" || fail 'reviewed SQLite plan digest is invalid' +valid_digest "$directory_plan_digest" || fail 'reviewed data-directory plan digest is invalid' +[ "$(uname -s)" = Linux ] || fail 'cutover rehearsal requires a Linux Docker host' +for tool in docker sha256sum grep sed stat date; do + command -v "$tool" >/dev/null 2>&1 || fail "$tool is required" +done +[ -S /var/run/docker.sock ] || fail 'canonical Docker socket is unavailable' + +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 +application_identity=$(docker image inspect --format '{{.Id}}|{{.Architecture}}|{{.Config.User}}|{{index .Config.Labels "org.opencontainers.image.revision"}}' "$APPLICATION_IMAGE") +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") +[ "$application_identity" = "$APPLICATION_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION" ] || fail 'application image identity is incompatible' +[ "$operator_identity" = "$OPERATOR_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION|short-lived|none-by-default" ] || fail 'operator image identity is incompatible' +docker run --rm --read-only --network none --cap-drop ALL \ + --security-opt no-new-privileges --entrypoint /usr/bin/docker \ + "$OPERATOR_IMAGE" --version >/dev/null + +[ ! -e "$rehearsal_root" ] || fail 'rehearsal root must not already exist' +sh "$script_dir/upgrade-rehearsal.sh" \ + "$profile" "$legacy_root" "$rehearsal_root" \ + "$sqlite_plan_digest" "$directory_plan_digest" + +legacy_sha256=$(sha256sum "$legacy_root/db/database.sqlite" | sed 's/ .*//') +uid=$(id -u) +gid=$(id -g) +[ "$uid" -eq 0 ] && allow_root_service=true || allow_root_service=false +socket_gid=$(stat -c %g /var/run/docker.sock) +old_umask=$(umask) +umask 077 +for directory in owner-peppers owner-pepper-backup owner-delivery receipts artifacts plugin-staging plugin-activation service service/cutovers service/cutovers/alpha-upgrade-cutover data-directory/transformation; do + [ -d "$rehearsal_root/$directory" ] || mkdir -m 0700 "$rehearsal_root/$directory" +done + +run_operator() { + command_name=$1 + command_file=$2 + result_file=$3 + docker run --rm --read-only --user "$uid:$gid" --network none \ + --cap-drop ALL --security-opt no-new-privileges \ + --memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \ + --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ + --mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \ + "$OPERATOR_IMAGE" "$command_name" run \ + --command-file "$rehearsal_root/commands/$command_file" \ + >"$rehearsal_root/results/$result_file" +} + +run_deploy() { + subcommand=$1 + command_file=$2 + result_file=$3 + docker run --rm --read-only --user "$uid:$gid" \ + --group-add "$socket_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=/var/run/docker.sock,dst=/var/run/docker.sock \ + --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ + --mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \ + "$OPERATOR_IMAGE" deploy "$subcommand" \ + --command-file "$rehearsal_root/commands/$command_file" \ + >"$rehearsal_root/results/$result_file" +} + +run_deployment_offline() { + subcommand=$1 + command_file=$2 + result_file=$3 + docker run --rm --read-only --user "$uid:$gid" --network none \ + --cap-drop ALL --security-opt no-new-privileges \ + --memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \ + --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ + --mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \ + "$OPERATOR_IMAGE" deploy "$subcommand" \ + --command-file "$rehearsal_root/commands/$command_file" \ + >"$rehearsal_root/results/$result_file" +} + +cat >"$rehearsal_root/commands/setup.json" <"$rehearsal_root/commands/owner-provision.json" <"$rehearsal_root/commands/owner-challenge.json" <"$rehearsal_root/commands/owner-claim.json" <"$rehearsal_root/commands/owner-credential-install.json" <"$rehearsal_root/commands/data-directory-transform.json" <"$rehearsal_root/commands/data-directory-transform-verify.json" +chmod 0600 "$rehearsal_root/commands/data-directory-transform-verify.json" +run_operator adoption data-directory-transform-verify.json data-directory-transform-verify.result.json +grep -q '"status":"verified"' "$rehearsal_root/results/data-directory-transform-verify.result.json" || fail 'data-directory transformation verification failed' + +cleanup_required=1 +cleanup() { + if [ "$cleanup_required" -eq 1 ]; then + docker rm --force "$target_name" "$legacy_name" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT +trap 'exit 130' HUP INT TERM +legacy_id=$(docker run --detach --name "$legacy_name" --restart no \ + --read-only --user "$uid:$gid" --network none --cap-drop ALL \ + --security-opt no-new-privileges --memory 64m --memory-swap 64m \ + --pids-limit 16 --tmpfs /tmp:rw,nosuid,nodev,noexec,size=4m \ + --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ + --entrypoint /usr/local/bin/node "$OPERATOR_IMAGE" \ + -e 'setInterval(() => {}, 60000)') +[ "${#legacy_id}" -eq 64 ] || fail 'legacy container ID is invalid' +case "$legacy_id" in *[!0-9a-f]*) fail 'legacy container ID is invalid' ;; esac + +now_ms=$(($(date +%s) * 1000)) +cat >"$rehearsal_root/commands/legacy-stop.json" <"$rehearsal_root/commands/data-directory-apply.json" <"$rehearsal_root/commands/data-directory-apply-verify.json" +chmod 0600 "$rehearsal_root/commands/data-directory-apply-verify.json" +run_operator adoption data-directory-apply-verify.json data-directory-apply-verify.result.json +grep -q '"status":"verified"' "$rehearsal_root/results/data-directory-apply-verify.result.json" || fail 'authenticated data application verification failed' + +prepared_ms=$((now_ms + 1)) +cat >"$rehearsal_root/commands/adopted-prepare.json" <"$rehearsal_root/commands/adopted-verify.json" +chmod 0600 "$rehearsal_root/commands/adopted-verify.json" +run_deployment_offline adopted-verify adopted-verify.json adopted-verify.result.json +grep -q '"status":"verified"' "$rehearsal_root/results/adopted-verify.result.json" || fail 'adopted bundle verification failed' +bundle_digest=$(extract_digest "$rehearsal_root/results/adopted-verify.result.json" bundleDigest) +grep -Fq "\"reference\": \"$APPLICATION_IMAGE\"" "$rehearsal_root/service/docker-target.json" || fail 'target descriptor image reference drifted' +grep -Fq "\"imageId\": \"$APPLICATION_ID\"" "$rehearsal_root/service/docker-target.json" || fail 'target descriptor image ID drifted' + +target_id=$(docker create --name "$target_name" --restart no \ + --read-only --user "$uid:$gid" --network none --cap-drop ALL \ + --security-opt no-new-privileges --memory "$memory" --memory-swap "$memory" \ + --cpus 0.5 --pids-limit "$pids" \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m \ + --mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \ + --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \ + "$APPLICATION_IMAGE" --config "$rehearsal_root/local-application.json") +[ "${#target_id}" -eq 64 ] || fail 'target container ID is invalid' +case "$target_id" in *[!0-9a-f]*) fail 'target container ID is invalid' ;; esac + +start_ms=$((prepared_ms + 1)) +cat >"$rehearsal_root/commands/target-start.json" <"$rehearsal_root/commands/target-stop.json" +chmod 0600 "$rehearsal_root/commands/target-stop.json" +run_deploy cutover-target-stop target-stop.json target-stop.result.json +grep -q '"reconciliation":"rollback_candidate"' "$rehearsal_root/results/target-stop.result.json" || fail 'target stop did not produce a clean rollback candidate' +[ "$(sha256sum "$legacy_root/db/database.sqlite" | sed 's/ .*//')" = "$legacy_sha256" ] || fail 'legacy database changed during rehearsal' + +cat >"$rehearsal_root/cutover-summary.json" <"$rehearsal_root/commands/sqlite-stage.json" <"$rehearsal_root/commands/sqlite-verify.json" <"$rehearsal_root/results/$result_file" } @@ -129,7 +129,7 @@ grep -q '"status":"verified"' "$rehearsal_root/results/sqlite-verify.result.json sqlite_manifest_digest=$(extract_digest "$rehearsal_root/results/sqlite-verify.result.json" manifestDigest) cat >"$rehearsal_root/commands/sqlite-activation.json" <"$rehearsal_root/commands/data-directory-stage.json" <"$rehearsal_root/commands/data-directory-verify.json" < { const paths = fixture(t); const manifest = finalizeLocalAlphaMilestone(finalizeOptions(paths)); - assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v4'); + assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v5'); assert.match( manifest.artifacts.amd64.upgradeReadinessSha256, /^sha256:[0-9a-f]{64}$/, @@ -215,6 +215,10 @@ test('finalizes two exact native trial kits into one closed milestone index', (t manifest.artifacts.amd64.upgradeRehearsalSha256, /^sha256:[0-9a-f]{64}$/, ); + assert.match( + manifest.artifacts.amd64.upgradeCutoverRehearsalSha256, + /^sha256:[0-9a-f]{64}$/, + ); assert.equal(manifest.variant, 'headless'); assert.equal(manifest.sourceRevision, revision); assert.deepEqual(Object.keys(manifest.artifacts), ['amd64', 'arm64']); @@ -243,6 +247,7 @@ test('finalizes two exact native trial kits into one closed milestone index', (t assert.equal(report.workflowRunId, runId); assert.equal(report.workflowRunAttempt, runAttempt); assert.equal(report.variant, 'headless'); + assert.equal(report.schema, 'qinglong/alpha-local-milestone-audit@v5'); }); test('finalizes Console trial kits as a separately named milestone', (t) => { diff --git a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs index 9f05f78e..ae3c7962 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@v7'); + assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v8'); assert.equal(manifest.variant, 'headless'); assert.equal(manifest.sourceRevision, revision); assert.equal(manifest.architecture, 'arm64'); @@ -177,6 +177,10 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { assert.equal(manifest.quickstart.file, 'quickstart.sh'); assert.equal(manifest.upgradeReadiness.file, 'upgrade-readiness.sh'); assert.equal(manifest.upgradeRehearsal.file, 'upgrade-rehearsal.sh'); + assert.equal( + manifest.upgradeCutoverRehearsal.file, + 'upgrade-cutover-rehearsal.sh', + ); const quickstart = path.join(paths.outputRoot, 'quickstart.sh'); const syntax = spawnSync('sh', ['-n', quickstart], { encoding: 'utf8' }); assert.equal(syntax.status, 0, syntax.stderr); @@ -186,6 +190,15 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { /QingLong 3\.0 Local Alpha is active \(\$VARIANT, \$profile, \$ARCHITECTURE\)/, ); assert.match(quickstartContents, /qinglong3-local-application:test-arm64/); + const cutoverRehearsal = path.join( + paths.outputRoot, + 'upgrade-cutover-rehearsal.sh', + ); + const cutoverSyntax = spawnSync('sh', ['-n', cutoverRehearsal], { + encoding: 'utf8', + }); + assert.equal(cutoverSyntax.status, 0, cutoverSyntax.stderr); + assert.match(fs.readFileSync(cutoverRehearsal, 'utf8'), /VARIANT='headless'/); const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); assert.equal(report.compatible, true); assert.equal(report.sourceRevision, revision); @@ -199,6 +212,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-cutover-rehearsal.sh', 'upgrade-readiness.sh', 'upgrade-rehearsal.sh', 'verification-evidence.json', @@ -235,6 +249,7 @@ test('materializes a distinct loopback Console trial kit without widening the he assert.equal(verification.gates.ownerCredentialPresentation, 'passed'); assert.equal(verification.gates.legacyUpgradeReadiness, 'passed'); assert.equal(verification.gates.legacyUpgradeStage, 'passed'); + assert.equal(verification.gates.legacyUpgradeCutover, 'not_applicable'); const quickstartContents = fs.readFileSync( path.join(paths.outputRoot, 'quickstart.sh'), 'utf8', @@ -246,6 +261,12 @@ test('materializes a distinct loopback Console trial kit without widening the he assert.match(quickstartContents, /Console: http:\/\/127\.0\.0\.1:5700\//); assert.match(quickstartContents, /alpha-first-automation/); assert.match(quickstartContents, /do not expose the port on LAN/); + const cutoverContents = fs.readFileSync( + path.join(paths.outputRoot, 'upgrade-cutover-rehearsal.sh'), + 'utf8', + ); + assert.match(cutoverContents, /VARIANT='console'/); + assert.match(cutoverContents, /available only in the headless Trial Kit/); const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); assert.equal(report.compatible, true); assert.equal(report.variant, 'console'); @@ -257,6 +278,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-cutover-rehearsal.sh', 'upgrade-readiness.sh', 'upgrade-rehearsal.sh', 'verification-evidence.json', @@ -293,6 +315,7 @@ test('offline audit rejects archive, file-set, SBOM and verification mutation', 'quickstart', 'upgrade-readiness', 'upgrade-rehearsal', + 'upgrade-cutover-rehearsal', 'sbom', 'verification', ]) { @@ -324,6 +347,11 @@ test('offline audit rejects archive, file-set, SBOM and verification mutation', path.join(paths.outputRoot, 'upgrade-rehearsal.sh'), '# drift\n', ); + } else if (mutation === 'upgrade-cutover-rehearsal') { + fs.appendFileSync( + path.join(paths.outputRoot, 'upgrade-cutover-rehearsal.sh'), + '# drift\n', + ); } else if (mutation === 'sbom') { fs.copyFileSync( path.join(paths.outputRoot, 'qinglong3-local-application.cdx.json'), @@ -364,6 +392,7 @@ test('offline audit rejects a rehashed non-canonical quickstart', (t) => { 'quickstart.sh', 'upgrade-readiness.sh', 'upgrade-rehearsal.sh', + 'upgrade-cutover-rehearsal.sh', 'README.md', 'manifest.json', ]; @@ -618,7 +647,10 @@ exit 1 assert.equal(summary.sqlite.activationDigest, 'd'.repeat(64)); assert.equal(summary.dataDirectory.manifestDigest, 'e'.repeat(64)); const calls = fs.readFileSync(dockerLog, 'utf8'); - assert.match(calls, /dst=\/var\/lib\/qinglong2,readonly/); + assert.match( + calls, + new RegExp(`dst=${legacyRoot.replaceAll('/', '\\/')},readonly`), + ); assert.match(calls, /--network none/); assert.match(calls, /--memory 128m --memory-swap 128m/); assert.doesNotMatch(calls, /cutover|target-start|legacy-rollback/);