diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 60834169..90e3ab95 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -790,11 +790,22 @@ jobs: 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" + COMPLETION_LEGACY_ROOT="${RUNNER_TEMP}/ql3-alpha-completion-legacy-${{ matrix.image_arch }}" + COMPLETION_UPGRADE_EVIDENCE_ROOT="${RUNNER_TEMP}/ql3-alpha-completion-upgrade-evidence-${{ matrix.image_arch }}" + COMPLETION_REHEARSAL_ROOT="${RUNNER_TEMP}/ql3-alpha-completion-rehearsal-${{ matrix.image_arch }}" + COMPLETION_CAPTURE_ROOT="${RUNNER_TEMP}/ql3-alpha-completion-capture-${{ matrix.image_arch }}" + COMPLETION_FLOW_ROOT="${RUNNER_TEMP}/ql3-alpha-completion-flow-${{ matrix.image_arch }}" + COMPLETION_DECISION_ROOT="${RUNNER_TEMP}/ql3-alpha-completion-decisions-${{ matrix.image_arch }}" + COMPLETION_REVIEW_DECISION_ROOT="${COMPLETION_DECISION_ROOT}/review" + COMPLETION_AUTOMATION_DECISION_ROOT="${COMPLETION_DECISION_ROOT}/automation" + COMPLETION_SECRET_CONFIG_DECISION_ROOT="${COMPLETION_DECISION_ROOT}/secret-config" 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 }}" RECONCILIATION_TARGET_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-reconciliation-target-${{ matrix.image_arch }}" - trap 'docker rm --force "${QUICKSTART_CONTAINER}" "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}" "${RECONCILIATION_TARGET_CONTAINER}" "${RECONCILIATION_LEGACY_CONTAINER}" >/dev/null 2>&1 || true' EXIT + COMPLETION_LEGACY_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-completion-legacy-${{ matrix.image_arch }}" + COMPLETION_TARGET_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-completion-target-${{ matrix.image_arch }}" + trap 'docker rm --force "${QUICKSTART_CONTAINER}" "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}" "${RECONCILIATION_TARGET_CONTAINER}" "${RECONCILIATION_LEGACY_CONTAINER}" "${COMPLETION_TARGET_CONTAINER}" "${COMPLETION_LEGACY_CONTAINER}" >/dev/null 2>&1 || true' EXIT sh "${BUNDLE_ROOT}/upgrade-cutover-rehearsal.sh" \ edge "${LEGACY_READINESS_ROOT}" "${UPGRADE_REHEARSAL_ROOT}" \ "${SQLITE_PLAN_DIGEST}" "${DIRECTORY_PLAN_DIGEST}" \ @@ -882,6 +893,79 @@ jobs: 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" + + node scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs \ + --shape=completion-ready \ + --output="${COMPLETION_LEGACY_ROOT}" + sh "${BUNDLE_ROOT}/upgrade-readiness.sh" \ + edge "${COMPLETION_LEGACY_ROOT}" "${COMPLETION_UPGRADE_EVIDENCE_ROOT}" + COMPLETION_SQLITE_PLAN_DIGEST="$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).evidence.planDigest)' \ + "${COMPLETION_UPGRADE_EVIDENCE_ROOT}/results/sqlite-inspect.result.json")" + COMPLETION_DIRECTORY_PLAN_DIGEST="$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).evidence.planDigest)' \ + "${COMPLETION_UPGRADE_EVIDENCE_ROOT}/results/data-directory-inspect.result.json")" + sh "${BUNDLE_ROOT}/upgrade-cutover-rehearsal.sh" \ + edge "${COMPLETION_LEGACY_ROOT}" "${COMPLETION_REHEARSAL_ROOT}" \ + "${COMPLETION_SQLITE_PLAN_DIGEST}" "${COMPLETION_DIRECTORY_PLAN_DIGEST}" \ + "${COMPLETION_LEGACY_CONTAINER}" "${COMPLETION_TARGET_CONTAINER}" \ + --capture-after-write "${COMPLETION_CAPTURE_ROOT}" + grep -q '"status":"reconciliation_captured"' \ + "${COMPLETION_REHEARSAL_ROOT}/reconciliation-capture-summary.json" + install -d -m 0700 \ + "${COMPLETION_DECISION_ROOT}" \ + "${COMPLETION_REVIEW_DECISION_ROOT}" \ + "${COMPLETION_AUTOMATION_DECISION_ROOT}" \ + "${COMPLETION_SECRET_CONFIG_DECISION_ROOT}" + sh "${BUNDLE_ROOT}/reconciliation-rehearsal.sh" \ + prepare edge "${COMPLETION_REHEARSAL_ROOT}" \ + "${COMPLETION_CAPTURE_ROOT}" "${COMPLETION_FLOW_ROOT}" \ + Asia/Shanghai + node scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs \ + --mode=completion-review \ + --reconciliation-root="${COMPLETION_FLOW_ROOT}" \ + --output="${COMPLETION_REVIEW_DECISION_ROOT}/review.ndjson" + sh "${BUNDLE_ROOT}/reconciliation-rehearsal.sh" \ + review edge "${COMPLETION_REHEARSAL_ROOT}" \ + "${COMPLETION_CAPTURE_ROOT}" "${COMPLETION_FLOW_ROOT}" \ + "${COMPLETION_REVIEW_DECISION_ROOT}/review.ndjson" + node scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs \ + --mode=automation \ + --reconciliation-root="${COMPLETION_FLOW_ROOT}" \ + --output="${COMPLETION_AUTOMATION_DECISION_ROOT}/automation.ndjson" + sh "${BUNDLE_ROOT}/reconciliation-rehearsal.sh" \ + apply-plan edge "${COMPLETION_REHEARSAL_ROOT}" \ + "${COMPLETION_CAPTURE_ROOT}" "${COMPLETION_FLOW_ROOT}" \ + "${COMPLETION_AUTOMATION_DECISION_ROOT}/automation.ndjson" \ + "${COMPLETION_REVIEW_DECISION_ROOT}/review.ndjson" \ + "${COMPLETION_LEGACY_ROOT}" + grep -q '"status":"secret_config_decision_required"' \ + "${COMPLETION_FLOW_ROOT}/summary.json" + grep -q '"automaticCandidateDecision":"not_authorized"' \ + "${COMPLETION_FLOW_ROOT}/summary.json" + grep -q '"state":"reconciliation_run_history_preserved"' \ + "${COMPLETION_FLOW_ROOT}/results/run-history-verify.result.json" + node scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs \ + --mode=secret-config \ + --reconciliation-root="${COMPLETION_FLOW_ROOT}" \ + --output="${COMPLETION_SECRET_CONFIG_DECISION_ROOT}/secret-config.ndjson" + sh "${BUNDLE_ROOT}/reconciliation-rehearsal.sh" \ + complete edge "${COMPLETION_REHEARSAL_ROOT}" \ + "${COMPLETION_CAPTURE_ROOT}" "${COMPLETION_FLOW_ROOT}" \ + "${COMPLETION_SECRET_CONFIG_DECISION_ROOT}/secret-config.ndjson" \ + "${COMPLETION_REVIEW_DECISION_ROOT}/review.ndjson" \ + "${COMPLETION_LEGACY_ROOT}" + grep -q '"status":"reconciliation_completed"' \ + "${COMPLETION_FLOW_ROOT}/summary.json" + grep -q '"adapterCount":3' \ + "${COMPLETION_FLOW_ROOT}/summary.json" + grep -q '"targetRestart":"not_authorized"' \ + "${COMPLETION_FLOW_ROOT}/summary.json" + grep -q '"legacyRestart":"not_authorized"' \ + "${COMPLETION_FLOW_ROOT}/summary.json" + grep -q '"status":"verified"' \ + "${COMPLETION_FLOW_ROOT}/results/completion-verify.result.json" + docker rm "${COMPLETION_TARGET_CONTAINER}" "${COMPLETION_LEGACY_CONTAINER}" + test ! -e "${COMPLETION_LEGACY_ROOT}/db/database.sqlite-wal" + test ! -e "${COMPLETION_LEGACY_ROOT}/db/database.sqlite-journal" trap - EXIT - name: Upload the tested native Local Alpha trial kit if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all') diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 0ba9f3e3..d116b588 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-426c3/ADR-0528(源码候选,等待 exact 双架构阶段实物):在 D-426c2 的三阶段回滚链上保留 `apply-rollback`,新增显式 `apply-plan` 与 `complete`。完成型 CI 使用完整 2.x schema、空 Apps/Auths、无未知插件表的独立 fixture;外部 completion review 对 blocked 事实直接拒绝,对 Legacy/Target Run History 分别授权 `retain_both/retain_target`,对 Secret/Config 只授权 `manual_external`。`apply-plan` 消费互相独立的 Automation/review decision,依次完成 Automation apply/verify、双侧终态 Run History preservation/verify、Secret/Config plan/verify 和 decision prepare,停在 `secret_config_decision_required`;`complete` 再消费独立 Secret/Config decision,完成 decision/apply/verify,并以 completion v3 同时绑定三类 adapter,要求 `reconciliation_completed`、`adapterCount=3`。Run History 必须位于 Automation apply 与会推进 head 的 Secret/Config plan 之间。completion 后 target/Legacy 仍 stopped,两个 restart 都是 `not_authorized`。针对 readiness 要求 Apps/Auths 必须存在而旧诊断又无条件阻塞 identity 域的矛盾,仅把“Legacy 已知 identity 表全部为空”收窄为 `informational/catalog_evidence`;任一身份行、未知表、目标 identity 或异常仍 fail-closed。Trial Kit/verification/auditor 升为 `@v11/@v9/@v8`、manifest schemaVersion 12,Local milestone 升为 `@v7`/schemaVersion 7,并新增 required `legacyUpgradeReconciliationCompletion=passed`;CI 仍独立保留 Automation apply→rollback 实证,completion 不覆盖 recovery 证据。本切片不增加 package、production dependency、daemon/listener/timer/watcher/连接或稳态资源,默认低配 headless 与 Cluster authority 边界不变。 + - D-426c2/ADR-0527(exact headless 双架构阶段实物已交付):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。首次普通 CI run `33525269537` 在 x64 暴露既有兼容测试的 module-scope `TaskLimit` 异步 SQLite 初始化与临时目录清理竞态,未以重跑掩盖;隔离测试副作用的提交 `c8d9eed95d402aae642e81e60fce336670ac06a0` 后,普通主 CI [run 33526720941](https://github.com/whyour/qinglong/actions/runs/33526720941) 为 41 success/3 expected skip/0 fail,Kubernetes [run 33526721040](https://github.com/whyour/qinglong/actions/runs/33526721040) 成功,显式 Local headless [run 33528370769](https://github.com/whyour/qinglong/actions/runs/33528370769) 为 42 success/2 scope skip/0 fail。该 run 交付 amd64/arm64/milestone artifact `9809046864`/`9809000920`/`9809293769`,大小 `226266116`/`221665796`/`6734` bytes,GitHub digest 为 `sha256:bffabf76c9d7b599c5ac65dd4ff7aa8f65af6a3167a68afb497a9c8967f34c36`、`sha256:8b74e5e9e1437962185fb87e7b0208bf157d063af28dfb1676c59b9785b937b8`、`sha256:a69de90a07bc36a397b08bb567739a001ac2f830fed73860255db7de175b3527`,保留至 2026-10-01;milestone v6 下载后 auditor 返回 `compatible=true`,并绑定双架构 reconciliation script digest。该切片不新增 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。 diff --git a/docs/adr/ADR-0528-cross-domain-reconciliation-completion-rehearsal.md b/docs/adr/ADR-0528-cross-domain-reconciliation-completion-rehearsal.md new file mode 100644 index 00000000..d15b0bf2 --- /dev/null +++ b/docs/adr/ADR-0528-cross-domain-reconciliation-completion-rehearsal.md @@ -0,0 +1,34 @@ +# ADR-0528:跨域 reconciliation completion 演练 + +- 状态:Accepted(D-426c3 源码候选,阶段实物以双架构 artifact gate 为准) +- 日期:2026-09-02 +- 关联:ADR-0526、ADR-0527、D-397、D-398、D-399、D-426c3 + +## 背景 + +ADR-0527 已证明 exact stopped capture 可以经外部 review/Automation 决策完成一次正式 Automation 应用并回滚,但交付链仍停在 `reconciliation_automation_rolled_back`。产品代码已经具备 Secret/Config plan/decision/apply、Run History append-only preservation 和 completion v3;缺口在于 Trial Kit 没有把这些能力组成一个可下载、可审计、可中断重放的阶段产物。 + +同时,原有诊断把 Legacy `Apps`/`Auths` 的空表与含真实身份数据的表一律视为 `identity_custody_required`。2.x readiness 又要求这两张表存在,因此删除表制造“可完成样本”会被正式 inspector 拒绝,而保留空表又永远无法完成。这是状态分类不精确,不应由 fixture 绕过。 + +## 决策 + +1. `reconciliation-rehearsal.sh` 保留 ADR-0527 的 `prepare`、`review`、`apply-rollback`,新增 `apply-plan` 与 `complete`,不把人工决策合并为一个自动命令。 +2. `apply-plan` 消费两个互相独立的 owner-private 文件:Automation row decision 和原始 review decision。它先应用并验证 Automation,再在该 exact head 上保存双侧终态 Run History,随后生成并验证 Secret/Config plan,停在 `secret_config_decision_required`。 +3. `complete` 消费外部 Secret/Config candidate decision 和原始 review decision,执行 decision commit/verify、apply/verify,并以 completion v3 同时绑定 Automation apply、Secret/Config apply 和 Run History preservation。成功终态固定为 `reconciliation_completed` 且 `adapterCount=3`。 +4. Run History 必须位于 Automation apply 与 Secret/Config plan 之间。preservation 不推进 instance head;Secret/Config plan 会推进 head。倒置顺序会使 preservation 丢失允许的 compare-and-swap 来源状态。 +5. target 和 Legacy 在 completion 后继续保持 stopped。脚本固定 `targetRestart=not_authorized`、`legacyRestart=not_authorized`;重启属于后续独立 authority ceremony。 +6. 外部 decision 目录必须为 current-UID `0700` canonical directory、恰好包含一个 `0400|0600` regular file、与所有 authority/Legacy root 不重叠,并整体只读挂载。两个 decision 不得放在同一父目录。 +7. completion-ready 2.x fixture 保留 readiness 所需完整 schema,只移除未知 `PluginOwnedState`,并让 `Apps`/`Auths` 保持空表。诊断仅在 Legacy 已知 identity-policy 表全部为空时把该域降为 `informational/catalog_evidence`;任一 identity 行、未知 schema、目标侧 identity 或查询异常仍维持原有 required/blocked 行为。 +8. CI 同时保留一条 apply→verify→rollback→verify 流和一条独立 completion-ready 全链,避免 completion 证明覆盖回滚证明。fixture/decision generator 只存在仓库 CI,不进入 Trial Kit。 +9. Trial Kit、verification、offline audit 升为 `@v11/@v9/@v8`,manifest `schemaVersion=12`;Local milestone 升为 `@v7`、`schemaVersion=7`。新增 required gate `legacyUpgradeReconciliationCompletion=passed`,旧证据不会被新 auditor 接受。 + +## 资源与产品边界 + +- 所有 reconciliation Operator 命令继续使用 128 MiB memory/swap、0.5 CPU、32 PID、无网络、只读 rootfs、drop-all 和 no-new-privileges;低配设备默认 headless 不变。 +- 本切片不增加 package、production dependency、daemon、listener、timer、watcher、连接池或稳态资源。 +- completion 是 stopped-state durable fence,不是 target/Legacy 重启授权、生产升级、Public Release 或用户真实数据自动迁移承诺。 +- Cluster 继续使用 PostgreSQL/Kubernetes authority,不复用 Local SQLite/POSIX/Docker ceremony。 + +## 后果 + +阶段产物将同时回答两个问题:Automation 是否能安全回滚,以及可适配的 Automation、Secret/Config、Run History 是否能在服务仍停止时形成可验证 completion。含真实 Legacy identity 数据、未知 schema、Secret/Config 冲突、非终态 Run 或缺少任一外部 decision 的部署仍会失败关闭,需要后续专用 adapter 或人工恢复方案。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 07c455aa..5f44fcf0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -530,7 +530,8 @@ | [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(同源 exact Console 双架构阶段实物已交付) | -| [ADR-0527](./ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md) | 受审核 Automation Reconciliation 应用与显式回滚 | Accepted(源码与本地 exact arm64 演练已闭合;双架构阶段实物待交付) | +| [ADR-0527](./ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md) | 受审核 Automation Reconciliation 应用与显式回滚 | Accepted(exact headless 双架构阶段实物已交付) | +| [ADR-0528](./ADR-0528-cross-domain-reconciliation-completion-rehearsal.md) | 跨域 Reconciliation Completion 演练 | Accepted(D-426c3 源码候选;双架构阶段实物待 gate) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index 8fdaed67..3cc62b04 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -121,7 +121,7 @@ ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verif Local artifact 含: - 一个包含所选 Application 与短生命周期 operator 的 archive;headless 为 `qinglong3-local-trial-kit-.docker.tar`,Console 为 `qinglong3-local-console-trial-kit-.docker.tar`,共享 Node 基础层在 archive 中去重; -- schema 为 `qinglong/alpha-local-trial-kit@v10`、`schemaVersion=11` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/upgradeReadiness/upgradeRehearsal/upgradeCutoverRehearsal/upgradeReconciliationRehearsal/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; +- schema 为 `qinglong/alpha-local-trial-kit@v11`、`schemaVersion=12` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/upgradeReadiness/upgradeRehearsal/upgradeCutoverRehearsal/upgradeReconciliationRehearsal/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256;verification v9 同时要求写后 capture、Automation 显式回滚和三 adapter stopped-state completion; - 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; @@ -131,7 +131,7 @@ Local artifact 含: Cluster artifact 是每角色/架构一个六文件闭包:native Docker archive、精确 CycloneDX SBOM、workflow-bound verification evidence、README、`qinglong/alpha-cluster-image@v1` manifest 和覆盖全部内容文件的 `SHA256SUMS`。完整 CI 成功后,八个 bundle 由 `qinglong/alpha-cluster-milestone@v1` 小型索引闭合;索引本身不重复存放大 archive。 -Local milestone 是 `qinglong/alpha-local-milestone@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。 +Local milestone 是 `qinglong/alpha-local-milestone@v7` 三文件闭包,绑定一个 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 70c92a4c..6a9dc9c1 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@v10`,`schemaVersion=11`; +- `schema` 为 `qinglong/alpha-local-trial-kit@v11`,`schemaVersion=12`; - `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 试运行 -v10 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX +v11 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX shell、`sha256sum` 和已启动的 Docker。必须选择一个尚不存在、与 2.x/生产数据完全 隔离的绝对路径: @@ -86,7 +86,7 @@ headless 不创建示例 Task,因此低配默认档没有示例数据或稳态 ## 只读检查现有 2.x 升级就绪度 -v10 bundle 还包含 canonical `upgrade-readiness.sh`。它让现有部署用户先回答“这份 2.x SQLite 和完整 data directory 是否能形成可审核 +v11 bundle 还包含 canonical `upgrade-readiness.sh`。它让现有部署用户先回答“这份 2.x SQLite 和完整 data directory 是否能形成可审核 计划”,不会把 inspect 成功冒充自动升级。建议停止 2.x、同步器和下载器,确认主库位于 `db/database.sqlite`,再选择一个尚不存在且不在 2.x data root 内的 evidence 路径: @@ -118,7 +118,7 @@ artifact job 必须在原生 amd64/arm64 上使用生产形态 2.x fixture 运 ## 受审核计划的 Side-by-side 暂存 -审核上一节两个完整结果后,把其中 exact `evidence.planDigest` 作为显式参数交给 v10 bundle 的 canonical `upgrade-rehearsal.sh`: +审核上一节两个完整结果后,把其中 exact `evidence.planDigest` 作为显式参数交给 v11 bundle 的 canonical `upgrade-rehearsal.sh`: ```sh sh upgrade-rehearsal.sh \ @@ -186,9 +186,9 @@ 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 应用与显式回滚 +### 受审核的跨域应用、回滚与 completion -v10 bundle 在写后 capture 之外增加 `reconciliation-rehearsal.sh`。它故意拆成三个命令,不能一条命令自动跨越人工决策: +v11 bundle 在写后 capture 之外提供 `reconciliation-rehearsal.sh`。回滚链仍是三个命令;完成链在同一 `prepare`/`review` 之后增加两个独立决策点,不能一条命令自动跨越: ```sh sh reconciliation-rehearsal.sh \ @@ -212,15 +212,40 @@ sh reconciliation-rehearsal.sh \ /opt/qinglong3-alpha-reconciliation-work \ /opt/qinglong3-alpha-decisions/automation/automation.ndjson \ /opt/qinglong/data + +# 完成链使用另一套全新 capture/reconciliation root: +sh reconciliation-rehearsal.sh \ + apply-plan edge \ + /opt/qinglong3-alpha-upgrade-reconciliation-completion \ + /opt/qinglong3-alpha-reconciliation-completion-capture \ + /opt/qinglong3-alpha-reconciliation-completion-work \ + /opt/qinglong3-alpha-completion-decisions/automation/automation.ndjson \ + /opt/qinglong3-alpha-completion-decisions/review/review.ndjson \ + /opt/qinglong/data + +sh reconciliation-rehearsal.sh \ + complete edge \ + /opt/qinglong3-alpha-upgrade-reconciliation-completion \ + /opt/qinglong3-alpha-reconciliation-completion-capture \ + /opt/qinglong3-alpha-reconciliation-completion-work \ + /opt/qinglong3-alpha-completion-decisions/secret-config/secret-config.ndjson \ + /opt/qinglong3-alpha-completion-decisions/review/review.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。 +`apply-plan` 必须同时重新提供原始 review decision;脚本不会从 reconciliation root 中复制或推断外部权限。它先保留 Automation apply,再以相同 review authority 对 Legacy/Target 的终态 Run History 做 append-only preservation/verify,随后生成 Secret/Config candidate plan 和 decision prepare,停在 `secret_config_decision_required`。该顺序是状态机约束:Run History preservation 不推进 head,而 Secret/Config plan 会推进 head,不能倒置。 + +操作者审核 Secret/Config plan 后,在第三个独立 `0700` 父目录中提供唯一 decision 文件。`complete` 才会提交并验证这些决定、加密应用 active binding/disabled preservation,再以 completion v3 同时绑定 Automation apply、Secret/Config apply 和 Run History preservation。成功 summary 必须是 `reconciliation_completed`、`adapterCount=3`,且 target/Legacy 仍为 stopped,`targetRestart/legacyRestart=not_authorized`。重启需要后续独立 authority ceremony。 + +每个 decision 父目录必须由当前 UID 控制、mode `0700`、恰好包含一个 `0400|0600` canonical regular file,并与 rehearsal/capture/reconciliation/Legacy roots 全部不重叠。review、Automation、Secret/Config decision 不得放在同一个父目录。交付包只消费决定,不生成决定;仓库内合成 fixture/generator 不进入 Trial Kit。 + 命令文件和成功结果支持中断后的 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 阶段实物。 +仓库 CI 会保留两条互相独立的合成链:第一条实跑 apply→verify→rollback→verify;第二条保持完整 2.x schema、空 Apps/Auths 且无未知插件表,实跑 Automation→Run History→Secret/Config→completion。两个 required gate 分别是 `legacyUpgradeReconciliationAutomationRollback=passed` 与 `legacyUpgradeReconciliationCompletion=passed`。空 Legacy Apps/Auths 只作为无数据 catalog evidence;任一真实 identity 行仍阻塞 completion。Local milestone v7 绑定两个架构各自的 `upgradeReconciliationRehearsalSha256`;只有同 run finalizer 重新下载、审计并闭合两个 bundle 后才是 D-426c3 阶段实物。 ## 手工加载与最小 smoke @@ -249,7 +274,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。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 常驻。 +完整 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。v11 artifact job 必须从将要上传的目录实际执行 `quickstart.sh`、read-only `upgrade-readiness.sh`、isolated `upgrade-cutover-rehearsal.sh` 的 clean/write-after 路径,以及独立的 Automation rollback 和三 adapter completion 两条 reconciliation 流;并完成 graceful stop、rollback-candidate、capture、全部外部 decision、旧 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/review/diagnostics.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts index d42b40c3..53c14ce8 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/review/diagnostics.ts @@ -162,6 +162,40 @@ function targetRunHistoryIsTerminal(client: DatabaseSync): boolean { return true; } +function legacyIdentityPolicyAuditIsEmpty(client: DatabaseSync): boolean { + const rows = client + .prepare( + `SELECT name + FROM sqlite_schema + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + LIMIT 513`, + ) + .iterate() as IterableIterator<{ readonly name?: unknown }>; + let catalog = 0; + for (const row of rows) { + catalog += 1; + if ( + catalog > 512 || + typeof row.name !== 'string' || + Buffer.byteLength(row.name, 'utf8') > MAX_NAME_BYTES + ) { + return false; + } + if ( + classifyLocalReconciliationFact('legacy', row.name) !== + 'identity_policy_audit' + ) { + continue; + } + const present = client + .prepare(`SELECT 1 AS present FROM ${quotedIdentifier(row.name)} LIMIT 1`) + .get() as { readonly present?: unknown } | undefined; + if (present?.present === 1) return false; + } + return true; +} + function requirement( client: DatabaseSync, database: LocalReconciliationSealedDatabaseKind, @@ -201,6 +235,12 @@ function requirement( reason: 'reviewable_fact' as const, }); } + if (legacyIdentityPolicyAuditIsEmpty(client)) { + return Object.freeze({ + decisionRequirement: 'informational' as const, + reason: 'catalog_evidence' as const, + }); + } return Object.freeze({ decisionRequirement: 'blocked' as const, reason: 'identity_custody_required' as const, diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index efc5b7f5..70319e52 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -3053,7 +3053,7 @@ test('review diagnostics keep secret and unknown facts blocked and row-free', (t ); }); -test('review diagnostics preserve target-native identity but keep legacy identity fail-closed', (t) => { +test('review diagnostics treat empty Legacy identity catalogs as no-effect evidence', (t) => { const initializeDatabases = (paths) => { planningDatabaseInitializer()(paths); const legacy = new DatabaseSync(paths.legacySourcePath); @@ -3085,8 +3085,8 @@ test('review diagnostics preserve target-native identity but keep legacy identit fs.readFileSync(legacyCommand.request.outputPath, 'utf8'), ); assert.equal(legacy.records[0].name, 'Auths'); - assert.equal(legacy.records[0].decisionRequirement, 'blocked'); - assert.equal(legacy.records[0].reason, 'identity_custody_required'); + assert.equal(legacy.records[0].decisionRequirement, 'informational'); + assert.equal(legacy.records[0].reason, 'catalog_evidence'); const targetCommand = diagnosticCommand(state, prepared, { database: 'target', @@ -3102,6 +3102,36 @@ test('review diagnostics preserve target-native identity but keep legacy identit assert.equal(target.records[0].reason, 'reviewable_fact'); }); +test('review diagnostics keep nonempty Legacy identity custody fail-closed', (t) => { + const initializeDatabases = (paths) => { + planningDatabaseInitializer()(paths); + const legacy = new DatabaseSync(paths.legacySourcePath); + legacy.exec(` + CREATE TABLE "Auths" (id INTEGER PRIMARY KEY); + INSERT INTO "Auths" (id) VALUES (1); + `); + legacy.close(); + fs.copyFileSync(paths.legacySourcePath, paths.recoveryPath); + fs.chmodSync(paths.recoveryPath, 0o600); + }; + const state = preparedReview(t, { + initializeDatabases, + planId: '00000000-0000-4000-8000-000000000335', + reviewId: '00000000-0000-4000-8000-000000000336', + reviewSuffix: 'nonempty-identity-custody', + }); + const prepared = prepareLocalReconciliationReview(state.reviewCommand); + const command = diagnosticCommand(state, prepared, { + database: 'legacy', + domain: 'identity_policy_audit', + outputName: 'legacy-nonempty-identity.json', + }); + writeLocalReconciliationReviewDiagnostics(command); + const page = JSON.parse(fs.readFileSync(command.request.outputPath, 'utf8')); + assert.equal(page.records[0].decisionRequirement, 'blocked'); + assert.equal(page.records[0].reason, 'identity_custody_required'); +}); + test('review diagnostics page at sixty-four and CLI output stays content-free', (t) => { const initializeDatabases = (paths) => { planningDatabaseInitializer()(paths); diff --git a/scripts/ql3-alpha-stage-index.cjs b/scripts/ql3-alpha-stage-index.cjs index 7719d17c..e1447169 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@v6' + ? 'qinglong/alpha-local-milestone@v7' : '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 85376917..e38a37d2 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@v6'; +const SCHEMA = 'qinglong/alpha-local-milestone@v7'; const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const FILES = Object.freeze({ readme: 'README.md', @@ -326,7 +326,7 @@ function auditLocalAlphaMilestone(options) { 'artifacts', 'readme', ]) || - manifest.schemaVersion !== 6 || + manifest.schemaVersion !== 7 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || @@ -405,7 +405,7 @@ function auditLocalAlphaMilestone(options) { } return Object.freeze({ schemaVersion: 1, - schema: 'qinglong/alpha-local-milestone-audit@v6', + schema: 'qinglong/alpha-local-milestone-audit@v7', sourceRevision: manifest.sourceRevision, version: manifest.version, variant: manifest.variant, @@ -463,7 +463,7 @@ function finalizeLocalAlphaMilestone(options) { path.join(normalized.outputRoot, FILES.readme), ); const manifest = { - schemaVersion: 6, + schemaVersion: 7, 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 index b2abe2c0..324680ec 100644 --- a/scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs +++ b/scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs @@ -187,6 +187,19 @@ function diagnosticFacts(root, summary) { return facts; } +function readNdjson(filePath, maximumBytes, label) { + const resolved = canonicalFile(filePath, maximumBytes, label); + try { + return fs + .readFileSync(resolved, 'utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)); + } catch { + return fail(`${label} must be canonical NDJSON`); + } +} + function reviewFixture(root, output) { const summary = validateSummary(root, 'operator_decision_required'); if ( @@ -259,6 +272,101 @@ function reviewFixture(root, output) { }); } +function completionReviewFixture(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('completion review fixture identity is invalid'); + } + const facts = diagnosticFacts(root, summary); + let adoptedAutomationTables = 0; + let legacyRunHistoryFacts = 0; + let targetRunHistoryFacts = 0; + let secretConfigFacts = 0; + const decisions = []; + for (const fact of facts) { + if (fact.decisionRequirement === 'informational') continue; + if (fact.decisionRequirement === 'blocked') { + fail('completion review fixture refuses blocked diagnostic facts'); + } + const automationTable = + fact.database === 'legacy' && + fact.domain === 'automation' && + fact.factKind === 'table' && + fact.tableName === 'Crontabs' && + fact.decisionRequirement === 'required'; + const runHistory = fact.domain === 'run_history'; + const secretConfig = fact.domain === 'secret_and_config'; + if (automationTable) adoptedAutomationTables += 1; + if (runHistory && fact.database === 'legacy') legacyRunHistoryFacts += 1; + if (runHistory && fact.database === 'target') targetRunHistoryFacts += 1; + if (secretConfig) secretConfigFacts += 1; + const legacy = fact.database === 'legacy'; + 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' + : secretConfig + ? 'manual_external' + : runHistory && legacy + ? 'retain_both' + : legacy + ? 'exclude_legacy' + : 'retain_target', + reason: automationTable + ? 'prefer_legacy' + : secretConfig + ? 'external_recovery_required' + : runHistory && legacy + ? 'preserve_both' + : legacy + ? 'legacy_excluded' + : 'preserve_target', + }); + } + if ( + adoptedAutomationTables !== 1 || + legacyRunHistoryFacts < 1 || + targetRunHistoryFacts < 1 || + secretConfigFacts < 1 + ) { + fail( + 'completion fixture requires Automation, Secret/Config, and dual Run History authority', + ); + } + 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: 'completion-review', + filePath, + decisionCount: decisions.length, + adoptedAutomationTables, + legacyRunHistoryFacts, + targetRunHistoryFacts, + secretConfigFacts, + }); +} + function automationFixture(root, output) { const summary = validateSummary(root, 'automation_decision_required'); const automation = summary.automation; @@ -351,6 +459,111 @@ function automationFixture(root, output) { }); } +function secretConfigFixture(root, output) { + const summary = validateSummary(root, 'secret_config_decision_required'); + const secretConfig = summary.secretConfig; + const decision = summary.secretConfigDecision; + if ( + !UUID_V4.test(secretConfig?.secretConfigId || '') || + !UUID_V7.test(decision?.decisionId || '') || + !DIGEST.test(secretConfig?.secretConfigPlanDigest || '') || + !DIGEST.test(decision?.preparationDigest || '') || + !Number.isSafeInteger(secretConfig?.eligibleBindingCount) || + !Number.isSafeInteger(secretConfig?.eligiblePreservationCount) || + secretConfig.eligibleBindingCount + secretConfig.eligiblePreservationCount < + 1 || + secretConfig.targetConflictCount !== 0 || + secretConfig.unadaptedLegacyConfigCount !== 0 + ) { + fail('fixture requires one ready conflict-free Secret/Config plan'); + } + const directory = path.join( + root, + 'secret-config', + secretConfig.secretConfigId, + ); + const receipt = readJson( + path.join(directory, 'receipt.json'), + 'Secret/Config plan receipt', + ); + if ( + receipt?.schemaVersion !== 1 || + receipt?.schema !== + 'qinglong3-local-reconciliation-secret-config-plan-receipt' || + receipt?.state !== 'reconciliation_secret_config_planned' || + receipt?.secretConfigId !== secretConfig.secretConfigId || + receipt?.secretConfigPlanDigest !== secretConfig.secretConfigPlanDigest || + receipt?.outcome !== 'ready' || + receipt?.eligibleBindingCount !== secretConfig.eligibleBindingCount || + receipt?.eligiblePreservationCount !== + secretConfig.eligiblePreservationCount || + receipt?.targetConflictCount !== 0 || + receipt?.unadaptedLegacyConfigCount !== 0 + ) { + fail('Secret/Config plan receipt is not fixture-eligible'); + } + const records = readNdjson( + path.join(directory, 'plan.ndjson'), + 8 * 1024 * 1024, + 'Secret/Config row plan', + ); + const candidates = records.filter( + (record) => + record?.kind === + 'qinglong3-local-reconciliation-secret-config-plan-candidate', + ); + if ( + candidates.length !== + secretConfig.eligibleBindingCount + secretConfig.eligiblePreservationCount + ) { + fail('Secret/Config row plan candidate count is incompatible'); + } + const decisions = candidates.map((candidate) => { + if ( + candidate?.schemaVersion !== 1 || + !Number.isSafeInteger(candidate.candidateOrdinal) || + candidate.candidateOrdinal < 1 || + !DIGEST.test(candidate.candidateDigest || '') || + !['review_apply_binding', 'review_preserve_disabled'].includes( + candidate.requirement, + ) || + candidate.target?.state !== 'absent' + ) { + fail('Secret/Config row plan contains a conflicting candidate'); + } + const active = candidate.requirement === 'review_apply_binding'; + return { + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-secret-config-decision', + candidateOrdinal: candidate.candidateOrdinal, + candidateDigest: candidate.candidateDigest, + disposition: active ? 'apply_active_binding' : 'preserve_disabled', + reason: active + ? 'reviewed_active_binding' + : 'reviewed_disabled_preservation', + }; + }); + const outputRecords = [ + { + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-secret-config-decision-header', + decisionContractVersion: 1, + decisionId: decision.decisionId, + profile: summary.profile, + secretConfigPlanDigest: secretConfig.secretConfigPlanDigest, + preparationDigest: decision.preparationDigest, + }, + ...decisions, + ]; + const filePath = writeExclusive(output, outputRecords); + return Object.freeze({ + mode: 'secret-config', + filePath, + decisionCount: decisions.length, + secretConfigPlanDigest: secretConfig.secretConfigPlanDigest, + }); +} + function parseArguments(argv) { const values = {}; for (const argument of argv) { @@ -360,7 +573,9 @@ function parseArguments(argv) { values[match[1]] = match[2]; } if ( - !['review', 'automation'].includes(values.mode) || + !['review', 'completion-review', 'automation', 'secret-config'].includes( + values.mode, + ) || JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(['mode', 'output', 'reconciliation-root']) ) { @@ -375,10 +590,16 @@ function parseArguments(argv) { function runCli(argv) { const options = parseArguments(argv); - const report = - options.mode === 'review' - ? reviewFixture(options.reconciliationRoot, options.output) - : automationFixture(options.reconciliationRoot, options.output); + const fixtures = { + review: reviewFixture, + 'completion-review': completionReviewFixture, + automation: automationFixture, + 'secret-config': secretConfigFixture, + }; + const report = fixtures[options.mode]( + options.reconciliationRoot, + options.output, + ); process.stdout.write(`${JSON.stringify(report)}\n`); return report; } @@ -396,8 +617,10 @@ if (require.main === module) { module.exports = Object.freeze({ automationFixture, + completionReviewFixture, diagnosticFacts, parseArguments, reviewFixture, runCli, + secretConfigFixture, }); diff --git a/scripts/ql3-local-alpha-trial-kit-bundle.cjs b/scripts/ql3-local-alpha-trial-kit-bundle.cjs index 1855f9d2..b5a6c9ff 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@v10'; -const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v8'; +const SCHEMA = 'qinglong/alpha-local-trial-kit@v11'; +const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v9'; const QUICKSTART_TEMPLATE = path.join( DEFAULT_ROOT, 'scripts/templates/ql3-local-alpha-quickstart.sh', @@ -73,6 +73,7 @@ const VERIFICATION = Object.freeze({ legacyUpgradeCutover: 'passed', legacyUpgradeReconciliationCapture: 'passed', legacyUpgradeReconciliationAutomationRollback: 'passed', + legacyUpgradeReconciliationCompletion: 'passed', }); function verificationGates(variant) { @@ -782,7 +783,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { 0o700, ); const manifest = { - schemaVersion: 11, + schemaVersion: 12, schema: SCHEMA, maturity: 'alpha_candidate_not_public_release', product: 'local', @@ -909,7 +910,7 @@ function auditLocalAlphaTrialKit(options) { 'readme', 'verification', ]) || - manifest.schemaVersion !== 11 || + manifest.schemaVersion !== 12 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || @@ -1030,7 +1031,7 @@ function auditLocalAlphaTrialKit(options) { expectedUpgradeReconciliationRehearsal ) { fail( - 'upgrade reconciliation rehearsal differs from the canonical reviewed application and rollback journey', + 'upgrade reconciliation rehearsal differs from the canonical reviewed rollback and completion journey', ); } validateFileRecord(manifest.readme, FILES.readme, bundleRoot); @@ -1110,7 +1111,7 @@ function auditLocalAlphaTrialKit(options) { } return Object.freeze({ schemaVersion: 1, - schema: 'qinglong/alpha-local-trial-kit-audit@v7', + schema: 'qinglong/alpha-local-trial-kit-audit@v8', sourceRevision: manifest.sourceRevision, version: manifest.version, architecture: manifest.architecture, diff --git a/scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs b/scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs index f377cae2..3956740f 100644 --- a/scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs +++ b/scripts/ql3-local-alpha-upgrade-readiness-fixture.cjs @@ -13,14 +13,32 @@ function fail(message) { } function parseArguments(argv) { - if (argv.length !== 1) fail('usage: --output=/absolute/new/data-root'); - const match = /^--output=(\/.+)$/u.exec(argv[0]); + const values = {}; + for (const argument of argv) { + const match = /^--([a-z-]+)=(.+)$/u.exec(argument); + if (!match || Object.hasOwn(values, match[1])) { + fail( + 'usage: --output=/absolute/new/data-root [--shape=production|completion-ready]', + ); + } + values[match[1]] = match[2]; + } + if ( + !values.output || + Object.keys(values).some((key) => !['output', 'shape'].includes(key)) || + (values.shape && !['production', 'completion-ready'].includes(values.shape)) + ) { + fail( + 'usage: --output=/absolute/new/data-root [--shape=production|completion-ready]', + ); + } + const match = /^(\/.+)$/u.exec(values.output); if (!match) fail('output must be an absolute path'); const output = path.resolve(match[1]); if (output !== match[1] || path.parse(output).root === output) { fail('output must be a normalized absolute non-root path'); } - return output; + return Object.freeze({ output, shape: values.shape || 'production' }); } function writePrivate(filePath, contents) { @@ -31,7 +49,7 @@ function writePrivate(filePath, contents) { }); } -function createLegacyDatabase(databasePath) { +function createLegacyDatabase(databasePath, shape) { const database = new DatabaseSync(databasePath); try { database.exec(` @@ -94,7 +112,6 @@ function createLegacyDatabase(databasePath) { status DECIMAL NOT NULL, exit_code DECIMAL, createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL ); - CREATE TABLE "PluginOwnedState" (id INTEGER PRIMARY KEY, payload TEXT NOT NULL); INSERT INTO "Crontabs" ( id, name, command, schedule, status, isDisabled, isPinned, createdAt, updatedAt ) VALUES ( @@ -107,22 +124,32 @@ function createLegacyDatabase(databasePath) { 1, 'ALPHA_READINESS_VALUE', 'synthetic-only', 0, 100, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ); - INSERT INTO "PluginOwnedState" (id, payload) - VALUES (1, '{"synthetic":true}'); `); + if (shape === 'production') { + database.exec(` + CREATE TABLE "PluginOwnedState" ( + id INTEGER PRIMARY KEY, payload TEXT NOT NULL + ); + INSERT INTO "PluginOwnedState" (id, payload) + VALUES (1, '{"synthetic":true}'); + `); + } } finally { database.close(); } fs.chmodSync(databasePath, 0o600); } -function createFixture(output) { +function createFixture(output, shape = 'production') { + if (!['production', 'completion-ready'].includes(shape)) { + fail('fixture shape is invalid'); + } if (fs.existsSync(output)) fail('output must not already exist'); fs.mkdirSync(output, { mode: 0o700 }); for (const directory of ['config', 'scripts', 'db', 'upload', 'ssh.d']) { fs.mkdirSync(path.join(output, directory), { mode: 0o700 }); } - createLegacyDatabase(path.join(output, 'db', 'database.sqlite')); + createLegacyDatabase(path.join(output, 'db', 'database.sqlite'), shape); writePrivate( path.join(output, 'config', 'config.sh'), "export ALPHA_READINESS_CONFIG='synthetic-only'\n", @@ -138,11 +165,13 @@ function createFixture(output) { return Object.freeze({ output, database: path.join(output, 'db', 'database.sqlite'), + shape, }); } function runCli(argv) { - const result = createFixture(parseArguments(argv)); + const options = parseArguments(argv); + const result = createFixture(options.output, options.shape); process.stdout.write(`${JSON.stringify({ status: 'created', ...result })}\n`); } diff --git a/scripts/ql3-local-operator-image-audit.cjs b/scripts/ql3-local-operator-image-audit.cjs index 9ab79bcd..1e9372bf 100644 --- a/scripts/ql3-local-operator-image-audit.cjs +++ b/scripts/ql3-local-operator-image-audit.cjs @@ -227,6 +227,18 @@ function auditWorkflow(contents, findings) { 'automation-apply-verify.result.json', 'automation-rollback-verify.result.json', '"completion":"not_attempted"', + '--shape=completion-ready', + '--mode=completion-review', + 'apply-plan edge', + '"status":"secret_config_decision_required"', + '"automaticCandidateDecision":"not_authorized"', + '--mode=secret-config', + 'complete edge', + '"status":"reconciliation_completed"', + '"adapterCount":3', + 'completion-verify.result.json', + '"targetRestart":"not_authorized"', + '"legacyRestart":"not_authorized"', 'docker rm "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}"', '--application-sbom="${APPLICATION_SBOM}"', '--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"', @@ -260,6 +272,11 @@ function auditWorkflow(contents, findings) { '--mode=review', '--mode=automation', 'apply-rollback edge', + '--shape=completion-ready', + '--mode=completion-review', + 'apply-plan edge', + '--mode=secret-config', + 'complete 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 index b9873315..1ba7b538 100644 --- a/scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh +++ b/scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh @@ -15,6 +15,11 @@ 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' +SECRET_CONFIG_ID='019f8680-143d-4000-8000-000000000491' +SECRET_CONFIG_DECISION_ID='019f8680-143d-7000-8000-0000000004a1' +SECRET_CONFIG_MUTATION_ID='019f8680-143d-4000-8000-0000000004b1' +RUN_HISTORY_PRESERVATION_ID='019f8680-143d-4000-8000-0000000004c1' +COMPLETION_ID='019f8680-143d-4000-8000-0000000004d1' fail() { printf '%s\n' "QingLong Local Alpha reconciliation rehearsal failed: $*" >&2 @@ -29,7 +34,9 @@ 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 + ' sh reconciliation-rehearsal.sh apply-rollback edge|standalone /absolute/rehearsal-root /absolute/capture-root /absolute/reconciliation-root /absolute/automation-decisions.ndjson /absolute/legacy-root' \ + ' sh reconciliation-rehearsal.sh apply-plan edge|standalone /absolute/rehearsal-root /absolute/capture-root /absolute/reconciliation-root /absolute/automation-decisions.ndjson /absolute/review-decisions.ndjson /absolute/legacy-root' \ + ' sh reconciliation-rehearsal.sh complete edge|standalone /absolute/rehearsal-root /absolute/capture-root /absolute/reconciliation-root /absolute/secret-config-decisions.ndjson /absolute/review-decisions.ndjson /absolute/legacy-root' >&2 exit 2 } @@ -116,17 +123,24 @@ private_decision_file() { [ "$#" -ge 1 ] || usage mode=$1 -[ "$mode" = apply-rollback ] && [ "$#" -eq 7 ] || { - [ "$mode" != apply-rollback ] && [ "$#" -eq 6 ] || usage -} +case "$mode" in + prepare|review) [ "$#" -eq 6 ] || usage ;; + apply-rollback) [ "$#" -eq 7 ] || usage ;; + apply-plan|complete) [ "$#" -eq 8 ] || usage ;; + *) usage ;; +esac profile=$2 rehearsal_root=$3 capture_root=$4 reconciliation_root=$5 phase_input=$6 -legacy_root=${7:-} +secondary_input= +legacy_root= +case "$mode" in + apply-rollback) legacy_root=$7 ;; + apply-plan|complete) secondary_input=$7; legacy_root=$8 ;; +esac -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' @@ -163,12 +177,20 @@ application_root="$reconciliation_root/application" automation_root="$reconciliation_root/automation" automation_decision_root="$reconciliation_root/automation-decision" automation_apply_root="$reconciliation_root/automation-apply" +secret_config_root="$reconciliation_root/secret-config" +secret_config_decision_root="$reconciliation_root/secret-config-decision" +secret_config_apply_root="$reconciliation_root/secret-config-apply" +run_history_root="$reconciliation_root/run-history" +completion_root="$reconciliation_root/completion" 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" +secret_keyring="$rehearsal_root/local-secret-keyring.json" +decision_parent= +secondary_decision_parent= run_deploy() { subcommand=$1 @@ -184,8 +206,10 @@ run_deploy() { --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 -- "$@" \ + [ -z "$decision_parent" ] || set -- "$@" \ --mount "type=bind,src=$decision_parent,dst=$decision_parent,readonly" + [ -z "$secondary_decision_parent" ] || set -- "$@" \ + --mount "type=bind,src=$secondary_decision_parent,dst=$secondary_decision_parent,readonly" set -- "$@" "$OPERATOR_IMAGE" deploy "$subcommand" \ --command-file "$command_root/$command_file" result_stage="$result_root/.$result_file.$$" @@ -213,7 +237,7 @@ if [ "$mode" = prepare ]; then 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 + for directory in plan review diagnostics application automation automation-decision automation-apply secret-config secret-config-decision secret-config-apply run-history completion commands results; do mkdir -m 0700 "$reconciliation_root/$directory" done umask "$old_umask" @@ -299,12 +323,19 @@ EOF fi canonical_directory "$reconciliation_root" 'reconciliation root' -for directory in "$plan_root" "$review_root" "$diagnostic_root" "$application_root" "$automation_root" "$automation_decision_root" "$automation_apply_root" "$command_root" "$result_root"; do +for directory in "$plan_root" "$review_root" "$diagnostic_root" "$application_root" "$automation_root" "$automation_decision_root" "$automation_apply_root" "$secret_config_root" "$secret_config_decision_root" "$secret_config_apply_root" "$run_history_root" "$completion_root" "$command_root" "$result_root"; do canonical_directory "$directory" 'reconciliation authority directory' done private_decision_file "$phase_input" 'external decision file' +if [ -n "$secondary_input" ]; then + primary_decision_parent=$decision_parent + private_decision_file "$secondary_input" 'external review decision file' + secondary_decision_parent=$decision_parent + decision_parent=$primary_decision_parent +fi if [ -n "$legacy_root" ]; then non_overlapping "$decision_parent" "$legacy_root" || fail 'external decision file parent must be outside legacy root' + [ -z "$secondary_decision_parent" ] || non_overlapping "$secondary_decision_parent" "$legacy_root" || fail 'external review decision file parent must be outside legacy root' fi if [ "$mode" = review ]; then @@ -400,11 +431,101 @@ EOF 'Authenticated review and Automation plan are ready.' \ "Summary: $reconciliation_root/summary.json" \ "Automation plan: $automation_root/$AUTOMATION_ID/plan.ndjson" \ - 'No Automation row decision was generated or applied. Supply an external owner-private NDJSON decision file to the apply-rollback phase.' + 'No Automation row decision was generated or applied. Supply an external owner-private NDJSON decision file to apply-rollback, or pair it with the original review decision for apply-plan.' exit 0 fi -grep -q '"status":"automation_decision_required"' "$reconciliation_root/summary.json" || fail 'apply-rollback phase is detached from Automation decision state' +if [ "$mode" = complete ]; then + grep -q '"status":"secret_config_decision_required"' "$reconciliation_root/summary.json" || fail 'completion phase is detached from Secret/Config decision state' + application_plan_digest=$(extract_digest "$result_root/application-commit.result.json" applicationPlanDigest) + automation_decision_digest=$(extract_digest "$result_root/automation-decision-commit.result.json" decisionDigest) + automation_apply_digest=$(extract_digest "$result_root/automation-apply.result.json" applyDigest) + preservation_digest=$(extract_digest "$result_root/run-history-preserve.result.json" preservationDigest) + secret_decision_preparation_digest=$(extract_digest "$result_root/secret-config-decision-prepare.result.json" preparationDigest) + secret_decision_head_digest=$(extract_digest "$result_root/secret-config-decision-prepare.result.json" instanceHeadDigest) + if [ -e "$command_root/secret-config-decision-commit.json" ]; then + committed_ms=$(extract_unsigned "$command_root/secret-config-decision-commit.json" committedAtMs) + else + committed_ms=$(($(date +%s) * 1000)) + cat >"$command_root/secret-config-decision-commit.json" <"$command_root/secret-config-decision-verify.json" <"$command_root/secret-config-apply.json" <"$command_root/secret-config-apply-verify.json" <"$command_root/completion.json" <"$command_root/completion-verify.json" <"$reconciliation_root/summary.json" <"$command_root/run-history-preserve.json" <"$command_root/run-history-verify.json" <"$command_root/secret-config-plan.json" <"$command_root/secret-config-verify.json" <"$command_root/secret-config-decision-prepare.json" <"$reconciliation_root/summary.json" < { const paths = fixture(t); const manifest = finalizeLocalAlphaMilestone(finalizeOptions(paths)); - assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v6'); + assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v7'); assert.match( manifest.artifacts.amd64.upgradeReadinessSha256, /^sha256:[0-9a-f]{64}$/, @@ -251,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@v6'); + assert.equal(report.schema, 'qinglong/alpha-local-milestone-audit@v7'); }); 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 index 4f11d44a..e7573a14 100644 --- a/test/back/ql3LocalAlphaReconciliationDecisionFixture.test.cjs +++ b/test/back/ql3LocalAlphaReconciliationDecisionFixture.test.cjs @@ -8,8 +8,10 @@ const test = require('node:test'); const { automationFixture, + completionReviewFixture, parseArguments, reviewFixture, + secretConfigFixture, } = require('../../scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs'); const DOMAINS = [ @@ -29,6 +31,10 @@ 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'; +const SECRET_CONFIG_ID = '019f8680-143d-4000-8000-000000000491'; +const SECRET_CONFIG_DECISION_ID = '019f8680-143d-7000-8000-0000000004a1'; +const SECRET_CONFIG_PLAN_DIGEST = '6'.repeat(64); +const SECRET_CONFIG_PREPARATION_DIGEST = '7'.repeat(64); function fixture(t) { const root = fs.realpathSync( @@ -248,6 +254,119 @@ test('review fixture fails closed without the exact Legacy Crontabs fact', (t) = assert.equal(fs.existsSync(output), false); }); +function completionReviewState(t) { + const state = fixture(t); + const unknownPath = path.join( + state.root, + 'diagnostics/legacy-unknown-table-0.json', + ); + const unknown = JSON.parse(fs.readFileSync(unknownPath, 'utf8')); + unknown.records = []; + unknown.recordCount = 0; + fs.writeFileSync(unknownPath, `${JSON.stringify(unknown)}\n`); + const additions = [ + ['legacy', 'secret_and_config', '6'], + ['target', 'secret_and_config', '7'], + ['target', 'run_history', '8'], + ]; + for (const [database, domain, digest] of additions) { + const pagePath = path.join( + state.root, + 'diagnostics', + `${database}-${domain}-table-0.json`, + ); + const page = JSON.parse(fs.readFileSync(pagePath, 'utf8')); + page.records = [ + { + schema: 'qinglong3-local-reconciliation-diagnostic-fact', + schemaVersion: 1, + ordinal: 1, + database, + domain, + factKind: 'table', + objectType: 'table', + name: + domain === 'secret_and_config' + ? database === 'legacy' + ? 'Envs' + : 'QingLong3Secrets' + : 'QingLong3Runs', + tableName: + domain === 'secret_and_config' + ? database === 'legacy' + ? 'Envs' + : 'QingLong3Secrets' + : 'QingLong3Runs', + rowCount: '1', + decisionRequirement: 'required', + reason: + domain === 'run_history' + ? 'historical_preservation_required' + : 'secret_custody_required', + factDigest: digest.repeat(64), + }, + ]; + page.recordCount = 1; + fs.writeFileSync(pagePath, `${JSON.stringify(page)}\n`); + } + const summaryPath = path.join(state.root, 'summary.json'); + const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); + summary.review.diagnosticRecords = state.recordCount - 1 + additions.length; + fs.writeFileSync(summaryPath, `${JSON.stringify(summary)}\n`); + return state; +} + +test('builds completion review authority for all adapted domains', (t) => { + const state = completionReviewState(t); + const output = path.join( + path.dirname(state.root), + `${path.basename(state.root)}.completion-review.ndjson`, + ); + t.after(() => fs.rmSync(output, { force: true })); + const report = completionReviewFixture(state.root, output); + assert.equal(report.adoptedAutomationTables, 1); + assert.equal(report.legacyRunHistoryFacts, 1); + assert.equal(report.targetRunHistoryFacts, 1); + assert.equal(report.secretConfigFacts, 2); + const decisions = fs + .readFileSync(output, 'utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)) + .slice(1); + assert.ok( + decisions.some( + (decision) => + decision.database === 'legacy' && + decision.domain === 'run_history' && + decision.disposition === 'retain_both' && + decision.reason === 'preserve_both', + ), + ); + assert.ok( + decisions + .filter((decision) => decision.domain === 'secret_and_config') + .every( + (decision) => + decision.disposition === 'manual_external' && + decision.reason === 'external_recovery_required', + ), + ); +}); + +test('completion review fixture refuses any blocked domain', (t) => { + const state = fixture(t); + const output = path.join( + path.dirname(state.root), + `${path.basename(state.root)}.blocked-completion.ndjson`, + ); + assert.throws( + () => completionReviewFixture(state.root, output), + /refuses blocked diagnostic facts/, + ); + assert.equal(fs.existsSync(output), false); +}); + function automationState(t) { const state = fixture(t); fs.writeFileSync( @@ -340,6 +459,99 @@ test('Automation fixture rejects a conflict before writing decisions', (t) => { assert.equal(fs.existsSync(output), false); }); +function secretConfigState(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: 'secret_config_decision_required', + profile: 'edge', + secretConfig: { + secretConfigId: SECRET_CONFIG_ID, + secretConfigPlanDigest: SECRET_CONFIG_PLAN_DIGEST, + eligibleBindingCount: 1, + eligiblePreservationCount: 1, + targetConflictCount: 0, + unadaptedLegacyConfigCount: 0, + }, + secretConfigDecision: { + decisionId: SECRET_CONFIG_DECISION_ID, + preparationDigest: SECRET_CONFIG_PREPARATION_DIGEST, + }, + })}\n`, + ); + const directory = path.join(state.root, 'secret-config', SECRET_CONFIG_ID); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(directory, 'receipt.json'), + `${JSON.stringify({ + schema: 'qinglong3-local-reconciliation-secret-config-plan-receipt', + schemaVersion: 1, + state: 'reconciliation_secret_config_planned', + secretConfigId: SECRET_CONFIG_ID, + secretConfigPlanDigest: SECRET_CONFIG_PLAN_DIGEST, + outcome: 'ready', + eligibleBindingCount: 1, + eligiblePreservationCount: 1, + targetConflictCount: 0, + unadaptedLegacyConfigCount: 0, + })}\n`, + { mode: 0o600 }, + ); + const candidates = [ + ['review_apply_binding', '9'], + ['review_preserve_disabled', 'a'], + ].map(([requirement, digest], index) => ({ + schemaVersion: 1, + kind: 'qinglong3-local-reconciliation-secret-config-plan-candidate', + candidateOrdinal: index + 1, + candidateDigest: digest.repeat(64), + requirement, + target: { state: 'absent' }, + })); + fs.writeFileSync( + path.join(directory, 'plan.ndjson'), + `${candidates.map((candidate) => JSON.stringify(candidate)).join('\n')}\n`, + { mode: 0o600 }, + ); + return state; +} + +test('builds reviewed Secret/Config candidate decisions', (t) => { + const state = secretConfigState(t); + const output = path.join( + path.dirname(state.root), + `${path.basename(state.root)}.secret-config.ndjson`, + ); + t.after(() => fs.rmSync(output, { force: true })); + const report = secretConfigFixture(state.root, output); + assert.equal(report.decisionCount, 2); + const records = fs + .readFileSync(output, 'utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)); + assert.equal(records[0].decisionId, SECRET_CONFIG_DECISION_ID); + assert.deepEqual( + records.slice(1).map(({ disposition, reason }) => ({ + disposition, + reason, + })), + [ + { + disposition: 'apply_active_binding', + reason: 'reviewed_active_binding', + }, + { + disposition: 'preserve_disabled', + reason: 'reviewed_disabled_preservation', + }, + ], + ); +}); + test('CLI grammar is exact', () => { assert.throws( () => parseArguments(['--mode=review', '--output=/tmp/review.ndjson']), diff --git a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs index 88ed4dc3..cc05fb96 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@v10'); - assert.equal(manifest.schemaVersion, 11); + assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v11'); + assert.equal(manifest.schemaVersion, 12); assert.equal(manifest.variant, 'headless'); assert.equal(manifest.sourceRevision, revision); assert.equal(manifest.architecture, 'arm64'); @@ -372,6 +372,10 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { 'reconciliation.automation.decision.commit', 'reconciliation.automation.apply', 'reconciliation.automation.apply.rollback', + 'reconciliation.run-history.preserve', + 'reconciliation.secret-config.decision.commit', + 'reconciliation.secret-config.apply', + 'reconciliation.complete', ]) { assert.match(reconciliationContents, new RegExp(operation)); } @@ -393,6 +397,14 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { reconciliationContents, /apply-rollback edge\|standalone .* \/absolute\/legacy-root/, ); + assert.match( + reconciliationContents, + /apply-plan edge\|standalone .* \/absolute\/review-decisions\.ndjson \/absolute\/legacy-root/, + ); + assert.match( + reconciliationContents, + /complete edge\|standalone .* \/absolute\/review-decisions\.ndjson \/absolute\/legacy-root/, + ); assert.match(reconciliationContents, /result_stage=.*\.\$result_file\.\$\$/); assert.match( reconciliationContents, @@ -409,7 +421,7 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { assert.equal( (reconciliationContents.match(/"authorizationLifetimeMs":60000/g) || []) .length, - 2, + 3, ); assert.doesNotMatch( reconciliationContents, @@ -418,6 +430,12 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { assert.match(reconciliationContents, /! -path "\$decision_file"/); assert.match(reconciliationContents, /automaticDecision":"not_authorized/); assert.match(reconciliationContents, /automaticRowDecision":"not_authorized/); + assert.match( + reconciliationContents, + /automaticCandidateDecision":"not_authorized/, + ); + assert.match(reconciliationContents, /"status":"reconciliation_completed"/); + assert.match(reconciliationContents, /"adapterCount":\$adapter_count/); assert.match(reconciliationContents, /"completion":"not_attempted"/); const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); assert.equal(report.compatible, true); @@ -476,6 +494,10 @@ test('materializes a distinct loopback Console trial kit without widening the he verification.gates.legacyUpgradeReconciliationAutomationRollback, 'passed', ); + assert.equal( + verification.gates.legacyUpgradeReconciliationCompletion, + 'passed', + ); const quickstartContents = fs.readFileSync( path.join(paths.outputRoot, 'quickstart.sh'), 'utf8', @@ -991,6 +1013,20 @@ test('create rejects verification without the reviewed Automation rollback gate' assert.equal(fs.existsSync(paths.outputRoot), false); }); +test('create rejects verification without cross-domain completion evidence', (t) => { + const paths = fixture(t); + const evidence = JSON.parse( + fs.readFileSync(paths.verificationEvidence, 'utf8'), + ); + delete evidence.gates.legacyUpgradeReconciliationCompletion; + 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'); diff --git a/test/back/ql3LocalAlphaUpgradeReadinessFixture.test.cjs b/test/back/ql3LocalAlphaUpgradeReadinessFixture.test.cjs index 862b5f0f..d468ee2a 100644 --- a/test/back/ql3LocalAlphaUpgradeReadinessFixture.test.cjs +++ b/test/back/ql3LocalAlphaUpgradeReadinessFixture.test.cjs @@ -50,11 +50,61 @@ test('creates one private production-shaped QingLong 2.x readiness fixture', (t) }); test('requires one normalized absolute output path', () => { - assert.equal(parseArguments(['--output=/tmp/ql3-legacy']), '/tmp/ql3-legacy'); + assert.deepEqual(parseArguments(['--output=/tmp/ql3-legacy']), { + output: '/tmp/ql3-legacy', + shape: 'production', + }); + assert.deepEqual( + parseArguments([ + '--shape=completion-ready', + '--output=/tmp/ql3-completion', + ]), + { + output: '/tmp/ql3-completion', + shape: 'completion-ready', + }, + ); assert.throws(() => parseArguments([]), /usage/); assert.throws(() => parseArguments(['--output=relative']), /absolute path/); + assert.throws( + () => parseArguments(['--output=/tmp/ql3-completion', '--shape=unsafe']), + /usage/, + ); assert.throws( () => parseArguments(['--output=/tmp/../tmp/legacy']), /normalized absolute non-root path/, ); }); + +test('creates a completion-ready fixture without unadapted domains', (t) => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-alpha-completion-ready-')), + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const output = path.join(root, 'legacy-data'); + const result = createFixture(output, 'completion-ready'); + assert.equal(result.shape, 'completion-ready'); + const database = new DatabaseSync(result.database, { readOnly: true }); + try { + const tables = database + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name", + ) + .all() + .map(({ name }) => name); + assert.deepEqual(tables, [ + 'Apps', + 'Auths', + 'CrontabStats', + 'CrontabViews', + 'Crontabs', + 'Dependences', + 'Envs', + 'RunningInstances', + 'Subscriptions', + 'sqlite_sequence', + ]); + } finally { + database.close(); + } +});