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