From 8580b25ecc700b4712d27866d860cfc0a9c07fbc Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 1 Sep 2026 23:20:13 +0800 Subject: [PATCH] feat(ql3): rehearse reviewed automation rollback --- .github/workflows/ql3-ci.yml | 51 ++ docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 + ...reconciliation-application-and-rollback.md | 52 ++ docs/adr/README.md | 3 +- docs/operations/ql3-alpha-candidate.md | 8 +- docs/operations/ql3-local-alpha-trial-kit.md | 48 +- .../application/automation/applyContract.ts | 23 +- .../automation/applyCoordinator.ts | 2 +- .../automation/decisionCoordinator.ts | 2 +- .../reconciliation/completion/contract.ts | 21 +- .../reconciliation/review/completion.ts | 11 +- ...nciliationAutomationApplyContract.test.cjs | 51 ++ .../reconciliationCapturePrepare.test.cjs | 28 +- scripts/ql3-alpha-stage-index.cjs | 2 +- scripts/ql3-local-alpha-milestone.cjs | 21 +- ...-alpha-reconciliation-decision-fixture.cjs | 403 +++++++++++++++ scripts/ql3-local-alpha-trial-kit-bundle.cjs | 84 ++- scripts/ql3-local-operator-image-audit.cjs | 21 + ...l3-local-alpha-reconciliation-rehearsal.sh | 479 ++++++++++++++++++ .../ql3-local-alpha-upgrade-readiness.sh | 29 +- test/back/ql3AlphaStageIndex.test.cjs | 6 +- test/back/ql3LocalAlphaMilestone.test.cjs | 8 +- ...lphaReconciliationDecisionFixture.test.cjs | 357 +++++++++++++ .../back/ql3LocalAlphaTrialKitBundle.test.cjs | 139 ++++- 24 files changed, 1794 insertions(+), 57 deletions(-) create mode 100644 docs/adr/ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md create mode 100644 packages/ql3-local-owner-cli/test/reconciliationAutomationApplyContract.test.cjs create mode 100644 scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs create mode 100644 scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh create mode 100644 test/back/ql3LocalAlphaReconciliationDecisionFixture.test.cjs diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index a2d0c243..60834169 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -786,6 +786,10 @@ jobs: UPGRADE_REHEARSAL_ROOT="${RUNNER_TEMP}/ql3-alpha-upgrade-cutover-rehearsal-${{ matrix.image_arch }}" RECONCILIATION_REHEARSAL_ROOT="${RUNNER_TEMP}/ql3-alpha-upgrade-reconciliation-rehearsal-${{ matrix.image_arch }}" RECONCILIATION_CAPTURE_ROOT="${RUNNER_TEMP}/ql3-alpha-upgrade-reconciliation-capture-${{ matrix.image_arch }}" + RECONCILIATION_FLOW_ROOT="${RUNNER_TEMP}/ql3-alpha-reconciliation-flow-${{ matrix.image_arch }}" + RECONCILIATION_DECISION_ROOT="${RUNNER_TEMP}/ql3-alpha-reconciliation-decisions-${{ matrix.image_arch }}" + RECONCILIATION_REVIEW_DECISION_ROOT="${RECONCILIATION_DECISION_ROOT}/review" + RECONCILIATION_AUTOMATION_DECISION_ROOT="${RECONCILIATION_DECISION_ROOT}/automation" LEGACY_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-upgrade-legacy-${{ matrix.image_arch }}" TARGET_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-upgrade-target-${{ matrix.image_arch }}" RECONCILIATION_LEGACY_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-reconciliation-legacy-${{ matrix.image_arch }}" @@ -828,6 +832,53 @@ jobs: test -s "${RECONCILIATION_CAPTURE_ROOT}/019f8680-143d-4000-8000-000000000161/manifest.json" test -s "${RECONCILIATION_CAPTURE_ROOT}/019f8680-143d-4000-8000-000000000161/receipt.json" test -d "${RECONCILIATION_CAPTURE_ROOT}/019f8680-143d-4000-8000-000000000161/assets" + install -d -m 0700 \ + "${RECONCILIATION_DECISION_ROOT}" \ + "${RECONCILIATION_REVIEW_DECISION_ROOT}" \ + "${RECONCILIATION_AUTOMATION_DECISION_ROOT}" + sh "${BUNDLE_ROOT}/reconciliation-rehearsal.sh" \ + prepare edge "${RECONCILIATION_REHEARSAL_ROOT}" \ + "${RECONCILIATION_CAPTURE_ROOT}" "${RECONCILIATION_FLOW_ROOT}" \ + Asia/Shanghai + grep -q '"status":"operator_decision_required"' \ + "${RECONCILIATION_FLOW_ROOT}/summary.json" + grep -q '"automaticDecision":"not_authorized"' \ + "${RECONCILIATION_FLOW_ROOT}/summary.json" + node scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs \ + --mode=review \ + --reconciliation-root="${RECONCILIATION_FLOW_ROOT}" \ + --output="${RECONCILIATION_REVIEW_DECISION_ROOT}/review.ndjson" + sh "${BUNDLE_ROOT}/reconciliation-rehearsal.sh" \ + review edge "${RECONCILIATION_REHEARSAL_ROOT}" \ + "${RECONCILIATION_CAPTURE_ROOT}" "${RECONCILIATION_FLOW_ROOT}" \ + "${RECONCILIATION_REVIEW_DECISION_ROOT}/review.ndjson" + grep -q '"status":"automation_decision_required"' \ + "${RECONCILIATION_FLOW_ROOT}/summary.json" + grep -q '"decisionAuthority":"authenticated_user"' \ + "${RECONCILIATION_FLOW_ROOT}/summary.json" + grep -q '"automaticRowDecision":"not_authorized"' \ + "${RECONCILIATION_FLOW_ROOT}/summary.json" + node scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs \ + --mode=automation \ + --reconciliation-root="${RECONCILIATION_FLOW_ROOT}" \ + --output="${RECONCILIATION_AUTOMATION_DECISION_ROOT}/automation.ndjson" + sh "${BUNDLE_ROOT}/reconciliation-rehearsal.sh" \ + apply-rollback edge "${RECONCILIATION_REHEARSAL_ROOT}" \ + "${RECONCILIATION_CAPTURE_ROOT}" "${RECONCILIATION_FLOW_ROOT}" \ + "${RECONCILIATION_AUTOMATION_DECISION_ROOT}/automation.ndjson" \ + "${LEGACY_READINESS_ROOT}" + grep -q '"status":"reconciliation_automation_rolled_back"' \ + "${RECONCILIATION_FLOW_ROOT}/summary.json" + grep -q '"completion":"not_attempted"' \ + "${RECONCILIATION_FLOW_ROOT}/summary.json" + grep -q '"targetRestart":"not_attempted"' \ + "${RECONCILIATION_FLOW_ROOT}/summary.json" + grep -q '"legacyRestart":"not_attempted"' \ + "${RECONCILIATION_FLOW_ROOT}/summary.json" + grep -q '"state":"reconciliation_automation_applied"' \ + "${RECONCILIATION_FLOW_ROOT}/results/automation-apply-verify.result.json" + grep -q '"state":"reconciliation_automation_rolled_back"' \ + "${RECONCILIATION_FLOW_ROOT}/results/automation-rollback-verify.result.json" docker rm "${RECONCILIATION_TARGET_CONTAINER}" "${RECONCILIATION_LEGACY_CONTAINER}" test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-wal" test ! -e "${LEGACY_READINESS_ROOT}/db/database.sqlite-journal" diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 95b00ad5..f6914a8f 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-426c2/ADR-0527(源码与本地 exact arm64 演练已闭合,双架构阶段实物待交付):downloadable Trial Kit 新增三阶段 canonical `reconciliation-rehearsal.sh`。`prepare` 从 D-426c1 stopped capture 建立 bounded plan/diagnostics/strong-auth review prepare 后停在 `operator_decision_required`;`review` 只消费 owner-private 外部 NDJSON,完成 authorization commit/verify、application plan 与 Automation plan 后停在 `automation_decision_required`;`apply-rollback` 再消费独立外部 Automation row NDJSON,只应用一个无冲突 Automation 行、验证正式 Task/Trigger 投影并显式回滚,终态固定 `reconciliation_automation_rolled_back`。交付脚本永不生成 decision;CI fixture 不进入 bundle,Legacy Run History 固定 `manual_external`。decision 父目录必须 current-UID `0700`、恰好一个 canonical file、与所有 authority roots 不重叠,并整体只读挂载;60 秒 authorization 不得晚于 strong principal,authentication database 必须在异步 confirm 完成后才关闭。target SQLite 必须位于 deployment root 下且避开 reconciliation sibling roots;apply/rollback 额外只读挂载 exact Legacy root,运行在 128 MiB/0.5 CPU/32 PID、无网络、只读 rootfs、drop-all/no-new-privileges 的短生命周期 Operator 中。带时间 command 和成功 result 支持中断后的 exact replay,不得静默重写。Trial Kit/verification/auditor 升为 `@v10/@v8/@v7`、manifest schemaVersion 11,milestone 升为 `@v6`/schemaVersion 6,并增加 required `legacyUpgradeReconciliationAutomationRollback=passed` 与双架构 reconciliation script digest。最终本地 arm64 exact bundle 使用 Application `sha256:eec404d24b5c101871e000caac902d3866e5fe3f0e6dcef31366cb526ef32f80`、无源码覆盖 Operator `sha256:10f75f12d185e5796dcc4a230b6684c074bd9a6e6156ee1110fff1c2d8dd3390`,offline audit 返回 `compatible=true`;全新 2.x fixture 贯通 readiness→stage→cutover→capture→437 条外部 review decision→1 条 Automation decision→apply/verify→rollback/verify,实际采用 1 Task/1 Trigger 后恢复应用前快照,未尝试 Secret/Config、Run History、completion 或任一重启。Docker Desktop 的只读 bind mount UID 瞬时漂移仅通过同一 inspect 一次有界重试收敛,持续错误仍 fail-closed,不放宽 Linux owner proof。该切片不新增 package、production dependency、daemon/listener/timer/watcher/连接或稳态资源;低配路由设备默认 headless 不变,Cluster 不复用 Local SQLite/POSIX/Docker authority,2.x 老面板仍需独立 API/认证/领域 adapter,不能零改直连。 + - D-426c1/ADR-0526(exact Console 双架构阶段实物已交付):downloadable `upgrade-cutover-rehearsal.sh` 保留既有 clean rollback 默认路径,并新增显式 `--capture-after-write <独立私有根>`。同一 reviewed stage/Owner/apply/target-active 链先通过正式 `task.put` 提交固定、无网络/Secret 的业务 Task,再要求 target stop 返回 `reconciliation_required`;随后以现有短生命周期 Operator 串行执行 reconciliation capture prepare/commit/verify,把 legacy、target、recovery、Application config、activation 与 exact stopped head/record 密封到外置 root。对 v4 adopted target,capture proof 必须以 stopped evidence 中的 exact baseline digest 重新加载并核对 durable post-apply baseline,不允许丢失 baseline 维度后再比较。成功 summary 固定 `reconciliation_captured`、`legacySource=unchanged`、`target=stopped`、`rollback=not_authorized`、`next=review_required`,不自动回退或应用。Trial Kit/verification/auditor 升为 `@v9/@v7/@v6`、manifest schemaVersion 10,并增加 required `legacyUpgradeReconciliationCapture=passed`;原生 artifact job 必须在保留 clean rollback 演练的同时,用独立目录/容器第二次实跑写后 capture 并验证 manifest/receipt/assets 后才能上传。首次 artifact run `33467541901` 在真实 arm64 上走到 `reconciliation_required` 后暴露 adopted baseline 重算缺口,finalizer 正确未形成;修复提交 `0235973c9b54a2f22de09b6487ea9f184f0b8bfd` 的普通主 CI [run 33469372499](https://github.com/whyour/qinglong/actions/runs/33469372499) 为 41 success/3 expected skip/0 fail,Kubernetes [run 33469372500](https://github.com/whyour/qinglong/actions/runs/33469372500) 成功,显式 Console [run 33469435652](https://github.com/whyour/qinglong/actions/runs/33469435652) 为 42 success/2 scope skip/0 fail。该 run 交付 amd64/arm64/milestone artifact `9786301280`/`9786374284`/`9786520389`,大小 `226683392`/`222083072`/`6489` bytes,保留至 2026-10-01;两个原生 job 在上传前离线审计 exact bundle,finalizer 再下载并复核二者,本机重新下载的 milestone v5 通过 `SHA256SUMS` 与 auditor,返回 `compatible=true` 并绑定 `3.0.0-alpha.2`、`console`、同一 source/workflow/run/attempt。内部 Docker archive digest 为 amd64 `sha256:3c9f7dac623bacd4b88b933a3668cfe74526a9fc4fe73823f821535a11aba3f4`、arm64 `sha256:68ee76d6f0a20f876da4ccfef96bb58f08aa965969d44ed587f3df37536529db`。该切片不修改核心 classifier、不新增 package/依赖/daemon/listener/timer/watcher/连接或稳态资源;低配路由器默认 headless 不变。它证明的是 active target 数据权威经 Owner 产品入口发生写入后的 fail-closed capture,不冒充普通 Local API listener、2.x 老面板、自动 reconciliation、生产升级或 Public Release。 - D-426b2c/ADR-0525(exact Console 双架构阶段实物已交付):Console adopted target 不再借用 fresh HTTP journey,也不以普通 Local API 启动破坏 clean rollback。`ql3-local-api` 新增显式 `--cutover-probe --config `:严格验证外层 loopback/deployment/Owner 配置后委托既有 Application 只读 probe,不绑定 listener、不读取 credential/pepper、不激活 recovery、scheduler、execution、plugin recovery 或产品管理面。Owner target command 可选绑定 Local API 宿主/容器配置路径,target evidence 同时摘要并校验外层 API、内层 Application、严格不同的 target path、exact read-only mounts 与 `['--cutover-probe','--config',expectedApiPath]`;省略该字段时 headless command/journal digest 不变。Trial Kit cutover summary 升为 v2,绑定 variant 与 `local-application|local-api` entrypoint;原生 workflow 对 headless/Console 均要求 `legacyUpgradeCutover=passed`,同时保留真实 Console listener/API/credential/Task journey 作为独立门。没有新增 workspace package、依赖、daemon、sidecar、timer、watcher、连接池或稳态资源;默认低配路由设备仍选择 headless,Cluster 不复用 Local POSIX/SQLite/Docker proof。Local API 80/80、Owner CLI 314 total/307 pass/7 conditional skip/0 fail、Trial Kit 12/12、Application 56 total/51 pass/5 conditional skip/0 fail,package/Cluster/Edge/image 审计 compatible。提交 `229c3cb4e826866a0c7c4d81cb5e52cdc3975eec` 的普通主 CI run `33462165722` 与 Kubernetes live run `33462165834` 成功;显式 Local Console artifact run `33463415938` 交付 amd64/arm64/milestone(artifact `9784212784`/`9784111987`/`9784288018`),三个重新下载的离线 auditor 均为 `compatible=true`,保留至 2026-10-01。正常 `ql3-local-api --config` 提供现有 3.0 Console,`--cutover-probe` 仅用于无写入的升级证据;这不声明 2.x 老面板 API 零改动兼容。 diff --git a/docs/adr/ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md b/docs/adr/ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md new file mode 100644 index 00000000..f1dc975e --- /dev/null +++ b/docs/adr/ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md @@ -0,0 +1,52 @@ +# ADR-0527:受审核 Automation Reconciliation 应用与显式回滚 + +- 状态:Accepted(源码与本地 exact arm64 演练已闭合;双架构阶段实物待交付) +- 日期:2026-09-01 +- 决策:D-426c2 +- 关联:ADR-0484、ADR-0485、ADR-0486、ADR-0487、ADR-0526 + +## 上下文 + +ADR-0526 已把 active target 的真实产品写入密封为独立 reconciliation capture,但 downloadable Trial Kit 仍停在 `review_required`。底层 plan、强认证 review、跨域 application plan、Automation row plan、apply 与 rollback 已存在;缺少的是一条部署者可执行、资源有界、不会自行替人决策的 exact artifact 链。 + +本切片只闭合一个最小可恢复事实:操作者审核完整计划并在 owner-private 文件中给出外部决定后,短生命周期 Operator 应用一个无冲突 Automation 行,验证正式 Task/Trigger 投影,再显式回滚到应用前 SQLite 快照。Secret/Config、Run History、completion 与服务重启继续留给后续独立决策。 + +## 决策 + +1. Trial Kit 新增 canonical `reconciliation-rehearsal.sh`,分为 `prepare`、`review`、`apply-rollback` 三个显式阶段。每个阶段只接受前一阶段的 exact sealed evidence,不跨阶段猜测路径、digest、时间或决定。 +2. `prepare` 只从 ADR-0526 的 stopped capture 建立 bounded reconciliation plan、诊断页与强认证 review prepare,并终止于 `operator_decision_required`。`review` 只消费外部 review NDJSON,完成 authorization commit/verify、application prepare/commit/verify 与 Automation plan/verify,并终止于 `automation_decision_required`。`apply-rollback` 只消费外部 Automation row NDJSON,完成 decision commit、apply/verify、rollback/verify,终态为 `reconciliation_automation_rolled_back`。 +3. 交付脚本永不生成 review 或 Automation decision。仓库中的 `ql3-local-alpha-reconciliation-decision-fixture.cjs` 只为 CI 合成数据生成冻结决定,不进入 Trial Kit、manifest、checksum 或用户下载包。Legacy Run History 固定为 `manual_external`,不能为了让演练通过而丢弃。 +4. 每份外部 decision 必须是 owner-private canonical regular file:父目录为当前 UID 拥有的 `0700` 目录、目录中恰好只有该文件,文件不位于 deployment/reconciliation/capture 权威根内;容器只读挂载整个父目录。review 与 Automation decision 使用不同父目录,不能复用或替换。 +5. review authorization 的有效期为 60 秒且不得晚于 strong principal;commit/verify 与 application/Automation 操作必须消费同一授权谱系。Automation decision commit 必须 `await` 完整确认后才能关闭 authentication database,避免异步确认读取已关闭 authority。 +6. Automation target SQLite 必须位于 canonical deployment root 下,同时与 plan、capture、review、application、Automation 和 decision sibling roots 双向不重叠。apply/verify/rollback 使用短命名空间 `reconcile_automation_apply`,满足既有 32 字节授权上限。 +7. `apply-rollback` 显式接收原始 Legacy root,并只读挂载它以复算 stopped proof;target deployment root 是唯一业务可写根。Operator 继续使用只读 rootfs、`network=none`、drop-all capabilities、no-new-privileges、128 MiB memory/swap、0.5 CPU、32 PID 与 8 MiB noexec tmpfs。 +8. 所有带时间的 command 只在首次阶段创建;中断后必须复用原文件和原时间。容器输出先写 PID-scoped 临时结果,命令成功后才原子替换正式 result。失败重跑不能覆盖已存在的 authorization、intent、decision 或成功 result。 +9. 成功 summary 只公开低敏 lineage:review/Automation/decision identity 与 digest、apply digest、采用的 Task/Trigger 数量、rollback head digest,以及 `target=restored_to_pre_automation_snapshot`。它固定 `completion=not_attempted`、`targetRestart=not_attempted`、`legacyRestart=not_attempted`。 +10. Trial Kit、verification、offline audit 分别升级为 `@v10/@v8/@v7`,manifest schemaVersion 为 11;Local milestone 升级为 `@v6`、schemaVersion 6,并绑定双架构 `upgradeReconciliationRehearsalSha256`。required gate 新增 `legacyUpgradeReconciliationAutomationRollback=passed`,旧 bundle/milestone 不会被新 auditor 静默接受。 + +## Profile 与部署边界 + +- Edge/低配路由设备:默认 headless Application 和稳态资源不变。D-426c2 只启动串行短生命周期 Operator;不会新增 package、依赖、daemon、listener、timer、watcher、连接池或常驻缓存。 +- Standalone:复用相同命令与安全边界,仅沿用既有较大 reconciliation 文件预算;本 ADR 不提高 Operator 资源上限。 +- Console:同一 reconciliation script 可存在于 Console bundle,但不会启动 Web listener;3.0 Console 与 2.x 面板仍是不同 API/认证/领域协议,不能据此声明零改动兼容。 +- Cluster:不复用 Local SQLite/POSIX/Docker 证据。PostgreSQL HA、Kubernetes lease、Worker 与 Cluster reconciliation 不受本 ADR 授权。 + +## 被拒绝的方案 + +- 在脚本内自动选择 `prefer_target` 或批量 adopt:会把 CI 策略伪装成人类审核,拒绝。 +- 一次命令从 capture 直接推进 completion/restart:跨越 Secret/Config、Run History 与服务权威,拒绝。 +- 只验证目标行数、不执行显式 rollback:无法证明应用前快照、backup digest 与恢复 head 闭合,拒绝。 +- 把 decision 单文件直接 bind mount,或允许父目录中存在额外文件:会扩大路径替换与同目录注入面,拒绝。 +- 为 Docker Desktop 放宽 POSIX owner 校验:会削弱原生 Linux 的 owner proof,拒绝。只读 readiness 可对瞬时 mount UID 漂移执行同一命令的一次有界重试;持续错误仍失败关闭。 + +## 验证与交付状态 + +- GitNexus 对修改的 TypeScript 符号均为 LOW:decision authorization 2 个上游、apply contract 8 个上游、apply/rollback authentication 4 个上游,均无 execution flow;shell 模板未被索引,按静态引用、`sh -n`、功能测试与 exact Docker gate 约束。 +- 聚焦回归覆盖 strong-principal expiry、authentication database close fence、target path containment、短 authorization namespace、decision file private parent、命令/结果精确重放,以及 CI fixture 不进入 bundle。 +- 本机完整 backend 回归为 `1667 total / 1665 pass / 0 fail / 2 conditional skip`;18 个 QL3 package clean build、package boundary、cluster dependency 与 image audit 均通过。PostgreSQL 18.6 arm64 HA Docker 门进一步完成 timeline `1→2` promotion 与 147 项检查,私有报告摘要为 `sha256:68e2f60b962ac62cee3c70bf759fccb6f8080541264427a4ea66bf8f586707db`。 +- 本机 exact arm64 headless Trial Kit 使用最终 Application `sha256:eec404d24b5c101871e000caac902d3866e5fe3f0e6dcef31366cb526ef32f80` 与无源码覆盖的 Operator `sha256:10f75f12d185e5796dcc4a230b6684c074bd9a6e6156ee1110fff1c2d8dd3390`,离线 audit v7 返回 `compatible=true`。全新 2.x fixture 已贯通 readiness、stage、cutover、post-write capture、437 条外部 review 决定、1 条外部 Automation 决定、apply/verify 与 explicit rollback/verify;最终实际采用 1 个 Task、1 个 Trigger并返回 `reconciliation_automation_rolled_back`,且未尝试 completion、restart、Secret/Config 或 Run History mutation。 +- 该本机证据是工程闭合,不冒充 GitHub workflow provenance 或双架构阶段实物。只有普通 CI、Kubernetes/Cluster 门与显式 amd64/arm64 artifact finalizer 全部成功后,D-426c2 才升级为可下载阶段产物。 + +## 后续 + +D-426c3 再独立处理 Secret/Config、Run History、跨域 completion 与 target/Legacy restart 选择。2.x 面板复用走单独 UI adapter RFC:保留现有视觉组件,替换 `/api/*` 数据层、JWT/session 假设与 Crontab/Env/Log 领域映射;不得把 D-426c2 当作 2.x API compatibility layer。 diff --git a/docs/adr/README.md b/docs/adr/README.md index d23c67a2..07c455aa 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -529,7 +529,8 @@ | [ADR-0523](./ADR-0523-post-apply-adopted-target-baseline.md) | Apply 后的 Adopted Target 启动前基线 | Accepted(D-426b2a 基线已由 D-426b2b 双架构实物闭合) | | [ADR-0524](./ADR-0524-exact-headless-upgrade-cutover-rehearsal.md) | Exact Headless 升级切换链演练 | Accepted(D-426b2b 双架构阶段实物已交付) | | [ADR-0525](./ADR-0525-console-adopted-target-entry-evidence.md) | Console Adopted Target 入口证据 | Accepted(exact Console 双架构阶段实物已交付并完成离线审计) | -| [ADR-0526](./ADR-0526-exact-post-write-reconciliation-capture.md) | Exact 写后 Reconciliation Capture | Accepted(源码与 artifact gate 已闭合;双架构实物待交付) | +| [ADR-0526](./ADR-0526-exact-post-write-reconciliation-capture.md) | Exact 写后 Reconciliation Capture | Accepted(同源 exact Console 双架构阶段实物已交付) | +| [ADR-0527](./ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md) | 受审核 Automation Reconciliation 应用与显式回滚 | Accepted(源码与本地 exact arm64 演练已闭合;双架构阶段实物待交付) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index fd7da55d..77301d42 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -35,6 +35,7 @@ | D-426b2b Exact headless 切换链 | 同源 v8 Trial Kit 已交付 amd64/arm64 headless 阶段实物;exact 上传包完成 readiness、reviewed stage、Owner 强认证 transform/apply、真实 legacy stop、只读 target probe start/stop 与 clean `rollback_candidate`,milestone v5 和三个离线 auditor 均闭合 | 仅授权 fresh/隔离数据演练;不停止用户真实 2.x、不执行 Legacy restart、写后 reconciliation 或生产 cutover;仍不是 Public Release | | D-426b2c Console adopted entry | 同源 exact amd64/arm64 Console Trial Kit 与 milestone 已交付;Local API cutover probe 不启动 listener/credential/mutation,controller 绑定双层配置、exact command/mount,原生 CI 完整演练且三个下载产物离线审计通过 | 仅授权 fresh/隔离数据演练;正常 Console 启动与只读 cutover probe 是不同模式;不承诺 2.x 老面板 API 零改动兼容、真实生产停机或写后回退 | | D-426c1 写后 reconciliation capture | 同源 Console Trial Kit v9 的 amd64/arm64 实物与 milestone 已交付;active target 数据权威经 Owner `task.put` 发生业务写入后分类为 `reconciliation_required`,并在独立私有 root 完成 capture prepare/commit/verify;双架构原生审计和 milestone v5 均闭合 | 停在 `review_required`,不自动 plan/review/apply/rollback/restart;不证明普通 Local API 流量接管、用户真实 2.x 升级或 Public Release | +| D-426c2 受审核 Automation apply/rollback | Trial Kit v10 源码与本地 exact arm64 工程演练已闭合;三阶段脚本只消费外部 owner-private decision,贯通 capture→437 条 review decision→1 条 Automation decision→1 Task/1 Trigger apply/verify→explicit rollback/verify,保持 128 MiB/0.5 CPU/32 PID、无网络、只读 rootfs | 双架构 GitHub artifact/milestone v6 尚待本提交远程 CI;不应用 Secret/Config、Run History,不执行 completion/restart,不是 2.x 老面板 compatibility layer 或生产升级包 | 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 创建能力已进入新的阶段实物,而不再只是候选源码。 @@ -54,6 +55,8 @@ D-426b2c 已从“源码候选”升级为新的可交付 Console 阶段实物 D-426c1 已从失败关闭的源码候选升级为新的可交付 Console 阶段实物。首次 [run 33467541901](https://github.com/whyour/qinglong/actions/runs/33467541901) 在真实 arm64 上证明 `task.put` 与 `reconciliation_required` 后暴露 adopted baseline 重算缺口,finalizer 正确未形成;修复提交 `0235973c9b54a2f22de09b6487ea9f184f0b8bfd` 的普通主 CI [run 33469372499](https://github.com/whyour/qinglong/actions/runs/33469372499) 为 41 success/3 expected skip/0 fail,同源 Kubernetes [run 33469372500](https://github.com/whyour/qinglong/actions/runs/33469372500) 成功,显式 Console [run 33469435652](https://github.com/whyour/qinglong/actions/runs/33469435652) 为 42 success/2 scope skip/0 fail。amd64/arm64/milestone artifact ID 为 `9786301280`/`9786374284`/`9786520389`,大小为 `226683392`/`222083072`/`6489` bytes,GitHub ZIP digest 为 `sha256:49e921cb251da8cec3dcee5308174dd8aca89ffa18a3df6e6c7d0f43095c6f70`、`sha256:4ff7d3fff5af16ca55350d0dbf14c35d8ddcdeecb7d395ba017a14081949e0e8`、`sha256:d69c769117268fe8559043bb62999549d215c12f353e34c51d32d22ce319dd4c`,保留至 2026-10-01。两个原生 job 在上传前各自执行 exact bundle offline auditor;finalizer 再下载并复核二者。本机重新下载的 milestone 通过 checksum,auditor 返回 `compatible=true`,内部 Docker archive digest 为 amd64 `sha256:3c9f7dac623bacd4b88b933a3668cfe74526a9fc4fe73823f821535a11aba3f4`、arm64 `sha256:68ee76d6f0a20f876da4ccfef96bb58f08aa965969d44ed587f3df37536529db`。该产物已能 fresh 运行内置 3.0 Console,并证明隔离切换后的写入必须进入 capture/review;它仍不兼容 2.x 老面板协议、不自动应用 reconciliation,也不是生产升级包。 +D-426c2 当前已经形成可重建的工程候选,但尚未冒充远程阶段实物。本地 exact arm64 headless bundle 使用 Application `sha256:eec404d24b5c101871e000caac902d3866e5fe3f0e6dcef31366cb526ef32f80` 与无源码覆盖 Operator `sha256:10f75f12d185e5796dcc4a230b6684c074bd9a6e6156ee1110fff1c2d8dd3390`,offline auditor v7 返回 `compatible=true`。全新 2.x fixture 已走通 readiness、reviewed stage、exact cutover、post-write capture、强认证 review、Automation apply/verify 与 explicit rollback/verify,终态 `reconciliation_automation_rolled_back`,采用 1 Task/1 Trigger 后恢复应用前快照;completion、target/Legacy restart、Secret/Config 和 Run History mutation 均为 `not_attempted`。本机完整 backend 为 `1667 total / 1665 pass / 0 fail / 2 conditional skip`;PostgreSQL 18.6 arm64 HA Docker 门以 timeline `1→2` 和 147 项检查通过。远程普通 CI、Kubernetes 门、原生 amd64/arm64 artifact 和 milestone v6 finalizer 全部通过后,才能把这里的“工程候选”升级为可下载 D-426c2 阶段产物。 + 默认低配 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。 @@ -118,16 +121,17 @@ 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@v9`、`schemaVersion=10` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/upgradeReadiness/upgradeRehearsal/upgradeCutoverRehearsal/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; +- schema 为 `qinglong/alpha-local-trial-kit@v10`、`schemaVersion=11` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/upgradeReadiness/upgradeRehearsal/upgradeCutoverRehearsal/upgradeReconciliationRehearsal/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; +- canonical `reconciliation-rehearsal.sh`,分阶段消费 exact capture、外部 review decision 与外部 Automation decision,在同一受限 Operator envelope 中证明 apply/verify 与 explicit rollback/verify;脚本和 bundle 均不携带 CI decision fixture; - `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@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。 +Local milestone 是 `qinglong/alpha-local-milestone@v6` 三文件闭包,绑定一个 variant 的双架构 Trial Kit,并直接记录两个架构的 `upgradeReadinessSha256`、`upgradeRehearsalSha256`、`upgradeCutoverRehearsalSha256` 与 `upgradeReconciliationRehearsalSha256`。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-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index ade41d9a..70c92a4c 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@v9`,`schemaVersion=10`; +- `schema` 为 `qinglong/alpha-local-trial-kit@v10`,`schemaVersion=11`; - `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 试运行 -v6 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX +v10 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX shell、`sha256sum` 和已启动的 Docker。必须选择一个尚不存在、与 2.x/生产数据完全 隔离的绝对路径: @@ -86,7 +86,7 @@ headless 不创建示例 Task,因此低配默认档没有示例数据或稳态 ## 只读检查现有 2.x 升级就绪度 -v6 bundle 还包含 canonical `upgrade-readiness.sh`。它让现有部署用户先回答“这份 2.x SQLite 和完整 data directory 是否能形成可审核 +v10 bundle 还包含 canonical `upgrade-readiness.sh`。它让现有部署用户先回答“这份 2.x SQLite 和完整 data directory 是否能形成可审核 计划”,不会把 inspect 成功冒充自动升级。建议停止 2.x、同步器和下载器,确认主库位于 `db/database.sqlite`,再选择一个尚不存在且不在 2.x data root 内的 evidence 路径: @@ -110,13 +110,15 @@ NAS/较大单机使用 `standalone`。脚本继续只依赖 POSIX shell、`sha25 计数和 active sidecar,并保存两个 exact `planDigest`。脚本不会运行 stage、activation、transform/apply、cutover、target stop 或 Legacy rollback,也不会修改 source、修权限或删除 sidecar。readiness 成功不授权下一阶段;需要继续演练时按仓库运维协议显式提交审核后的 digest。 +macOS Docker Desktop 可能在新建 bind mount 的父/子进程间短暂呈现不同 UID。readiness 对每个纯只读 inspect 最多执行两次相同命令,且只在命令成功后原子发布结果;第二次仍失败就立即停止。该兼容窗口不修改数据、不重写成功结果,也不放宽原生 Linux 的 canonical path、POSIX owner 或 mode 校验。 + artifact job 必须在原生 amd64/arm64 上使用生产形态 2.x fixture 运行将要上传的 exact `upgrade-readiness.sh`,两个正式 Operator 结果都为 `inspected` 后才能记录 `verification-evidence.json.gates.legacyUpgradeReadiness=passed` 并上传。该证明仍不是用户实际磁盘、停机窗口、I/O 峰值或 生产数据内容兼容性承诺。 ## 受审核计划的 Side-by-side 暂存 -审核上一节两个完整结果后,把其中 exact `evidence.planDigest` 作为显式参数交给 v9 bundle 的 canonical `upgrade-rehearsal.sh`: +审核上一节两个完整结果后,把其中 exact `evidence.planDigest` 作为显式参数交给 v10 bundle 的 canonical `upgrade-rehearsal.sh`: ```sh sh upgrade-rehearsal.sh \ @@ -184,6 +186,42 @@ sh upgrade-cutover-rehearsal.sh \ 当前已闭合的 exact Console v9 阶段实物绑定源码 `0235973c9b54a2f22de09b6487ea9f184f0b8bfd` 与 [workflow run 33469435652](https://github.com/whyour/qinglong/actions/runs/33469435652):amd64 artifact `9786301280`、arm64 artifact `9786374284`、双架构 milestone `9786520389`,均保留至 2026-10-01。两个原生架构在上传前分别完成 clean rollback、写后 capture 和 bundle offline audit;milestone finalizer 下载并再次审计两个 exact bundle。本机重新下载的 milestone v5 通过 `SHA256SUMS`,auditor 返回 `compatible=true`。这是 `3.0.0-alpha.2` 的隔离 Alpha 候选,不是 Public Release 或用户真实 2.x 数据的自动升级授权。 +### 受审核 Automation 应用与显式回滚 + +v10 bundle 在写后 capture 之外增加 `reconciliation-rehearsal.sh`。它故意拆成三个命令,不能一条命令自动跨越人工决策: + +```sh +sh reconciliation-rehearsal.sh \ + prepare edge \ + /opt/qinglong3-alpha-upgrade-reconciliation \ + /opt/qinglong3-alpha-reconciliation-capture \ + /opt/qinglong3-alpha-reconciliation-work \ + Asia/Shanghai + +sh reconciliation-rehearsal.sh \ + review edge \ + /opt/qinglong3-alpha-upgrade-reconciliation \ + /opt/qinglong3-alpha-reconciliation-capture \ + /opt/qinglong3-alpha-reconciliation-work \ + /opt/qinglong3-alpha-decisions/review/review.ndjson + +sh reconciliation-rehearsal.sh \ + apply-rollback edge \ + /opt/qinglong3-alpha-upgrade-reconciliation \ + /opt/qinglong3-alpha-reconciliation-capture \ + /opt/qinglong3-alpha-reconciliation-work \ + /opt/qinglong3-alpha-decisions/automation/automation.ndjson \ + /opt/qinglong/data +``` + +`prepare` 成功只会生成 bounded plan、私有诊断与 review prepare,并把 `summary.json.status` 置为 `operator_decision_required`。操作者必须审核完整事实集,在独立 `0700` 目录中自行生成唯一的 `0400|0600` canonical NDJSON;交付包不含决定生成器。`review` 消费该文件,以 60 秒 strong Owner authorization 提交并验证裁决,生成跨域 application plan 和 Automation row plan,然后停在 `automation_decision_required`。review 文件与 Automation 文件必须位于不同私有目录,目录中不得有其他文件。 + +`apply-rollback` 只消费外部 Automation row decision,并额外只读挂载原始 Legacy root。它在无网络、只读 rootfs、128 MiB、0.5 CPU、32 PID 的短生命周期 Operator 中应用已审核行、验证 Task/Trigger 投影、生成应用前 backup,再显式 rollback/verify。成功 summary 必须是 `reconciliation_automation_rolled_back`、`target=restored_to_pre_automation_snapshot`,并固定 `completion/targetRestart/legacyRestart=not_attempted`。本阶段不应用 Secret/Config、不修改 Run History、不完成 reconciliation,也不启动 target 或 Legacy。 + +命令文件和成功结果支持中断后的 exact replay:脚本只在首次创建带时间 command,输出先写临时文件,成功后原子发布。不要编辑或删除 reconciliation root 中的 authorization、intent、plan、decision、backup、receipt 或 result;任何 digest、权限、路径或 lineage 漂移都应失败关闭。 + +仓库 CI 会用合成 fixture 在原生 amd64/arm64 上为 exact capture 生成冻结的 review/Automation 决定,实跑 apply→verify→rollback→verify,并要求 `verification-evidence.json.gates.legacyUpgradeReconciliationAutomationRollback=passed`。该 fixture 脚本不会被打入 Trial Kit。Local milestone v6 还必须绑定两个架构各自的 `upgradeReconciliationRehearsalSha256`;只有同 run finalizer 重新下载、审计并闭合两个 bundle 后才是 D-426c2 阶段实物。 + ## 手工加载与最小 smoke 从 `manifest.json.archive.file` 找到 archive 后加载: @@ -211,7 +249,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。v9 artifact job 必须从将要上传的目录实际执行 `quickstart.sh`、read-only `upgrade-readiness.sh` 和 isolated `upgrade-cutover-rehearsal.sh` 的 clean/write-after 两条路径,并完成 graceful stop、rollback-candidate、reconciliation capture、旧 SQLite 未变与四个合成容器清理。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 +完整 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。v10 artifact job 必须从将要上传的目录实际执行 `quickstart.sh`、read-only `upgrade-readiness.sh`、isolated `upgrade-cutover-rehearsal.sh` 的 clean/write-after 两条路径和三阶段 `reconciliation-rehearsal.sh`,并完成 graceful stop、rollback-candidate、reconciliation capture、review/Automation external decision、apply/rollback、旧 SQLite 未变与合成容器清理。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 Edge 的验证上限为 Application 128 MiB、0.5 CPU、64 PID;Standalone 为 256 MiB、0.5 CPU、256 PID;operator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。 diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/applyContract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/applyContract.ts index 4ffe7274..f770188e 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/applyContract.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/applyContract.ts @@ -209,12 +209,25 @@ function options( fail('authentication material must be below deploymentRoot'); } } + const targetRelative = path.relative( + normalized.deploymentRoot, + normalized.targetDatabasePath, + ); if ( - roots.some( - (root) => - overlaps(root, normalized.targetDatabasePath) || - overlaps(normalized.targetDatabasePath, root), - ) + !targetRelative || + targetRelative.startsWith('..') || + path.isAbsolute(targetRelative) + ) { + fail('targetDatabasePath must be below deploymentRoot'); + } + if ( + roots + .slice(1) + .some( + (root) => + overlaps(root, normalized.targetDatabasePath) || + overlaps(normalized.targetDatabasePath, root), + ) ) { fail('targetDatabasePath overlaps an authority root'); } diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/applyCoordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/applyCoordinator.ts index 19e6d0d6..7947a989 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/applyCoordinator.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/applyCoordinator.ts @@ -210,7 +210,7 @@ async function authenticate( databasePath: options.targetDatabasePath, ownerPepperKeyringDirectory: options.ownerPepperKeyringDirectory, credentialFilePath: options.credentialFilePath, - authenticationNamespace: 'local_reconciliation_automation_apply', + authenticationNamespace: 'reconcile_automation_apply', now: () => atMs, }); assertReviewer(selected, reviewer, atMs); diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionCoordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionCoordinator.ts index d2b21739..5bdcc7e9 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionCoordinator.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionCoordinator.ts @@ -691,7 +691,7 @@ async function authorization( selected.application.review.authorization.header.reviewer, command.request.committedAtMs, ); - return issueReconciliationAutomationDecision({ + return await issueReconciliationAutomationDecision({ ...common, reviewFilePath: command.request.decisionFilePath, reviewer, diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/completion/contract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/completion/contract.ts index 370999e5..2acb5139 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/completion/contract.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/completion/contract.ts @@ -321,14 +321,25 @@ function normalizeOptions( } if ( automation !== null && - roots.some( - (root) => - overlaps(root, automation.targetDatabasePath) || - overlaps(automation.targetDatabasePath, root), - ) + roots + .slice(1) + .some( + (root) => + overlaps(root, automation.targetDatabasePath) || + overlaps(automation.targetDatabasePath, root), + ) ) { fail('targetDatabasePath overlaps an authority root'); } + if (automation !== null) { + const relative = path.relative( + normalized.deploymentRoot, + automation.targetDatabasePath, + ); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + fail('Automation targetDatabasePath must be below deploymentRoot'); + } + } if ( secretConfig !== null && roots.some( diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts index 95ae5f68..f4028134 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/completion.ts @@ -257,6 +257,7 @@ function maxAuthorizationBytes(profile: 'edge' | 'standalone'): number { function strongPrincipal( authenticated: Readonly, committedAtMs: number, + authorizationExpiresAtMs: number, ): Readonly { const principal = authenticated.principal; if ( @@ -266,7 +267,9 @@ function strongPrincipal( ) || principal.authenticatedAtMs > committedAtMs || committedAtMs - principal.authenticatedAtMs > MAX_AUTHENTICATION_AGE_MS || - principal.expiresAtMs <= committedAtMs + principal.expiresAtMs <= committedAtMs || + !Number.isSafeInteger(authorizationExpiresAtMs) || + principal.expiresAtMs < authorizationExpiresAtMs ) { configurationError( 'review commit requires a recent strongly authenticated User', @@ -615,9 +618,12 @@ async function publishAuthorization( dependencies: LocalReconciliationReviewCompletionDependencies, uid: number, ): Promise> { + const authorizationExpiresAtMs = + command.request.committedAtMs + command.request.authorizationLifetimeMs; const reviewer = strongPrincipal( authenticated, command.request.committedAtMs, + authorizationExpiresAtMs, ); ensureLocalReconciliationReviewIssuerKeyring( command.options.issuerKeyringPath, @@ -638,8 +644,7 @@ async function publishAuthorization( preparedHeadDigest: head.headDigest, reviewer, issuedAtMs: command.request.committedAtMs, - expiresAtMs: - command.request.committedAtMs + command.request.authorizationLifetimeMs, + expiresAtMs: authorizationExpiresAtMs, }); return publishLocalReconciliationReviewAuthorization({ targetPath: selected.authorization, diff --git a/packages/ql3-local-owner-cli/test/reconciliationAutomationApplyContract.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationAutomationApplyContract.test.cjs new file mode 100644 index 00000000..e0e942bd --- /dev/null +++ b/packages/ql3-local-owner-cli/test/reconciliationAutomationApplyContract.test.cjs @@ -0,0 +1,51 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + normalizeLocalReconciliationAutomationApplyCommand, +} = require('../dist/deployment/reconciliation/application/automation/applyContract.js'); + +test('Automation apply requires the target database below deployment authority', () => { + const command = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.automation.apply', + options: { + deploymentRoot: '/authority/deployment', + applicationRoot: '/authority/application', + automationRoot: '/authority/automation', + automationDecisionRoot: '/authority/automation-decision', + automationApplyRoot: '/authority/automation-apply', + targetDatabasePath: '/authority/deployment/sqlite/qinglong3.sqlite', + ownerPepperKeyringDirectory: '/authority/deployment/owner-peppers', + credentialFilePath: '/authority/deployment/owner-credential.json', + allowRootService: + typeof process.getuid === 'function' && process.getuid() === 0, + }, + request: { + decisionId: '019f8680-143d-7000-8000-000000000471', + automationId: '019f8680-143d-4000-8000-000000000461', + expectedDecisionDigest: '1'.repeat(64), + expectedHeadDigest: '2'.repeat(64), + mutationId: '019f8680-143d-4000-8000-000000000481', + requestId: 'bounded-automation-apply', + appliedAtMs: 1, + }, + }; + + assert.equal( + normalizeLocalReconciliationAutomationApplyCommand(command).options + .targetDatabasePath, + command.options.targetDatabasePath, + ); + assert.throws( + () => + normalizeLocalReconciliationAutomationApplyCommand({ + ...command, + options: { + ...command.options, + targetDatabasePath: '/authority/outside.sqlite', + }, + }), + /must be below deploymentRoot/, + ); +}); diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index 3b979950..efc5b7f5 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -126,6 +126,7 @@ function fixture( profile = 'edge', createDefaultSidecars = true, useAdoptedTargetBaseline = false, + targetInsideDeploymentRoot = false, initializeDatabases, mutateTarget, } = {}, @@ -136,6 +137,7 @@ function fixture( fs.chmodSync(root, 0o700); t.after(() => removeFixtureRoot(root)); const deploymentRoot = path.join(root, 'runtime'); + const sqliteRoot = path.join(deploymentRoot, 'sqlite'); const serviceRoot = path.join(deploymentRoot, 'service'); const cutoverId = 'capture-cutover-1'; const journal = path.join(serviceRoot, 'cutovers', cutoverId); @@ -143,6 +145,7 @@ function fixture( const captureRoot = path.join(root, 'capture-root'); for (const directory of [ deploymentRoot, + sqliteRoot, serviceRoot, path.dirname(journal), journal, @@ -152,7 +155,9 @@ function fixture( if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 }); } const legacySourcePath = path.join(root, 'database.sqlite'); - const targetDatabasePath = path.join(root, 'database.ql3.sqlite'); + const targetDatabasePath = targetInsideDeploymentRoot + ? path.join(sqliteRoot, 'database.ql3.sqlite') + : path.join(root, 'database.ql3.sqlite'); const recoveryPath = path.join(root, 'database.recovery.sqlite'); const manifestPath = path.join(root, 'adoption-manifest.json'); const activationPath = path.join(root, 'activation.json'); @@ -995,6 +1000,7 @@ function mutatePlanningTarget({ targetDatabasePath }) { function preparedPlan(t, options = {}) { const state = preparedCapture(t, { createDefaultSidecars: options.createDefaultSidecars ?? false, + targetInsideDeploymentRoot: options.targetInsideDeploymentRoot ?? false, initializeDatabases: options.initializeDatabases ?? planningDatabaseInitializer(options), mutateTarget: options.mutateTarget ?? mutatePlanningTarget, @@ -1229,7 +1235,7 @@ function reviewCommitFixture(t, options = {}) { expectedHeadDigest: prepared.instanceHeadDigest, decisionFilePath: reviewFile.filePath, committedAtMs, - authorizationLifetimeMs: 30 * 60 * 1_000, + authorizationLifetimeMs: 60_000, }, }; let authentications = 0; @@ -1408,6 +1414,7 @@ async function plannedAutomationFixture(t, options = {}) { applicationId: options.applicationId, reviewSuffix: `automation-decision-${suffix}`, createDefaultSidecars: false, + targetInsideDeploymentRoot: true, initializeDatabases: options.readyTarget === true ? automationReadyDatabaseInitializer() @@ -1571,6 +1578,7 @@ function automationDecisionCommitFixture( let authentications = 0; let confirmations = 0; let databaseCloses = 0; + let databaseClosed = true; const command = { schemaVersion: 1, operation: 'local.deployment.reconciliation.automation.decision.commit', @@ -1594,8 +1602,10 @@ function automationDecisionCommitFixture( const dependencies = { now: () => committedAtMs, async openAuthenticationDatabase() { + databaseClosed = false; return { async close() { + databaseClosed = true; databaseCloses += 1; }, }; @@ -1624,6 +1634,7 @@ function automationDecisionCommitFixture( pepperVersion: 1, }, async confirm() { + assert.equal(databaseClosed, false); confirmations += 1; }, }; @@ -3377,6 +3388,17 @@ test('review commit rejects weak principals, oversized Edge streams and decision /recent strongly authenticated User/, ); + const overlong = reviewCommitFixture(t, { + planId: '00000000-0000-4000-8000-000000000392', + reviewId: '00000000-0000-4000-8000-000000000393', + reviewSuffix: 'overlong', + }); + overlong.command.request.authorizationLifetimeMs = 60_001; + await assert.rejects( + commitLocalReconciliationReview(overlong.command, overlong.dependencies), + /recent strongly authenticated User/, + ); + const oversized = reviewCommitFixture(t, { planId: '00000000-0000-4000-8000-000000000367', reviewId: '00000000-0000-4000-8000-000000000368', @@ -5658,7 +5680,7 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec async authenticate(_database, options) { assert.equal( options.authenticationNamespace, - 'local_reconciliation_automation_apply', + 'reconcile_automation_apply', ); const authenticatedAtMs = options.now(); return { diff --git a/scripts/ql3-alpha-stage-index.cjs b/scripts/ql3-alpha-stage-index.cjs index 4132a12b..7719d17c 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@v5' + ? 'qinglong/alpha-local-milestone@v6' : '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 b327e1ad..85376917 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@v5'; +const SCHEMA = 'qinglong/alpha-local-milestone@v6'; const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const FILES = Object.freeze({ readme: 'README.md', @@ -222,6 +222,8 @@ function bundleRecord(options, architecture) { upgradeReadinessSha256: report.upgradeReadinessSha256, upgradeRehearsalSha256: report.upgradeRehearsalSha256, upgradeCutoverRehearsalSha256: report.upgradeCutoverRehearsalSha256, + upgradeReconciliationRehearsalSha256: + report.upgradeReconciliationRehearsalSha256, }); } @@ -238,6 +240,7 @@ function validateArtifactRecord(record, architecture, manifest) { 'upgradeReadinessSha256', 'upgradeRehearsalSha256', 'upgradeCutoverRehearsalSha256', + 'upgradeReconciliationRehearsalSha256', ]) || record.artifactName !== artifactName(manifest.sourceRevision, architecture, manifest.variant) || @@ -254,6 +257,7 @@ function validateArtifactRecord(record, architecture, manifest) { !SHA256_PATTERN.test(record.upgradeReadinessSha256 || '') || !SHA256_PATTERN.test(record.upgradeRehearsalSha256 || '') || !SHA256_PATTERN.test(record.upgradeCutoverRehearsalSha256 || '') || + !SHA256_PATTERN.test(record.upgradeReconciliationRehearsalSha256 || '') || record.applicationImageId === record.operatorImageId ) { fail(`${architecture} milestone artifact record is incompatible`); @@ -322,7 +326,7 @@ function auditLocalAlphaMilestone(options) { 'artifacts', 'readme', ]) || - manifest.schemaVersion !== 5 || + manifest.schemaVersion !== 6 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || @@ -371,7 +375,10 @@ function auditLocalAlphaMilestone(options) { new Set(records.map((record) => record.upgradeRehearsalSha256)).size !== ARCHITECTURES.length || new Set(records.map((record) => record.upgradeCutoverRehearsalSha256)) - .size !== ARCHITECTURES.length + .size !== ARCHITECTURES.length || + new Set( + records.map((record) => record.upgradeReconciliationRehearsalSha256), + ).size !== ARCHITECTURES.length ) { fail('milestone architecture subjects are not distinct'); } @@ -398,7 +405,7 @@ function auditLocalAlphaMilestone(options) { } return Object.freeze({ schemaVersion: 1, - schema: 'qinglong/alpha-local-milestone-audit@v5', + schema: 'qinglong/alpha-local-milestone-audit@v6', sourceRevision: manifest.sourceRevision, version: manifest.version, variant: manifest.variant, @@ -441,7 +448,9 @@ function finalizeLocalAlphaMilestone(options) { artifacts.amd64.upgradeRehearsalSha256 === artifacts.arm64.upgradeRehearsalSha256 || artifacts.amd64.upgradeCutoverRehearsalSha256 === - artifacts.arm64.upgradeCutoverRehearsalSha256 + artifacts.arm64.upgradeCutoverRehearsalSha256 || + artifacts.amd64.upgradeReconciliationRehearsalSha256 === + artifacts.arm64.upgradeReconciliationRehearsalSha256 ) { fail('milestone architecture subjects must be distinct'); } @@ -454,7 +463,7 @@ function finalizeLocalAlphaMilestone(options) { path.join(normalized.outputRoot, FILES.readme), ); const manifest = { - schemaVersion: 5, + schemaVersion: 6, schema: SCHEMA, maturity: 'alpha_candidate_not_public_release', product: 'local', diff --git a/scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs b/scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs new file mode 100644 index 00000000..b2abe2c0 --- /dev/null +++ b/scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs @@ -0,0 +1,403 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const DOMAINS = Object.freeze([ + 'schema_lineage', + 'automation', + 'secret_and_config', + 'run_history', + 'plugin_package', + 'ai_and_tool', + 'identity_policy_audit', + 'unknown', +]); +const DATABASES = Object.freeze(['legacy', 'target']); +const FACT_KINDS = Object.freeze(['schema_object', 'table']); +const MAX_JSON_BYTES = 4 * 1024 * 1024; +const DIGEST = /^[0-9a-f]{64}$/u; +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const UUID_V7 = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +function fail(message) { + throw new Error(message); +} + +function canonicalFile(filePath, maximumBytes, label) { + const resolved = path.resolve(filePath); + const stat = fs.lstatSync(resolved); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 2 || + stat.size > maximumBytes || + fs.realpathSync(resolved) !== resolved + ) { + fail(`${label} must be one bounded canonical regular file`); + } + return resolved; +} + +function readJson(filePath, label) { + const resolved = canonicalFile(filePath, MAX_JSON_BYTES, label); + try { + return JSON.parse(fs.readFileSync(resolved, 'utf8')); + } catch { + return fail(`${label} must contain valid JSON`); + } +} + +function writeExclusive(filePath, records) { + const resolved = path.resolve(filePath); + const parent = path.dirname(resolved); + if ( + fs.existsSync(resolved) || + !fs.lstatSync(parent).isDirectory() || + fs.realpathSync(parent) !== parent + ) { + fail('fixture output must be a new file below one canonical directory'); + } + const descriptor = fs.openSync( + resolved, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600, + ); + try { + fs.writeFileSync( + descriptor, + `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, + ); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + return resolved; +} + +function fixtureRoot(value) { + const resolved = path.resolve(value || ''); + if ( + !path.isAbsolute(value || '') || + !fs.lstatSync(resolved).isDirectory() || + fs.realpathSync(resolved) !== resolved + ) { + fail('reconciliation root must be one canonical directory'); + } + return resolved; +} + +function validateSummary(root, status) { + const summary = readJson( + path.join(root, 'summary.json'), + 'reconciliation summary', + ); + if ( + summary?.schemaVersion !== 1 || + summary?.schema !== + 'qinglong/local-alpha-reconciliation-rehearsal-summary@v1' || + summary?.status !== status || + (summary?.profile !== 'edge' && summary?.profile !== 'standalone') + ) { + fail(`reconciliation summary is not in ${status} state`); + } + return summary; +} + +function validateFact(fact, expected) { + if ( + fact?.schemaVersion !== 1 || + fact?.schema !== 'qinglong3-local-reconciliation-diagnostic-fact' || + fact?.database !== expected.database || + fact?.domain !== expected.domain || + fact?.factKind !== expected.factKind || + !Number.isSafeInteger(fact.ordinal) || + fact.ordinal < 1 || + !DIGEST.test(fact.factDigest || '') || + !['informational', 'required', 'blocked'].includes(fact.decisionRequirement) + ) { + fail('diagnostic fact is incompatible with the fixture contract'); + } +} + +function diagnosticFacts(root, summary) { + const facts = []; + let pageCount = 0; + for (const database of DATABASES) { + for (const domain of DOMAINS) { + for (const factKind of FACT_KINDS) { + let pageNumber = 0; + let expectedOffset = 0; + while (true) { + const page = readJson( + path.join( + root, + 'diagnostics', + `${database}-${domain}-${factKind}-${pageNumber}.json`, + ), + 'diagnostic page', + ); + if ( + page?.schemaVersion !== 1 || + page?.schema !== 'qinglong3-local-reconciliation-diagnostic-page' || + page?.state !== 'reconciliation_review_prepared' || + page?.reviewId !== summary.review.reviewId || + page?.planDigest !== summary.plan.planDigest || + page?.preparationDigest !== summary.review.preparationDigest || + page?.database !== database || + page?.domain !== domain || + page?.factKind !== factKind || + page?.offset !== expectedOffset || + page?.limit !== 64 || + !Array.isArray(page.records) || + page.recordCount !== page.records.length || + !DIGEST.test(page.pageDigest || '') + ) { + fail('diagnostic page is detached or malformed'); + } + for (const fact of page.records) { + validateFact(fact, { database, domain, factKind }); + facts.push(fact); + } + pageCount += 1; + if (page.complete === true && page.nextOffset === null) break; + if ( + page.complete !== false || + !Number.isSafeInteger(page.nextOffset) || + page.nextOffset <= expectedOffset + ) { + fail('diagnostic pagination is invalid'); + } + expectedOffset = page.nextOffset; + pageNumber += 1; + } + } + } + } + if ( + pageCount !== summary.review.diagnosticPages || + facts.length !== summary.review.diagnosticRecords + ) { + fail('diagnostic inventory differs from the reconciliation summary'); + } + return facts; +} + +function reviewFixture(root, output) { + const summary = validateSummary(root, 'operator_decision_required'); + if ( + !UUID_V4.test(summary.review?.reviewId || '') || + !DIGEST.test(summary.plan?.planDigest || '') || + !DIGEST.test(summary.review?.preparationDigest || '') + ) { + fail('review fixture identity is invalid'); + } + const facts = diagnosticFacts(root, summary); + let adoptedAutomationTables = 0; + const decisions = []; + for (const fact of facts) { + if (fact.decisionRequirement === 'informational') continue; + const automationTable = + fact.database === 'legacy' && + fact.domain === 'automation' && + fact.factKind === 'table' && + fact.tableName === 'Crontabs' && + fact.decisionRequirement === 'required'; + if (automationTable) adoptedAutomationTables += 1; + const blocked = fact.decisionRequirement === 'blocked'; + const legacy = fact.database === 'legacy'; + const legacyRunHistory = legacy && fact.domain === 'run_history'; + decisions.push({ + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-review-decision', + database: fact.database, + domain: fact.domain, + factKind: fact.factKind, + ordinal: fact.ordinal, + factDigest: fact.factDigest, + disposition: automationTable + ? 'adopt_legacy' + : blocked || legacyRunHistory + ? 'manual_external' + : legacy + ? 'exclude_legacy' + : 'retain_target', + reason: automationTable + ? 'prefer_legacy' + : blocked || legacyRunHistory + ? 'external_recovery_required' + : legacy + ? 'legacy_excluded' + : 'preserve_target', + }); + } + if (adoptedAutomationTables !== 1 || decisions.length < 1) { + fail('fixture requires exactly one reviewable Legacy Crontabs table'); + } + const records = [ + { + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-review-decision-header', + diagnosticsContractVersion: 1, + reviewId: summary.review.reviewId, + profile: summary.profile, + planDigest: summary.plan.planDigest, + preparationDigest: summary.review.preparationDigest, + }, + ...decisions, + ]; + const filePath = writeExclusive(output, records); + return Object.freeze({ + mode: 'review', + filePath, + decisionCount: decisions.length, + adoptedAutomationTables, + }); +} + +function automationFixture(root, output) { + const summary = validateSummary(root, 'automation_decision_required'); + const automation = summary.automation; + const decision = summary.decision; + if ( + !UUID_V4.test(automation?.automationId || '') || + !UUID_V7.test(decision?.decisionId || '') || + !DIGEST.test(automation?.automationPlanDigest || '') || + automation.rowCount !== 1 || + automation.eligibleCount !== 1 || + automation.conflictCount !== 0 + ) { + fail('fixture requires one conflict-free eligible Automation row'); + } + const directory = path.join(root, 'automation', automation.automationId); + const receipt = readJson( + path.join(directory, 'receipt.json'), + 'Automation plan receipt', + ); + if ( + receipt?.schemaVersion !== 1 || + receipt?.schema !== + 'qinglong3-local-reconciliation-automation-plan-receipt' || + receipt?.automationId !== automation.automationId || + receipt?.automationPlanDigest !== automation.automationPlanDigest || + receipt?.rowCount !== 1 || + receipt?.eligibleCount !== 1 || + receipt?.manualCount !== 0 || + receipt?.conflictCount !== 0 || + !DIGEST.test(receipt.legacyInventoryDigest || '') + ) { + fail('Automation plan receipt is not fixture-eligible'); + } + const planPath = canonicalFile( + path.join(directory, 'plan.ndjson'), + 8 * 1024 * 1024, + 'Automation row plan', + ); + let records; + try { + records = fs + .readFileSync(planPath, 'utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)); + } catch { + return fail('Automation row plan must be canonical NDJSON'); + } + const rows = records.filter( + (record) => + record?.kind === 'qinglong3-local-reconciliation-automation-plan-row', + ); + if ( + rows.length !== 1 || + rows[0].schemaVersion !== 1 || + rows[0].requirement !== 'review_adopt' || + rows[0].target?.state !== 'absent' || + !Number.isSafeInteger(rows[0].rowOrdinal) || + rows[0].rowOrdinal < 1 || + !DIGEST.test(rows[0].sourceDigest || '') + ) { + fail('Automation row plan contains a non-lossless or conflicting row'); + } + const outputRecords = [ + { + schemaVersion: 1, + kind: 'qinglong3-legacy-crontab-decision-review-file-header', + decisionId: decision.decisionId, + profile: summary.profile, + planDigest: automation.automationPlanDigest, + inventoryDigest: receipt.legacyInventoryDigest, + }, + { + schemaVersion: 1, + kind: 'qinglong3-legacy-crontab-decision-review-file-row', + decision: { + rowOrdinal: rows[0].rowOrdinal, + sourceDigest: rows[0].sourceDigest, + disposition: 'adopt', + reason: 'reviewed_lossless', + }, + }, + ]; + const filePath = writeExclusive(output, outputRecords); + return Object.freeze({ + mode: 'automation', + filePath, + decisionCount: 1, + automationPlanDigest: automation.automationPlanDigest, + }); +} + +function parseArguments(argv) { + const values = {}; + for (const argument of argv) { + const match = /^--([a-z-]+)=(.+)$/u.exec(argument); + if (!match || Object.hasOwn(values, match[1])) + fail('arguments are invalid'); + values[match[1]] = match[2]; + } + if ( + !['review', 'automation'].includes(values.mode) || + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(['mode', 'output', 'reconciliation-root']) + ) { + fail('fixture arguments are invalid'); + } + return Object.freeze({ + mode: values.mode, + reconciliationRoot: fixtureRoot(values['reconciliation-root']), + output: path.resolve(values.output), + }); +} + +function runCli(argv) { + const options = parseArguments(argv); + const report = + options.mode === 'review' + ? reviewFixture(options.reconciliationRoot, options.output) + : automationFixture(options.reconciliationRoot, options.output); + process.stdout.write(`${JSON.stringify(report)}\n`); + return report; +} + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : 'decision fixture failed'}\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + automationFixture, + diagnosticFacts, + parseArguments, + reviewFixture, + runCli, +}); diff --git a/scripts/ql3-local-alpha-trial-kit-bundle.cjs b/scripts/ql3-local-alpha-trial-kit-bundle.cjs index 94770bd7..1855f9d2 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@v9'; -const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v7'; +const SCHEMA = 'qinglong/alpha-local-trial-kit@v10'; +const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v8'; const QUICKSTART_TEMPLATE = path.join( DEFAULT_ROOT, 'scripts/templates/ql3-local-alpha-quickstart.sh', @@ -28,6 +28,10 @@ const UPGRADE_CUTOVER_REHEARSAL_TEMPLATE = path.join( DEFAULT_ROOT, 'scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh', ); +const UPGRADE_RECONCILIATION_REHEARSAL_TEMPLATE = path.join( + DEFAULT_ROOT, + 'scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh', +); const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const VARIANTS = Object.freeze(['headless', 'console']); const ARCHIVE_MIN_BYTES = 1024; @@ -37,6 +41,7 @@ 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 MAX_UPGRADE_RECONCILIATION_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({ @@ -47,6 +52,7 @@ const FILES = Object.freeze({ upgradeReadiness: 'upgrade-readiness.sh', upgradeRehearsal: 'upgrade-rehearsal.sh', upgradeCutoverRehearsal: 'upgrade-cutover-rehearsal.sh', + upgradeReconciliationRehearsal: 'reconciliation-rehearsal.sh', readme: 'README.md', manifest: 'manifest.json', checksums: 'SHA256SUMS', @@ -66,6 +72,7 @@ const VERIFICATION = Object.freeze({ legacyUpgradeStage: 'passed', legacyUpgradeCutover: 'passed', legacyUpgradeReconciliationCapture: 'passed', + legacyUpgradeReconciliationAutomationRollback: 'passed', }); function verificationGates(variant) { @@ -550,6 +557,35 @@ function renderUpgradeCutoverRehearsal(identity) { return rendered; } +function renderUpgradeReconciliationRehearsal(identity) { + const template = fs.readFileSync( + assertCanonicalFile( + UPGRADE_RECONCILIATION_REHEARSAL_TEMPLATE, + MAX_UPGRADE_RECONCILIATION_REHEARSAL_BYTES, + 'upgrade reconciliation rehearsal template', + ), + 'utf8', + ); + const replacements = Object.freeze({ + '@@OPERATOR_IMAGE@@': identity.images.operator.reference, + '@@OPERATOR_ID@@': identity.images.operator.id, + '@@ARCHITECTURE@@': identity.architecture, + '@@SOURCE_REVISION@@': identity.sourceRevision, + '@@ARCHIVE@@': identity.archive.file, + '@@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 reconciliation rehearsal template contains an unresolved token', + ); + } + return rendered; +} + function fileRecord(bundleRoot, name) { const filePath = path.join(bundleRoot, name); const stat = fs.lstatSync(filePath); @@ -740,8 +776,13 @@ function createLocalAlphaTrialKit(options, adapters = {}) { renderUpgradeCutoverRehearsal(manifestIdentity), 0o700, ); + writeExclusive( + path.join(normalized.outputRoot, FILES.upgradeReconciliationRehearsal), + renderUpgradeReconciliationRehearsal(manifestIdentity), + 0o700, + ); const manifest = { - schemaVersion: 10, + schemaVersion: 11, schema: SCHEMA, maturity: 'alpha_candidate_not_public_release', product: 'local', @@ -768,6 +809,10 @@ function createLocalAlphaTrialKit(options, adapters = {}) { normalized.outputRoot, FILES.upgradeCutoverRehearsal, ), + upgradeReconciliationRehearsal: fileRecord( + normalized.outputRoot, + FILES.upgradeReconciliationRehearsal, + ), readme: fileRecord(normalized.outputRoot, FILES.readme), verification: fileRecord( normalized.outputRoot, @@ -787,6 +832,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { FILES.upgradeReadiness, FILES.upgradeRehearsal, FILES.upgradeCutoverRehearsal, + FILES.upgradeReconciliationRehearsal, FILES.readme, FILES.manifest, ]; @@ -859,10 +905,11 @@ function auditLocalAlphaTrialKit(options) { 'upgradeReadiness', 'upgradeRehearsal', 'upgradeCutoverRehearsal', + 'upgradeReconciliationRehearsal', 'readme', 'verification', ]) || - manifest.schemaVersion !== 10 || + manifest.schemaVersion !== 11 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || @@ -963,6 +1010,29 @@ function auditLocalAlphaTrialKit(options) { 'upgrade cutover rehearsal differs from the canonical cutover journey', ); } + validateFileRecord( + manifest.upgradeReconciliationRehearsal, + FILES.upgradeReconciliationRehearsal, + bundleRoot, + ); + const expectedUpgradeReconciliationRehearsal = + renderUpgradeReconciliationRehearsal(manifest); + const actualUpgradeReconciliationRehearsal = fs.readFileSync( + assertCanonicalFile( + path.join(bundleRoot, FILES.upgradeReconciliationRehearsal), + MAX_UPGRADE_RECONCILIATION_REHEARSAL_BYTES, + 'upgrade reconciliation rehearsal', + ), + 'utf8', + ); + if ( + actualUpgradeReconciliationRehearsal !== + expectedUpgradeReconciliationRehearsal + ) { + fail( + 'upgrade reconciliation rehearsal differs from the canonical reviewed application and rollback journey', + ); + } validateFileRecord(manifest.readme, FILES.readme, bundleRoot); validateOfflineSbom( readBoundedJson( @@ -1002,6 +1072,7 @@ function auditLocalAlphaTrialKit(options) { FILES.upgradeReadiness, FILES.upgradeRehearsal, FILES.upgradeCutoverRehearsal, + FILES.upgradeReconciliationRehearsal, expectedArchive, ].sort(); const actualFiles = fs @@ -1025,6 +1096,7 @@ function auditLocalAlphaTrialKit(options) { FILES.upgradeReadiness, FILES.upgradeRehearsal, FILES.upgradeCutoverRehearsal, + FILES.upgradeReconciliationRehearsal, FILES.readme, FILES.manifest, ]; @@ -1038,7 +1110,7 @@ function auditLocalAlphaTrialKit(options) { } return Object.freeze({ schemaVersion: 1, - schema: 'qinglong/alpha-local-trial-kit-audit@v6', + schema: 'qinglong/alpha-local-trial-kit-audit@v7', sourceRevision: manifest.sourceRevision, version: manifest.version, architecture: manifest.architecture, @@ -1050,6 +1122,8 @@ function auditLocalAlphaTrialKit(options) { upgradeReadinessSha256: manifest.upgradeReadiness.sha256, upgradeRehearsalSha256: manifest.upgradeRehearsal.sha256, upgradeCutoverRehearsalSha256: manifest.upgradeCutoverRehearsal.sha256, + upgradeReconciliationRehearsalSha256: + manifest.upgradeReconciliationRehearsal.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 f1b35d85..9ab79bcd 100644 --- a/scripts/ql3-local-operator-image-audit.cjs +++ b/scripts/ql3-local-operator-image-audit.cjs @@ -210,6 +210,23 @@ function auditWorkflow(contents, findings) { '"status":"reconciliation_captured"', '"rollback":"not_authorized"', 'reconciliation-capture-verify.result.json', + 'scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs', + 'sh "${BUNDLE_ROOT}/reconciliation-rehearsal.sh"', + 'RECONCILIATION_REVIEW_DECISION_ROOT=', + 'RECONCILIATION_AUTOMATION_DECISION_ROOT=', + '"status":"operator_decision_required"', + '"automaticDecision":"not_authorized"', + '--mode=review', + '"status":"automation_decision_required"', + '"decisionAuthority":"authenticated_user"', + '"automaticRowDecision":"not_authorized"', + '--mode=automation', + 'apply-rollback edge', + '"${LEGACY_READINESS_ROOT}"', + '"status":"reconciliation_automation_rolled_back"', + 'automation-apply-verify.result.json', + 'automation-rollback-verify.result.json', + '"completion":"not_attempted"', 'docker rm "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}"', '--application-sbom="${APPLICATION_SBOM}"', '--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"', @@ -239,6 +256,10 @@ function auditWorkflow(contents, findings) { '/quickstart.sh"', '/upgrade-cutover-rehearsal.sh"', '--capture-after-write', + '/reconciliation-rehearsal.sh"', + '--mode=review', + '--mode=automation', + 'apply-rollback edge', 'name: Upload the tested native Local Alpha trial kit', ]) { const index = contents.indexOf(value, cursor + 1); diff --git a/scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh b/scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh new file mode 100644 index 00000000..b9873315 --- /dev/null +++ b/scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh @@ -0,0 +1,479 @@ +#!/bin/sh + +set -eu + +OPERATOR_IMAGE='@@OPERATOR_IMAGE@@' +OPERATOR_ID='@@OPERATOR_ID@@' +ARCHITECTURE='@@ARCHITECTURE@@' +SOURCE_REVISION='@@SOURCE_REVISION@@' +ARCHIVE='@@ARCHIVE@@' +VARIANT='@@VARIANT@@' + +PLAN_ID='019f8680-143d-4000-8000-000000000201' +REVIEW_ID='019f8680-143d-4000-8000-000000000301' +APPLICATION_ID='019f8680-143d-4000-8000-000000000401' +AUTOMATION_ID='019f8680-143d-4000-8000-000000000461' +AUTOMATION_DECISION_ID='019f8680-143d-7000-8000-000000000471' +AUTOMATION_MUTATION_ID='019f8680-143d-4000-8000-000000000481' + +fail() { + printf '%s\n' "QingLong Local Alpha reconciliation rehearsal failed: $*" >&2 + exit 1 +} + +phase() { + printf '%s\n' "QingLong Local Alpha reconciliation phase: $1" >&2 +} + +usage() { + printf '%s\n' \ + 'usage: sh reconciliation-rehearsal.sh prepare edge|standalone /absolute/rehearsal-root /absolute/capture-root /absolute/new/reconciliation-root ' \ + ' sh reconciliation-rehearsal.sh review edge|standalone /absolute/rehearsal-root /absolute/capture-root /absolute/reconciliation-root /absolute/review-decisions.ndjson' \ + ' sh reconciliation-rehearsal.sh apply-rollback edge|standalone /absolute/rehearsal-root /absolute/capture-root /absolute/reconciliation-root /absolute/automation-decisions.ndjson /absolute/legacy-root' >&2 + exit 2 +} + +safe_absolute_path() { + case "$1" in + /|*[!A-Za-z0-9_./@-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/) + return 1 + ;; + /*) return 0 ;; + *) return 1 ;; + esac +} + +valid_digest() { + [ "${#1}" -eq 64 ] || return 1 + case "$1" in *[!0-9a-f]*) return 1 ;; *) return 0 ;; esac +} + +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" +} + +extract_unsigned() { + result_file=$1 + field=$2 + value=$(sed -n "s/^.*\"$field\":\([0-9][0-9]*\).*$/\1/p" "$result_file") + case "$value" in ''|*[!0-9]*) fail "$field is missing or invalid in $result_file" ;; esac + printf '%s' "$value" +} + +extract_capture_id() { + result_file=$1 + value=$(sed -n 's/^.*"captureId":"\([0-9a-f-][0-9a-f-]*\)".*$/\1/p' "$result_file") + case "$value" in + ????????-????-4???-[89ab]???-????????????) printf '%s' "$value" ;; + *) fail "captureId is missing or invalid in $result_file" ;; + esac +} + +non_overlapping() { + left=$1 + right=$2 + [ "$left" != "$right" ] || return 1 + case "$left/" in "$right"/*) return 1 ;; esac + case "$right/" in "$left"/*) return 1 ;; esac + return 0 +} + +canonical_directory() { + selected=$1 + label=$2 + safe_absolute_path "$selected" || fail "$label is not a safe canonical absolute path" + [ -d "$selected" ] || fail "$label does not exist" + [ ! -L "$selected" ] || fail "$label must not be a symbolic link" + [ "$(realpath "$selected")" = "$selected" ] || fail "$label is not canonical" +} + +private_decision_file() { + decision_file=$1 + decision_label=$2 + safe_absolute_path "$decision_file" || fail "$decision_label is not a safe canonical absolute path" + [ -f "$decision_file" ] || fail "$decision_label does not exist" + [ ! -L "$decision_file" ] || fail "$decision_label must not be a symbolic link" + [ "$(realpath "$decision_file")" = "$decision_file" ] || fail "$decision_label is not canonical" + size=$(stat -c %s "$decision_file") + [ "$size" -ge 2 ] && [ "$size" -le 4194304 ] || fail "$decision_label is empty or too large" + file_mode=$(stat -c %a "$decision_file") + [ "$file_mode" = 400 ] || [ "$file_mode" = 600 ] || fail "$decision_label must have mode 0400 or 0600" + decision_parent=${decision_file%/*} + [ -n "$decision_parent" ] || decision_parent=/ + canonical_directory "$decision_parent" "$decision_label parent" + [ "$(stat -c %a "$decision_parent")" = 700 ] || fail "$decision_label parent must have mode 0700" + extra_entry=$(find "$decision_parent" -mindepth 1 -maxdepth 1 ! -path "$decision_file" -print -quit) + [ -z "$extra_entry" ] || fail "$decision_label parent must contain only the selected decision file" + for authority_root in "$rehearsal_root" "$capture_root" "$reconciliation_root"; do + non_overlapping "$decision_file" "$authority_root" || fail "$decision_label must be outside all authority roots" + non_overlapping "$decision_parent" "$authority_root" || fail "$decision_label parent must be outside all authority roots" + done +} + +[ "$#" -ge 1 ] || usage +mode=$1 +[ "$mode" = apply-rollback ] && [ "$#" -eq 7 ] || { + [ "$mode" != apply-rollback ] && [ "$#" -eq 6 ] || usage +} +profile=$2 +rehearsal_root=$3 +capture_root=$4 +reconciliation_root=$5 +phase_input=$6 +legacy_root=${7:-} + +case "$mode" in prepare|review|apply-rollback) ;; *) usage ;; esac +case "$profile" in edge|standalone) ;; *) usage ;; esac +case "$VARIANT" in headless|console) ;; *) fail 'embedded Trial Kit variant is invalid' ;; esac +[ "$(uname -s)" = Linux ] || fail 'reconciliation rehearsal requires a Linux Docker host' +for tool in docker sha256sum grep sed stat date realpath find mv rm; do + command -v "$tool" >/dev/null 2>&1 || fail "$tool is required" +done +canonical_directory "$rehearsal_root" 'rehearsal root' +canonical_directory "$capture_root" 'capture root' +safe_absolute_path "$reconciliation_root" || fail 'reconciliation root is not a safe canonical absolute path' +non_overlapping "$rehearsal_root" "$capture_root" || fail 'rehearsal and capture roots overlap' +non_overlapping "$rehearsal_root" "$reconciliation_root" || fail 'rehearsal and reconciliation roots overlap' +non_overlapping "$capture_root" "$reconciliation_root" || fail 'capture and reconciliation roots overlap' +if [ -n "$legacy_root" ]; then + canonical_directory "$legacy_root" 'legacy root' + for authority_root in "$rehearsal_root" "$capture_root" "$reconciliation_root"; do + non_overlapping "$legacy_root" "$authority_root" || fail 'legacy root overlaps an authority root' + done +fi + +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' + +uid=$(id -u) +gid=$(id -g) +[ "$uid" -eq 0 ] && allow_root_service=true || allow_root_service=false +plan_root="$reconciliation_root/plan" +review_root="$reconciliation_root/review" +diagnostic_root="$reconciliation_root/diagnostics" +application_root="$reconciliation_root/application" +automation_root="$reconciliation_root/automation" +automation_decision_root="$reconciliation_root/automation-decision" +automation_apply_root="$reconciliation_root/automation-apply" +command_root="$reconciliation_root/commands" +result_root="$reconciliation_root/results" +target_database="$rehearsal_root/sqlite/qinglong3.sqlite" +issuer_keyring="$rehearsal_root/reconciliation-review-issuer.keyring" +owner_peppers="$rehearsal_root/owner-peppers" +owner_credential="$rehearsal_root/owner-credential.json" + +run_deploy() { + subcommand=$1 + command_file=$2 + result_file=$3 + input_file=${4:-} + set -- 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=$rehearsal_root,dst=$rehearsal_root" \ + --mount "type=bind,src=$capture_root,dst=$capture_root,readonly" \ + --mount "type=bind,src=$reconciliation_root,dst=$reconciliation_root" + [ -z "$legacy_root" ] || set -- "$@" \ + --mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" + [ -z "$input_file" ] || set -- "$@" \ + --mount "type=bind,src=$decision_parent,dst=$decision_parent,readonly" + set -- "$@" "$OPERATOR_IMAGE" deploy "$subcommand" \ + --command-file "$command_root/$command_file" + result_stage="$result_root/.$result_file.$$" + [ ! -e "$result_stage" ] || fail 'result staging path already exists' + if "$@" >"$result_stage"; then + chmod 0600 "$result_stage" + mv -f "$result_stage" "$result_root/$result_file" + else + status=$? + rm -f "$result_stage" + return "$status" + fi +} + +if [ "$mode" = prepare ]; then + [ ! -e "$reconciliation_root" ] || fail 'new reconciliation root already exists' + reconciliation_parent=${reconciliation_root%/*} + [ -n "$reconciliation_parent" ] || reconciliation_parent=/ + canonical_directory "$reconciliation_parent" 'reconciliation root parent' + case "$phase_input" in + none) legacy_timezone_json=null; legacy_timezone_summary=none ;; + ''|*[!A-Za-z0-9._+/-]*) fail 'legacy timezone is invalid' ;; + *) legacy_timezone_json="\"$phase_input\""; legacy_timezone_summary=$phase_input ;; + esac + old_umask=$(umask) + umask 077 + mkdir -m 0700 "$reconciliation_root" + for directory in plan review diagnostics application automation automation-decision automation-apply commands results; do + mkdir -m 0700 "$reconciliation_root/$directory" + done + umask "$old_umask" + capture_result="$rehearsal_root/results/reconciliation-capture-verify.result.json" + capture_commit_result="$rehearsal_root/results/reconciliation-capture-commit.result.json" + grep -q '"status":"verified"' "$capture_result" || fail 'verified reconciliation capture is missing' + capture_id=$(extract_capture_id "$capture_result") + capture_bundle_digest=$(extract_digest "$capture_commit_result" bundleDigest) + capture_head_digest=$(extract_digest "$capture_result" instanceHeadDigest) + prepared_ms=$(($(date +%s) * 1000)) + cat >"$command_root/plan-prepare.json" <"$command_root/plan-commit.json" <"$command_root/plan-verify.json" <"$command_root/review-prepare.json" <"$command_root/diagnostic-$stem.json" <"$reconciliation_root/summary.json" <"$command_root/review-commit.json" <"$command_root/review-verify.json" <"$command_root/application-prepare.json" <"$command_root/application-commit.json" <"$command_root/application-verify.json" <"$command_root/automation-plan.json" <"$command_root/automation-verify.json" <"$command_root/automation-decision-prepare.json" <"$reconciliation_root/summary.json" <"$command_root/automation-decision-commit.json" <"$command_root/automation-decision-verify.json" <"$command_root/automation-apply.json" <"$command_root/automation-apply-verify.json" <"$command_root/automation-rollback.json" <"$reconciliation_root/summary.json" <"$evidence_root/results/$result_file" + result_stage="$evidence_root/results/.$result_file.$$" + attempt=1 + while [ "$attempt" -le 2 ]; do + if 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=$evidence_root,dst=/var/lib/qinglong3" \ + "$OPERATOR_IMAGE" adoption run \ + --command-file "/var/lib/qinglong3/$command_file" \ + >"$result_stage" + then + chmod 0600 "$result_stage" + mv "$result_stage" "$evidence_root/results/$result_file" + return 0 + fi + attempt=$((attempt + 1)) + done + return 1 } run_inspect sqlite-inspect.json sqlite-inspect.result.json diff --git a/test/back/ql3AlphaStageIndex.test.cjs b/test/back/ql3AlphaStageIndex.test.cjs index 2373e68c..873796c3 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: 5, - schema: 'qinglong/alpha-local-milestone@v5', + schemaVersion: 6, + schema: 'qinglong/alpha-local-milestone@v6', maturity: 'alpha_candidate_not_public_release', product: 'local', variant, @@ -104,6 +104,7 @@ function localManifest(attempt = runAttempt, variant = 'headless') { upgradeReadinessSha256: digest('b'), upgradeRehearsalSha256: digest('d'), upgradeCutoverRehearsalSha256: digest('f'), + upgradeReconciliationRehearsalSha256: digest('7'), }, arm64: { artifactName: `ql3-alpha-${revision}-local-${variant}-arm64`, @@ -120,6 +121,7 @@ function localManifest(attempt = runAttempt, variant = 'headless') { upgradeReadinessSha256: digest('c'), upgradeRehearsalSha256: digest('e'), upgradeCutoverRehearsalSha256: digest('0'), + upgradeReconciliationRehearsalSha256: digest('8'), }, }, readme: null, diff --git a/test/back/ql3LocalAlphaMilestone.test.cjs b/test/back/ql3LocalAlphaMilestone.test.cjs index bce57209..409035ca 100644 --- a/test/back/ql3LocalAlphaMilestone.test.cjs +++ b/test/back/ql3LocalAlphaMilestone.test.cjs @@ -206,7 +206,7 @@ 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@v5'); + assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v6'); assert.match( manifest.artifacts.amd64.upgradeReadinessSha256, /^sha256:[0-9a-f]{64}$/, @@ -219,6 +219,10 @@ test('finalizes two exact native trial kits into one closed milestone index', (t manifest.artifacts.amd64.upgradeCutoverRehearsalSha256, /^sha256:[0-9a-f]{64}$/, ); + assert.match( + manifest.artifacts.amd64.upgradeReconciliationRehearsalSha256, + /^sha256:[0-9a-f]{64}$/, + ); assert.equal(manifest.variant, 'headless'); assert.equal(manifest.sourceRevision, revision); assert.deepEqual(Object.keys(manifest.artifacts), ['amd64', 'arm64']); @@ -247,7 +251,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'); + assert.equal(report.schema, 'qinglong/alpha-local-milestone-audit@v6'); }); test('finalizes Console trial kits as a separately named milestone', (t) => { diff --git a/test/back/ql3LocalAlphaReconciliationDecisionFixture.test.cjs b/test/back/ql3LocalAlphaReconciliationDecisionFixture.test.cjs new file mode 100644 index 00000000..4f11d44a --- /dev/null +++ b/test/back/ql3LocalAlphaReconciliationDecisionFixture.test.cjs @@ -0,0 +1,357 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + automationFixture, + parseArguments, + reviewFixture, +} = require('../../scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs'); + +const DOMAINS = [ + 'schema_lineage', + 'automation', + 'secret_and_config', + 'run_history', + 'plugin_package', + 'ai_and_tool', + 'identity_policy_audit', + 'unknown', +]; +const PLAN_DIGEST = '1'.repeat(64); +const PREPARATION_DIGEST = '2'.repeat(64); +const AUTOMATION_PLAN_DIGEST = '3'.repeat(64); +const INVENTORY_DIGEST = '4'.repeat(64); +const REVIEW_ID = '019f8680-143d-4000-8000-000000000301'; +const AUTOMATION_ID = '019f8680-143d-4000-8000-000000000461'; +const DECISION_ID = '019f8680-143d-7000-8000-000000000471'; + +function fixture(t) { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-reconciliation-decisions-')), + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'diagnostics'), { mode: 0o700 }); + let recordCount = 0; + for (const database of ['legacy', 'target']) { + for (const domain of DOMAINS) { + for (const factKind of ['schema_object', 'table']) { + const records = []; + if ( + database === 'legacy' && + domain === 'automation' && + factKind === 'table' + ) { + records.push({ + schema: 'qinglong3-local-reconciliation-diagnostic-fact', + schemaVersion: 1, + ordinal: 1, + database, + domain, + factKind, + objectType: 'table', + name: 'Crontabs', + tableName: 'Crontabs', + rowCount: '1', + decisionRequirement: 'required', + reason: 'reviewable_fact', + factDigest: 'a'.repeat(64), + }); + } + if ( + database === 'legacy' && + domain === 'unknown' && + factKind === 'table' + ) { + records.push({ + schema: 'qinglong3-local-reconciliation-diagnostic-fact', + schemaVersion: 1, + ordinal: 1, + database, + domain, + factKind, + objectType: 'table', + name: 'PluginOwnedState', + tableName: 'PluginOwnedState', + rowCount: null, + decisionRequirement: 'blocked', + reason: 'unknown_schema', + factDigest: 'b'.repeat(64), + }); + } + if ( + database === 'legacy' && + domain === 'run_history' && + factKind === 'table' + ) { + records.push({ + schema: 'qinglong3-local-reconciliation-diagnostic-fact', + schemaVersion: 1, + ordinal: 1, + database, + domain, + factKind, + objectType: 'table', + name: 'ExecutionHistory', + tableName: 'ExecutionHistory', + rowCount: '1', + decisionRequirement: 'required', + reason: 'historical_preservation_required', + factDigest: 'd'.repeat(64), + }); + } + if ( + database === 'target' && + domain === 'automation' && + factKind === 'table' + ) { + records.push({ + schema: 'qinglong3-local-reconciliation-diagnostic-fact', + schemaVersion: 1, + ordinal: 1, + database, + domain, + factKind, + objectType: 'table', + name: 'QingLong3TaskDefinitions', + tableName: 'QingLong3TaskDefinitions', + rowCount: '1', + decisionRequirement: 'required', + reason: 'reviewable_fact', + factDigest: 'c'.repeat(64), + }); + } + recordCount += records.length; + fs.writeFileSync( + path.join( + root, + 'diagnostics', + `${database}-${domain}-${factKind}-0.json`, + ), + `${JSON.stringify({ + schema: 'qinglong3-local-reconciliation-diagnostic-page', + schemaVersion: 1, + state: 'reconciliation_review_prepared', + reviewId: REVIEW_ID, + planId: '019f8680-143d-4000-8000-000000000201', + planDigest: PLAN_DIGEST, + preparationDigest: PREPARATION_DIGEST, + bundleDigest: 'd'.repeat(64), + bundleFingerprintDigest: 'e'.repeat(64), + database, + domain, + factKind, + offset: 0, + limit: 64, + recordCount: records.length, + complete: true, + nextOffset: null, + records, + pageDigest: 'f'.repeat(64), + })}\n`, + { mode: 0o600 }, + ); + } + } + } + fs.writeFileSync( + path.join(root, 'summary.json'), + `${JSON.stringify({ + schemaVersion: 1, + schema: 'qinglong/local-alpha-reconciliation-rehearsal-summary@v1', + status: 'operator_decision_required', + profile: 'edge', + plan: { + planId: '019f8680-143d-4000-8000-000000000201', + planDigest: PLAN_DIGEST, + }, + review: { + reviewId: REVIEW_ID, + preparationDigest: PREPARATION_DIGEST, + diagnosticPages: 32, + diagnosticRecords: recordCount, + }, + })}\n`, + { mode: 0o600 }, + ); + return { root, recordCount }; +} + +test('builds explicit synthetic review decisions without approving blocked facts', (t) => { + const state = fixture(t); + const output = path.join( + path.dirname(state.root), + `${path.basename(state.root)}.review.ndjson`, + ); + t.after(() => fs.rmSync(output, { force: true })); + const report = reviewFixture(state.root, output); + assert.equal(report.adoptedAutomationTables, 1); + const records = fs + .readFileSync(output, 'utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)); + assert.equal( + records[0].kind, + 'qinglong3-local-reconciliation-review-decision-header', + ); + const crontabs = records.find( + (record) => record.factDigest === 'a'.repeat(64), + ); + const unknown = records.find( + (record) => record.factDigest === 'b'.repeat(64), + ); + const target = records.find((record) => record.factDigest === 'c'.repeat(64)); + const legacyRunHistory = records.find( + (record) => record.factDigest === 'd'.repeat(64), + ); + assert.deepEqual( + [crontabs.disposition, crontabs.reason], + ['adopt_legacy', 'prefer_legacy'], + ); + assert.deepEqual( + [unknown.disposition, unknown.reason], + ['manual_external', 'external_recovery_required'], + ); + assert.deepEqual( + [target.disposition, target.reason], + ['retain_target', 'preserve_target'], + ); + assert.deepEqual( + [legacyRunHistory.disposition, legacyRunHistory.reason], + ['manual_external', 'external_recovery_required'], + ); + assert.equal(fs.statSync(output).mode & 0o777, 0o600); +}); + +test('review fixture fails closed without the exact Legacy Crontabs fact', (t) => { + const state = fixture(t); + const pagePath = path.join( + state.root, + 'diagnostics/legacy-automation-table-0.json', + ); + const page = JSON.parse(fs.readFileSync(pagePath, 'utf8')); + page.records[0].tableName = 'CronLikeState'; + fs.writeFileSync(pagePath, `${JSON.stringify(page)}\n`); + const output = path.join( + path.dirname(state.root), + `${path.basename(state.root)}.invalid.ndjson`, + ); + assert.throws( + () => reviewFixture(state.root, output), + /exactly one reviewable Legacy Crontabs table/, + ); + assert.equal(fs.existsSync(output), false); +}); + +function automationState(t) { + const state = fixture(t); + fs.writeFileSync( + path.join(state.root, 'summary.json'), + `${JSON.stringify({ + schemaVersion: 1, + schema: 'qinglong/local-alpha-reconciliation-rehearsal-summary@v1', + status: 'automation_decision_required', + profile: 'edge', + automation: { + automationId: AUTOMATION_ID, + automationPlanDigest: AUTOMATION_PLAN_DIGEST, + rowCount: 1, + eligibleCount: 1, + conflictCount: 0, + }, + decision: { decisionId: DECISION_ID }, + })}\n`, + ); + const directory = path.join(state.root, 'automation', AUTOMATION_ID); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(directory, 'receipt.json'), + `${JSON.stringify({ + schema: 'qinglong3-local-reconciliation-automation-plan-receipt', + schemaVersion: 1, + automationId: AUTOMATION_ID, + automationPlanDigest: AUTOMATION_PLAN_DIGEST, + rowCount: 1, + eligibleCount: 1, + manualCount: 0, + conflictCount: 0, + legacyInventoryDigest: INVENTORY_DIGEST, + })}\n`, + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(directory, 'plan.ndjson'), + `${JSON.stringify({ + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-automation-plan-row', + rowOrdinal: 1, + sourceDigest: '5'.repeat(64), + requirement: 'review_adopt', + target: { state: 'absent' }, + })}\n`, + { mode: 0o600 }, + ); + return state; +} + +test('builds one reviewed-lossless Automation row decision', (t) => { + const state = automationState(t); + const output = path.join( + path.dirname(state.root), + `${path.basename(state.root)}.automation.ndjson`, + ); + t.after(() => fs.rmSync(output, { force: true })); + const report = automationFixture(state.root, output); + assert.equal(report.decisionCount, 1); + const records = fs + .readFileSync(output, 'utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)); + assert.equal(records[0].decisionId, DECISION_ID); + assert.deepEqual(records[1].decision, { + rowOrdinal: 1, + sourceDigest: '5'.repeat(64), + disposition: 'adopt', + reason: 'reviewed_lossless', + }); +}); + +test('Automation fixture rejects a conflict before writing decisions', (t) => { + const state = automationState(t); + const summaryPath = path.join(state.root, 'summary.json'); + const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); + summary.automation.eligibleCount = 0; + summary.automation.conflictCount = 1; + fs.writeFileSync(summaryPath, `${JSON.stringify(summary)}\n`); + const output = path.join( + path.dirname(state.root), + `${path.basename(state.root)}.conflict.ndjson`, + ); + assert.throws( + () => automationFixture(state.root, output), + /one conflict-free eligible Automation row/, + ); + assert.equal(fs.existsSync(output), false); +}); + +test('CLI grammar is exact', () => { + assert.throws( + () => parseArguments(['--mode=review', '--output=/tmp/review.ndjson']), + /fixture arguments are invalid/, + ); + assert.throws( + () => + parseArguments([ + '--mode=completion', + '--reconciliation-root=/tmp/root', + '--output=/tmp/review.ndjson', + ]), + /fixture arguments are invalid/, + ); +}); diff --git a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs index 3180291b..88ed4dc3 100644 --- a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs +++ b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs @@ -166,8 +166,8 @@ function adapters(overrides = {}, variant = 'headless') { test('materializes and offline-audits one closed two-image trial kit', (t) => { const paths = fixture(t); const manifest = createLocalAlphaTrialKit(createOptions(paths), adapters()); - assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v9'); - assert.equal(manifest.schemaVersion, 10); + assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v10'); + assert.equal(manifest.schemaVersion, 11); assert.equal(manifest.variant, 'headless'); assert.equal(manifest.sourceRevision, revision); assert.equal(manifest.architecture, 'arm64'); @@ -182,6 +182,10 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { manifest.upgradeCutoverRehearsal.file, 'upgrade-cutover-rehearsal.sh', ); + assert.equal( + manifest.upgradeReconciliationRehearsal.file, + 'reconciliation-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); @@ -343,6 +347,78 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { 'stoppedAuthority', 'targetDatabasePath', ]); + const reconciliationRehearsal = path.join( + paths.outputRoot, + 'reconciliation-rehearsal.sh', + ); + const reconciliationSyntax = spawnSync( + 'sh', + ['-n', reconciliationRehearsal], + { + encoding: 'utf8', + }, + ); + assert.equal(reconciliationSyntax.status, 0, reconciliationSyntax.stderr); + const reconciliationContents = fs.readFileSync( + reconciliationRehearsal, + 'utf8', + ); + assert.match(reconciliationContents, /VARIANT='headless'/); + for (const operation of [ + 'reconciliation.plan.prepare', + 'reconciliation.review.diagnostics', + 'reconciliation.review.commit', + 'reconciliation.application.commit', + 'reconciliation.automation.decision.commit', + 'reconciliation.automation.apply', + 'reconciliation.automation.apply.rollback', + ]) { + assert.match(reconciliationContents, new RegExp(operation)); + } + assert.match(reconciliationContents, /--memory 128m --memory-swap 128m/); + assert.match(reconciliationContents, /--network none/); + assert.match( + reconciliationContents, + /\[ "\$\(stat -c %a "\$decision_parent"\)" = 700 \]/, + ); + assert.match( + reconciliationContents, + /type=bind,src=\$decision_parent,dst=\$decision_parent,readonly/, + ); + assert.match( + reconciliationContents, + /type=bind,src=\$legacy_root,dst=\$legacy_root,readonly/, + ); + assert.match( + reconciliationContents, + /apply-rollback edge\|standalone .* \/absolute\/legacy-root/, + ); + assert.match(reconciliationContents, /result_stage=.*\.\$result_file\.\$\$/); + assert.match( + reconciliationContents, + /\[ -e "\$command_root\/automation-apply\.json" \]/, + ); + assert.doesNotMatch( + reconciliationContents, + /type=bind,src=\$input_file,dst=\$input_file,readonly/, + ); + assert.match( + reconciliationContents, + /\$decision_label parent must contain only the selected decision file/, + ); + assert.equal( + (reconciliationContents.match(/"authorizationLifetimeMs":60000/g) || []) + .length, + 2, + ); + assert.doesNotMatch( + reconciliationContents, + /"authorizationLifetimeMs":1800000/, + ); + assert.match(reconciliationContents, /! -path "\$decision_file"/); + assert.match(reconciliationContents, /automaticDecision":"not_authorized/); + assert.match(reconciliationContents, /automaticRowDecision":"not_authorized/); + assert.match(reconciliationContents, /"completion":"not_attempted"/); const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); assert.equal(report.compatible, true); assert.equal(report.sourceRevision, revision); @@ -356,6 +432,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', + 'reconciliation-rehearsal.sh', 'upgrade-cutover-rehearsal.sh', 'upgrade-readiness.sh', 'upgrade-rehearsal.sh', @@ -395,6 +472,10 @@ test('materializes a distinct loopback Console trial kit without widening the he assert.equal(verification.gates.legacyUpgradeStage, 'passed'); assert.equal(verification.gates.legacyUpgradeCutover, 'passed'); assert.equal(verification.gates.legacyUpgradeReconciliationCapture, 'passed'); + assert.equal( + verification.gates.legacyUpgradeReconciliationAutomationRollback, + 'passed', + ); const quickstartContents = fs.readFileSync( path.join(paths.outputRoot, 'quickstart.sh'), 'utf8', @@ -429,6 +510,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', + 'reconciliation-rehearsal.sh', 'upgrade-cutover-rehearsal.sh', 'upgrade-readiness.sh', 'upgrade-rehearsal.sh', @@ -467,6 +549,7 @@ test('offline audit rejects archive, file-set, SBOM and verification mutation', 'upgrade-readiness', 'upgrade-rehearsal', 'upgrade-cutover-rehearsal', + 'upgrade-reconciliation-rehearsal', 'sbom', 'verification', ]) { @@ -503,6 +586,11 @@ test('offline audit rejects archive, file-set, SBOM and verification mutation', path.join(paths.outputRoot, 'upgrade-cutover-rehearsal.sh'), '# drift\n', ); + } else if (mutation === 'upgrade-reconciliation-rehearsal') { + fs.appendFileSync( + path.join(paths.outputRoot, 'reconciliation-rehearsal.sh'), + '# drift\n', + ); } else if (mutation === 'sbom') { fs.copyFileSync( path.join(paths.outputRoot, 'qinglong3-local-application.cdx.json'), @@ -544,6 +632,7 @@ test('offline audit rejects a rehashed non-canonical quickstart', (t) => { 'upgrade-readiness.sh', 'upgrade-rehearsal.sh', 'upgrade-cutover-rehearsal.sh', + 'reconciliation-rehearsal.sh', 'README.md', 'manifest.json', ]; @@ -652,7 +741,7 @@ exit 1 assert.doesNotMatch(calls, /--network (?!none)/); }); -test('generated upgrade readiness drives two read-only legacy inspections', (t) => { +test('generated upgrade readiness retries one transient read-only inspection', (t) => { const paths = fixture(t); createLocalAlphaTrialKit(createOptions(paths), adapters()); const fakeBin = path.join(paths.fixtureRoot, 'readiness-fake-bin'); @@ -676,9 +765,16 @@ 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 ;; + *'/data-directory-inspect.json'*) + if [ ! -e "$FAKE_DOCKER_DATA_ATTEMPT" ]; then + : >"$FAKE_DOCKER_DATA_ATTEMPT" + exit 1 + fi + printf '%s\\n' '{"status":"inspected","evidence":{"planDigest":"${'b'.repeat( + 64, + )}"}}' + exit 0 + ;; esac exit 1 `, @@ -702,6 +798,10 @@ exit 1 encoding: 'utf8', env: { ...process.env, + FAKE_DOCKER_DATA_ATTEMPT: path.join( + paths.fixtureRoot, + 'readiness-data-attempt', + ), FAKE_DOCKER_LOG: dockerLog, PATH: `${fakeBin}:${process.env.PATH}`, }, @@ -740,6 +840,19 @@ exit 1 ); assert.match(calls, /--network none/); assert.match(calls, /--memory 128m --memory-swap 128m/); + assert.equal((calls.match(/\/sqlite-inspect\.json/g) ?? []).length, 1); + assert.equal( + (calls.match(/\/data-directory-inspect\.json/g) ?? []).length, + 2, + ); + assert.equal( + fs + .readdirSync(path.join(evidenceRoot, 'results')) + .some((entry) => + entry.startsWith('.data-directory-inspect.result.json.'), + ), + false, + ); assert.doesNotMatch(calls, /adoption\.stage|activation\.prepare|cutover/); }); @@ -864,6 +977,20 @@ test('create rejects verification without the exact reconciliation capture gate' assert.equal(fs.existsSync(paths.outputRoot), false); }); +test('create rejects verification without the reviewed Automation rollback gate', (t) => { + const paths = fixture(t); + const evidence = JSON.parse( + fs.readFileSync(paths.verificationEvidence, 'utf8'), + ); + delete evidence.gates.legacyUpgradeReconciliationAutomationRollback; + fs.writeFileSync(paths.verificationEvidence, `${JSON.stringify(evidence)}\n`); + assert.throws( + () => createLocalAlphaTrialKit(createOptions(paths), adapters()), + /verification evidence is incompatible/, + ); + assert.equal(fs.existsSync(paths.outputRoot), false); +}); + test('verification recorder rejects non-milestone workflow provenance', (t) => { const paths = fixture(t); const output = path.join(paths.fixtureRoot, 'unreviewed-verification.json');