mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): rehearse cross-domain reconciliation completion
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 或人工恢复方案。
|
||||
+2
-1
@@ -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) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -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-<arch>.docker.tar`,Console 为 `qinglong3-local-console-trial-kit-<arch>.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。
|
||||
|
||||
|
||||
@@ -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 的容量承诺。
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']) ||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
`);
|
||||
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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <legacy-timezone|none>' \
|
||||
' 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" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.secret-config.decision.commit","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","secretConfigRoot":"$secret_config_root","secretConfigDecisionRoot":"$secret_config_decision_root","targetDatabasePath":"$target_database","ownerPepperKeyringDirectory":"$owner_peppers","credentialFilePath":"$owner_credential","busyTimeoutMs":100,"allowRootService":$allow_root_service},"request":{"decisionId":"$SECRET_CONFIG_DECISION_ID","secretConfigId":"$SECRET_CONFIG_ID","expectedPreparationDigest":"$secret_decision_preparation_digest","expectedHeadDigest":"$secret_decision_head_digest","decisionFilePath":"$phase_input","committedAtMs":$committed_ms,"authorizationLifetimeMs":60000}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/secret-config-decision-commit.json"
|
||||
fi
|
||||
phase 'commit explicit Secret/Config decisions with real Owner authentication'
|
||||
run_deploy reconciliation-secret-config-decision-commit secret-config-decision-commit.json secret-config-decision-commit.result.json "$phase_input"
|
||||
grep -q '"state":"reconciliation_secret_config_reviewed"' "$result_root/secret-config-decision-commit.result.json" || fail 'Secret/Config decisions did not commit'
|
||||
grep -q '"outcome":"ready"' "$result_root/secret-config-decision-commit.result.json" || fail 'Secret/Config decisions did not authorize a ready application'
|
||||
secret_decision_digest=$(extract_digest "$result_root/secret-config-decision-commit.result.json" decisionDigest)
|
||||
cat >"$command_root/secret-config-decision-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.secret-config.decision.verify","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","secretConfigRoot":"$secret_config_root","secretConfigDecisionRoot":"$secret_config_decision_root","allowRootService":$allow_root_service},"request":{"decisionId":"$SECRET_CONFIG_DECISION_ID","secretConfigId":"$SECRET_CONFIG_ID","expectedDecisionDigest":"$secret_decision_digest"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/secret-config-decision-verify.json"
|
||||
run_deploy reconciliation-secret-config-decision-verify secret-config-decision-verify.json secret-config-decision-verify.result.json
|
||||
grep -q '"status":"verified"' "$result_root/secret-config-decision-verify.result.json" || fail 'Secret/Config decision verification failed'
|
||||
secret_decision_head_digest=$(extract_digest "$result_root/secret-config-decision-verify.result.json" instanceHeadDigest)
|
||||
|
||||
secret_apply_options="\"deploymentRoot\":\"$rehearsal_root\",\"applicationRoot\":\"$application_root\",\"secretConfigRoot\":\"$secret_config_root\",\"secretConfigDecisionRoot\":\"$secret_config_decision_root\",\"secretConfigApplyRoot\":\"$secret_config_apply_root\",\"targetDatabasePath\":\"$target_database\",\"secretKeyringPath\":\"$secret_keyring\",\"ownerPepperKeyringDirectory\":\"$owner_peppers\",\"credentialFilePath\":\"$owner_credential\",\"busyTimeoutMs\":100,\"allowRootService\":$allow_root_service"
|
||||
if [ -e "$command_root/secret-config-apply.json" ]; then
|
||||
applied_ms=$(extract_unsigned "$command_root/secret-config-apply.json" appliedAtMs)
|
||||
else
|
||||
applied_ms=$((committed_ms + 1))
|
||||
cat >"$command_root/secret-config-apply.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.secret-config.apply","options":{$secret_apply_options},"request":{"decisionId":"$SECRET_CONFIG_DECISION_ID","secretConfigId":"$SECRET_CONFIG_ID","expectedDecisionDigest":"$secret_decision_digest","expectedHeadDigest":"$secret_decision_head_digest","mutationId":"$SECRET_CONFIG_MUTATION_ID","requestId":"alpha-reconciliation-secret-config-apply","appliedAtMs":$applied_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/secret-config-apply.json"
|
||||
fi
|
||||
phase 'apply reviewed Secret/Config candidates under the bounded operator envelope'
|
||||
run_deploy reconciliation-secret-config-apply secret-config-apply.json secret-config-apply.result.json
|
||||
grep -q '"state":"reconciliation_secret_config_applied"' "$result_root/secret-config-apply.result.json" || fail 'Secret/Config candidates did not apply'
|
||||
secret_apply_digest=$(extract_digest "$result_root/secret-config-apply.result.json" applyDigest)
|
||||
secret_applied_head_digest=$(extract_digest "$result_root/secret-config-apply.result.json" instanceHeadDigest)
|
||||
cat >"$command_root/secret-config-apply-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.secret-config.apply.verify","options":{$secret_apply_options},"request":{"decisionId":"$SECRET_CONFIG_DECISION_ID","secretConfigId":"$SECRET_CONFIG_ID","expectedApplyDigest":"$secret_apply_digest"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/secret-config-apply-verify.json"
|
||||
run_deploy reconciliation-secret-config-apply-verify secret-config-apply-verify.json secret-config-apply-verify.result.json
|
||||
grep -q '"state":"reconciliation_secret_config_applied"' "$result_root/secret-config-apply-verify.result.json" || fail 'applied Secret/Config verification failed'
|
||||
|
||||
completion_options="\"deploymentRoot\":\"$rehearsal_root\",\"applicationRoot\":\"$application_root\",\"completionRoot\":\"$completion_root\",\"automation\":{\"automationRoot\":\"$automation_root\",\"automationDecisionRoot\":\"$automation_decision_root\",\"automationApplyRoot\":\"$automation_apply_root\",\"targetDatabasePath\":\"$target_database\"},\"secretConfig\":{\"secretConfigRoot\":\"$secret_config_root\",\"secretConfigDecisionRoot\":\"$secret_config_decision_root\",\"secretConfigApplyRoot\":\"$secret_config_apply_root\",\"targetDatabasePath\":\"$target_database\"},\"runHistory\":{\"runHistoryRoot\":\"$run_history_root\",\"decisionFilePath\":\"$secondary_input\"},\"allowRootService\":$allow_root_service"
|
||||
completion_automation="{\"automationId\":\"$AUTOMATION_ID\",\"decisionId\":\"$AUTOMATION_DECISION_ID\",\"expectedApplyDigest\":\"$automation_apply_digest\"}"
|
||||
completion_secret_config="{\"secretConfigId\":\"$SECRET_CONFIG_ID\",\"decisionId\":\"$SECRET_CONFIG_DECISION_ID\",\"expectedApplyDigest\":\"$secret_apply_digest\"}"
|
||||
completion_run_history="{\"preservationId\":\"$RUN_HISTORY_PRESERVATION_ID\",\"expectedPreservationDigest\":\"$preservation_digest\"}"
|
||||
if [ -e "$command_root/completion.json" ]; then
|
||||
completed_ms=$(extract_unsigned "$command_root/completion.json" completedAtMs)
|
||||
else
|
||||
completed_ms=$((applied_ms + 1))
|
||||
cat >"$command_root/completion.json" <<EOF
|
||||
{"schemaVersion":3,"operation":"local.deployment.reconciliation.complete","options":{$completion_options},"request":{"completionId":"$COMPLETION_ID","applicationId":"$APPLICATION_ID","expectedApplicationPlanDigest":"$application_plan_digest","expectedHeadDigest":"$secret_applied_head_digest","automation":$completion_automation,"secretConfig":$completion_secret_config,"runHistory":$completion_run_history,"completedAtMs":$completed_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/completion.json"
|
||||
fi
|
||||
phase 'seal cross-domain reconciliation completion while both services remain stopped'
|
||||
run_deploy reconciliation-complete completion.json completion.result.json "$secondary_input"
|
||||
grep -q '"state":"reconciliation_completed"' "$result_root/completion.result.json" || fail 'cross-domain reconciliation did not complete'
|
||||
completion_digest=$(extract_digest "$result_root/completion.result.json" completionDigest)
|
||||
completion_head_digest=$(extract_digest "$result_root/completion.result.json" instanceHeadDigest)
|
||||
cat >"$command_root/completion-verify.json" <<EOF
|
||||
{"schemaVersion":3,"operation":"local.deployment.reconciliation.complete.verify","options":{$completion_options},"request":{"completionId":"$COMPLETION_ID","applicationId":"$APPLICATION_ID","expectedCompletionDigest":"$completion_digest","automation":$completion_automation,"secretConfig":$completion_secret_config,"runHistory":$completion_run_history}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/completion-verify.json"
|
||||
run_deploy reconciliation-complete-verify completion-verify.json completion-verify.result.json "$secondary_input"
|
||||
grep -q '"status":"verified"' "$result_root/completion-verify.result.json" || fail 'cross-domain completion verification failed'
|
||||
adapter_count=$(extract_unsigned "$result_root/completion.result.json" adapterCount)
|
||||
[ "$adapter_count" -eq 3 ] || fail 'completion did not bind all three reconciliation adapters'
|
||||
cat >"$reconciliation_root/summary.json" <<EOF
|
||||
{"schemaVersion":1,"schema":"qinglong/local-alpha-reconciliation-rehearsal-summary@v1","status":"reconciliation_completed","profile":"$profile","variant":"$VARIANT","sourceRevision":"$SOURCE_REVISION","architecture":"$ARCHITECTURE","review":{"reviewId":"$REVIEW_ID","authority":"authenticated_user"},"automation":{"automationId":"$AUTOMATION_ID","decisionId":"$AUTOMATION_DECISION_ID","decisionDigest":"$automation_decision_digest","applyDigest":"$automation_apply_digest"},"secretConfig":{"secretConfigId":"$SECRET_CONFIG_ID","decisionId":"$SECRET_CONFIG_DECISION_ID","decisionDigest":"$secret_decision_digest","applyDigest":"$secret_apply_digest"},"runHistory":{"preservationId":"$RUN_HISTORY_PRESERVATION_ID","preservationDigest":"$preservation_digest"},"completion":{"completionId":"$COMPLETION_ID","completionDigest":"$completion_digest","adapterCount":$adapter_count,"instanceHeadDigest":"$completion_head_digest"},"target":"stopped","legacy":"stopped","targetRestart":"not_authorized","legacyRestart":"not_authorized","next":"independent_restart_authority_required"}
|
||||
EOF
|
||||
chmod 0600 "$reconciliation_root/summary.json"
|
||||
printf '%s\n' \
|
||||
'Automation, Secret/Config, and Run History evidence reached verified reconciliation completion.' \
|
||||
"Summary: $reconciliation_root/summary.json" \
|
||||
"Completion evidence: $result_root/completion-verify.result.json" \
|
||||
'Target and Legacy remain stopped. Restart requires a separate authority ceremony.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
grep -q '"status":"automation_decision_required"' "$reconciliation_root/summary.json" || fail 'Automation application phase is detached from Automation decision state'
|
||||
decision_preparation_digest=$(extract_digest "$result_root/automation-decision-prepare.result.json" preparationDigest)
|
||||
decision_head_digest=$(extract_digest "$result_root/automation-decision-prepare.result.json" instanceHeadDigest)
|
||||
if [ -e "$command_root/automation-decision-commit.json" ]; then
|
||||
@@ -450,6 +571,71 @@ chmod 0600 "$command_root/automation-apply-verify.json"
|
||||
run_deploy reconciliation-automation-apply-verify automation-apply-verify.json automation-apply-verify.result.json
|
||||
grep -q '"state":"reconciliation_automation_applied"' "$result_root/automation-apply-verify.result.json" || fail 'applied Automation verification failed'
|
||||
|
||||
if [ "$mode" = apply-plan ]; then
|
||||
application_plan_digest=$(extract_digest "$result_root/application-commit.result.json" applicationPlanDigest)
|
||||
preserved_ms=$((applied_ms + 1))
|
||||
cat >"$command_root/run-history-preserve.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.run-history.preserve","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","runHistoryRoot":"$run_history_root","allowRootService":$allow_root_service},"request":{"preservationId":"$RUN_HISTORY_PRESERVATION_ID","applicationId":"$APPLICATION_ID","expectedApplicationPlanDigest":"$application_plan_digest","expectedHeadDigest":"$applied_head_digest","decisionFilePath":"$secondary_input","preservedAtMs":$preserved_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/run-history-preserve.json"
|
||||
phase 'preserve dual-sided terminal Run History before Secret/Config advances the head'
|
||||
run_deploy reconciliation-run-history-preserve run-history-preserve.json run-history-preserve.result.json "$secondary_input"
|
||||
grep -q '"state":"reconciliation_run_history_preserved"' "$result_root/run-history-preserve.result.json" || fail 'Run History preservation failed'
|
||||
preservation_digest=$(extract_digest "$result_root/run-history-preserve.result.json" preservationDigest)
|
||||
cat >"$command_root/run-history-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.run-history.verify","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","runHistoryRoot":"$run_history_root","allowRootService":$allow_root_service},"request":{"preservationId":"$RUN_HISTORY_PRESERVATION_ID","applicationId":"$APPLICATION_ID","expectedPreservationDigest":"$preservation_digest","decisionFilePath":"$secondary_input"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/run-history-verify.json"
|
||||
run_deploy reconciliation-run-history-verify run-history-verify.json run-history-verify.result.json "$secondary_input"
|
||||
grep -q '"status":"verified"' "$result_root/run-history-verify.result.json" || fail 'Run History preservation verification failed'
|
||||
legacy_history_facts=$(extract_unsigned "$result_root/run-history-preserve.result.json" legacyFactCount)
|
||||
target_history_facts=$(extract_unsigned "$result_root/run-history-preserve.result.json" targetFactCount)
|
||||
[ "$legacy_history_facts" -gt 0 ] && [ "$target_history_facts" -gt 0 ] || fail 'Run History evidence is not dual-sided'
|
||||
|
||||
secret_prepared_ms=$((preserved_ms + 1))
|
||||
cat >"$command_root/secret-config-plan.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.secret-config.plan","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","secretConfigRoot":"$secret_config_root","allowRootService":$allow_root_service},"request":{"secretConfigId":"$SECRET_CONFIG_ID","applicationId":"$APPLICATION_ID","expectedApplicationPlanDigest":"$application_plan_digest","expectedHeadDigest":"$applied_head_digest","decisionFilePath":"$secondary_input","projectId":"default","preparedAtMs":$secret_prepared_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/secret-config-plan.json"
|
||||
phase 'materialize reviewed Secret/Config candidate plan'
|
||||
run_deploy reconciliation-secret-config-plan secret-config-plan.json secret-config-plan.result.json "$secondary_input"
|
||||
grep -q '"state":"reconciliation_secret_config_planned"' "$result_root/secret-config-plan.result.json" || fail 'Secret/Config plan did not materialize'
|
||||
grep -q '"outcome":"ready"' "$result_root/secret-config-plan.result.json" || fail 'Secret/Config plan is manual-required or has no applicable candidates'
|
||||
secret_config_plan_digest=$(extract_digest "$result_root/secret-config-plan.result.json" secretConfigPlanDigest)
|
||||
cat >"$command_root/secret-config-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.secret-config.verify","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","secretConfigRoot":"$secret_config_root","allowRootService":$allow_root_service},"request":{"secretConfigId":"$SECRET_CONFIG_ID","expectedSecretConfigPlanDigest":"$secret_config_plan_digest"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/secret-config-verify.json"
|
||||
run_deploy reconciliation-secret-config-verify secret-config-verify.json secret-config-verify.result.json
|
||||
grep -q '"status":"verified"' "$result_root/secret-config-verify.result.json" || fail 'Secret/Config plan verification failed'
|
||||
secret_config_head_digest=$(extract_digest "$result_root/secret-config-verify.result.json" instanceHeadDigest)
|
||||
|
||||
secret_decision_prepared_ms=$((secret_prepared_ms + 1))
|
||||
cat >"$command_root/secret-config-decision-prepare.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.secret-config.decision.prepare","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","secretConfigRoot":"$secret_config_root","secretConfigDecisionRoot":"$secret_config_decision_root","allowRootService":$allow_root_service},"request":{"decisionId":"$SECRET_CONFIG_DECISION_ID","secretConfigId":"$SECRET_CONFIG_ID","expectedSecretConfigPlanDigest":"$secret_config_plan_digest","expectedHeadDigest":"$secret_config_head_digest","preparedAtMs":$secret_decision_prepared_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/secret-config-decision-prepare.json"
|
||||
run_deploy reconciliation-secret-config-decision-prepare secret-config-decision-prepare.json secret-config-decision-prepare.result.json
|
||||
grep -q '"state":"reconciliation_secret_config_decision_prepared"' "$result_root/secret-config-decision-prepare.result.json" || fail 'Secret/Config decision authority was not prepared'
|
||||
secret_decision_preparation_digest=$(extract_digest "$result_root/secret-config-decision-prepare.result.json" preparationDigest)
|
||||
secret_rows=$(extract_unsigned "$result_root/secret-config-plan.result.json" rowCount)
|
||||
eligible_bindings=$(extract_unsigned "$result_root/secret-config-plan.result.json" eligibleBindingCount)
|
||||
eligible_preservations=$(extract_unsigned "$result_root/secret-config-plan.result.json" eligiblePreservationCount)
|
||||
target_conflicts=$(extract_unsigned "$result_root/secret-config-plan.result.json" targetConflictCount)
|
||||
unadapted_configs=$(extract_unsigned "$result_root/secret-config-plan.result.json" unadaptedLegacyConfigCount)
|
||||
[ $((eligible_bindings + eligible_preservations)) -gt 0 ] && [ "$target_conflicts" -eq 0 ] && [ "$unadapted_configs" -eq 0 ] || fail 'Secret/Config plan is not eligible for reviewed completion'
|
||||
cat >"$reconciliation_root/summary.json" <<EOF
|
||||
{"schemaVersion":1,"schema":"qinglong/local-alpha-reconciliation-rehearsal-summary@v1","status":"secret_config_decision_required","profile":"$profile","variant":"$VARIANT","sourceRevision":"$SOURCE_REVISION","architecture":"$ARCHITECTURE","review":{"reviewId":"$REVIEW_ID","authority":"authenticated_user"},"application":{"applicationId":"$APPLICATION_ID","applicationPlanDigest":"$application_plan_digest"},"automation":{"automationId":"$AUTOMATION_ID","decisionId":"$AUTOMATION_DECISION_ID","decisionDigest":"$decision_digest","applyDigest":"$apply_digest"},"runHistory":{"preservationId":"$RUN_HISTORY_PRESERVATION_ID","preservationDigest":"$preservation_digest","legacyFactCount":$legacy_history_facts,"targetFactCount":$target_history_facts},"secretConfig":{"secretConfigId":"$SECRET_CONFIG_ID","secretConfigPlanDigest":"$secret_config_plan_digest","rowCount":$secret_rows,"eligibleBindingCount":$eligible_bindings,"eligiblePreservationCount":$eligible_preservations,"targetConflictCount":$target_conflicts,"unadaptedLegacyConfigCount":$unadapted_configs},"secretConfigDecision":{"decisionId":"$SECRET_CONFIG_DECISION_ID","preparationDigest":"$secret_decision_preparation_digest"},"target":"stopped","legacy":"stopped","automaticCandidateDecision":"not_authorized","next":"supply_external_secret_config_decisions"}
|
||||
EOF
|
||||
chmod 0600 "$reconciliation_root/summary.json"
|
||||
printf '%s\n' \
|
||||
'Automation application, dual Run History preservation, and Secret/Config plan are ready.' \
|
||||
"Summary: $reconciliation_root/summary.json" \
|
||||
"Secret/Config plan: $secret_config_root/$SECRET_CONFIG_ID/plan.ndjson" \
|
||||
'No Secret/Config candidate decision was generated or applied. Supply an external owner-private NDJSON decision file to the complete phase.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -e "$command_root/automation-rollback.json" ]; then
|
||||
rolled_back_ms=$(extract_unsigned "$command_root/automation-rollback.json" rolledBackAtMs)
|
||||
else
|
||||
|
||||
@@ -80,8 +80,8 @@ function writeMilestone(directory, manifest) {
|
||||
|
||||
function localManifest(attempt = runAttempt, variant = 'headless') {
|
||||
return {
|
||||
schemaVersion: 6,
|
||||
schema: 'qinglong/alpha-local-milestone@v6',
|
||||
schemaVersion: 7,
|
||||
schema: 'qinglong/alpha-local-milestone@v7',
|
||||
maturity: 'alpha_candidate_not_public_release',
|
||||
product: 'local',
|
||||
variant,
|
||||
|
||||
@@ -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@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) => {
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user