mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): plan reconciliation application
This commit is contained in:
@@ -11,6 +11,25 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-21):
|
最新增量证据(2026-08-21):
|
||||||
|
|
||||||
|
- D-392/ADR-0485(已接受):D-391 的 signed review 不能直接获得通用 DML authority;表级 `adopt_legacy/retain_both` 也不能证明
|
||||||
|
Automation 行级 command/trigger 兼容,更不能覆盖 Secret custody、append-only history、Plugin/AI 外部资产与 Identity/Policy 语义。
|
||||||
|
因此既有 Local Owner 新增 `reconciliation.application.prepare|commit|verify`,以
|
||||||
|
`reconciliation_reviewed → reconciliation_application_prepared → reconciliation_application_planned` CAS 建立唯一领域执行根。coordinator
|
||||||
|
重新验证 sealed review、issuer HMAC、decision-set 与 instance head,只用固定 counters 生成八领域、Legacy/Target 双侧、content-free 的
|
||||||
|
disposition/action plan;plan 不包含名称、路径、fact digest、row value、command、Secret、reviewer identity,且上限 64 KiB。Legacy fact
|
||||||
|
现拒绝 `retain_target`,Target fact 拒绝 `adopt_legacy/exclude_legacy`,修复了 D-391 的数据库方向语义缺口。intent/plan/receipt/seal/head
|
||||||
|
全部 no-replace exact replay,terminal 收敛为 `0400/0500`,专用只读 reader 不放宽通用 `0600` command-file policy。prepare/commit/verify
|
||||||
|
均不打开 SQLite、不执行 SQL/DML、Secret 解密、service/Docker/network,也不授予 restart/completion。实现位于既有
|
||||||
|
`deployment/reconciliation/application/` 的 contract/plan/coordinator 三个职责文件,没有新增 package、dependency、binary、daemon 或
|
||||||
|
`src/` 根平铺。聚焦套件 `35 total / 33 pass / 2 conditional Docker skip / 0 fail`,完整 Local Owner
|
||||||
|
`257 total / 250 pass / 7 conditional skip / 0 fail`,Git 跟踪 backend
|
||||||
|
`1541 total / 1539 pass / 2 conditional skip / 0 fail`;包含用户未提交测试的工作区 backend 也以 `1542/1540/2/0` 通过。18-package clean
|
||||||
|
build/逐包测试、八项架构/部署审计、真实 Docker readonly reconciliation `2/2` 与十四档 artifact audit 全通过。workspace 仍为 18
|
||||||
|
packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `158 source / 157 nested / 1 root binary entry`。基础
|
||||||
|
Edge/Standalone 精确保持 `2,611,978 / 2,612,056 bytes`、319 files、58 modules。虽无 SQL/Cluster 变更,仍额外重跑 PostgreSQL 18.6
|
||||||
|
arm64 physical HA:146 gates、timeline `1 → 2`,private evidence SHA-256
|
||||||
|
`632d71f2a5b33cf657476fbe41702064b609b12cdf2382bcddaef06f4d08279f`,独立审计无 finding,Docker 资源已清理。D-393 才能以独立 ADR
|
||||||
|
定义首个 Automation 行级 adapter、写前 backup、重新认证、幂等冲突、commit/replay 与 rollback。
|
||||||
- D-391/ADR-0484(已接受):既有 Local Owner 已完整实现
|
- D-391/ADR-0484(已接受):既有 Local Owner 已完整实现
|
||||||
`reconciliation.review.prepare|diagnostics|commit|verify`,以
|
`reconciliation.review.prepare|diagnostics|commit|verify`,以
|
||||||
`reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把一个
|
`reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把一个
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# ADR-0485:Reconciliation 应用协调器与领域 Adapter 边界
|
||||||
|
|
||||||
|
- 状态:Accepted(D-392 已实现并完成门禁)
|
||||||
|
- 日期:2026-08-21
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-05、D-06、D-17、D-64、D-87、D-184、D-259、D-383、D-389、D-390、D-391、D-392
|
||||||
|
- 关联 ADR:ADR-0482、ADR-0483、ADR-0484
|
||||||
|
- 细化:ADR-0484 的签名人工裁决消费边界
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
ADR-0484 已把密封 reconciliation plan 转换为逐事实、强认证 User 签名的 review authorization,但该 authorization 仍只表达
|
||||||
|
operator 对 schema object/table 事实的选择。它没有定义任何领域的行级兼容、幂等键、写入顺序、备份、回滚或外部资产语义。
|
||||||
|
|
||||||
|
直接让通用导入器消费 review 会产生错误的授权提升。例如 Automation 表被选择为 `adopt_legacy`,并不意味着表内每个 Legacy shell
|
||||||
|
command 都能转换为 3.0 TaskDefinition,也不能证明目标 task id、timezone、disabled state 和 trigger 没有冲突。Secret、Run history、
|
||||||
|
Plugin package、AI/Tool 与 Identity/Policy/Audit 的安全语义差异更大,不存在一套可信的通用 DML。
|
||||||
|
|
||||||
|
D-392 因此只建立一个持久、内容无关的应用顺序根。它把签名 decision stream 汇总成固定八领域计划,为后续 adapter 提供唯一 fence,
|
||||||
|
但不执行迁移。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
### 1. 增加独立 application coordinator 状态机
|
||||||
|
|
||||||
|
既有 `ql3-local-deploy` 增加三个显式私有命令:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local.deployment.reconciliation.application.prepare
|
||||||
|
local.deployment.reconciliation.application.commit
|
||||||
|
local.deployment.reconciliation.application.verify
|
||||||
|
```
|
||||||
|
|
||||||
|
状态以 instance head CAS 推进:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reconciliation_reviewed
|
||||||
|
→ reconciliation_application_prepared
|
||||||
|
→ reconciliation_application_planned
|
||||||
|
```
|
||||||
|
|
||||||
|
`application.prepare` 必须绑定 exact `reviewId`、`reviewDigest` 和 reviewed head digest。它建立唯一 application fence 后,以 no-replace
|
||||||
|
方式发布 intent。第二个 application、旧 review replay、rollback、restart 或越级 commit 都必须失败关闭。
|
||||||
|
|
||||||
|
`reconciliation_application_planned` 只表示领域执行顺序已固定,不表示 reconciliation 完成、数据库已迁移或 target 可以重启。
|
||||||
|
|
||||||
|
### 2. Application plan 固定为八领域内容无关摘要
|
||||||
|
|
||||||
|
coordinator 重新验证 D-391 的 sealed review directory、authorization HMAC、issuer keyring、review/receipt binding 和 decision-set digest,
|
||||||
|
并在读取签名 NDJSON 时以固定 counter 统计:
|
||||||
|
|
||||||
|
```text
|
||||||
|
schema_lineage
|
||||||
|
automation
|
||||||
|
secret_and_config
|
||||||
|
run_history
|
||||||
|
plugin_package
|
||||||
|
ai_and_tool
|
||||||
|
identity_policy_audit
|
||||||
|
unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
每个领域只保存 Legacy/Target 两侧的 decision count、固定 disposition counts、summary digest 与 action:
|
||||||
|
|
||||||
|
```text
|
||||||
|
no_effect | adapter_required | manual_external | adapter_and_manual
|
||||||
|
```
|
||||||
|
|
||||||
|
总体 outcome 为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
no_effect_ready | adapter_required |
|
||||||
|
manual_required | adapter_and_manual_required
|
||||||
|
```
|
||||||
|
|
||||||
|
plan 不保存对象名、表名、路径、fact digest、row value、command、schedule、Secret、credential、Prompt/Tool payload、Artifact、日志或 reviewer
|
||||||
|
identity。它最多 64 KiB,domain 数固定为 8,内存只使用固定 counters 与 64 KiB 读取缓冲。
|
||||||
|
|
||||||
|
### 3. 修正 disposition 的数据库方向语义
|
||||||
|
|
||||||
|
D-391 已限制 blocked domain,但原始校验没有拒绝数据库方向矛盾的选择。D-392 明确:
|
||||||
|
|
||||||
|
- Legacy fact 不允许 `retain_target`;
|
||||||
|
- Target fact 不允许 `adopt_legacy` 或 `exclude_legacy`;
|
||||||
|
- `retain_both`、`defer` 与 `manual_external` 仍受既有 domain/blocked 规则约束;
|
||||||
|
- informational fact 仍不得出现在 decision stream。
|
||||||
|
|
||||||
|
该规则在签名发布前对重新派生的 canonical fact 生效,不依赖 diagnostics page。
|
||||||
|
|
||||||
|
### 4. 持久化、封存与崩溃恢复
|
||||||
|
|
||||||
|
`applicationRoot` 必须与 deployment、capture、plan、review root 两两不重叠;issuer keyring 必须位于 deployment root 的真后代。catalog 最多
|
||||||
|
保留 64 个 application directory,每个目录只含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
intent.json
|
||||||
|
plan.json
|
||||||
|
receipt.json
|
||||||
|
staging/
|
||||||
|
```
|
||||||
|
|
||||||
|
intent、plan、receipt 使用 no-replace publication、stable file identity、file/directory fsync 和 digest binding。prepare head、plan、receipt、
|
||||||
|
terminal seal 与 planned head 后的 response loss 均必须 exact replay;冲突内容不允许覆盖或自动清理。terminal 文件收敛为 `0400`,目录与
|
||||||
|
空 staging 收敛为 `0500`。专用 terminal reader 在读取前后复核 owner、mode、link count、device/inode、size、mtime/ctime,不放宽通用
|
||||||
|
`0600` command-file policy。
|
||||||
|
|
||||||
|
### 5. Verify 完全只读
|
||||||
|
|
||||||
|
verify 只读取 sealed application evidence、sealed review authorization、issuer verification key 与 instance head。它不创建、替换、修复或
|
||||||
|
清理文件,不打开 Legacy/Target SQLite,不执行 checkpoint、SQL/DML、Secret 解密、Docker/init/service/network 或后台 retry。
|
||||||
|
|
||||||
|
应用 coordinator 的 prepare/commit 同样不得打开 SQLite。它消费的是 D-391 已签名、内容完整性受保护的 decision stream,而不是重新读取
|
||||||
|
数据库。每个未来 adapter 在写入前仍必须重新验证 exact application plan/head 和自己的领域输入。
|
||||||
|
|
||||||
|
### 6. 领域 Adapter 必须独立授权和回滚
|
||||||
|
|
||||||
|
D-392 不提供通用 apply。后续每个 adapter 至少要单独定义:
|
||||||
|
|
||||||
|
- 可接受的 source/target row schema 与兼容矩阵;
|
||||||
|
- Project/Policy/Owner authority 与需要重新认证的操作;
|
||||||
|
- 幂等 identity、冲突规则、写入顺序和 response-loss replay;
|
||||||
|
- 写前 backup、失败保全、rollback 与不可逆边界;
|
||||||
|
- Secret custody、append-only history、外部文件/OCI/KMS 等领域特有语义;
|
||||||
|
- Edge 的 RSS/I/O/磁盘峰值与 Cluster 的并发、租约和 HA 语义。
|
||||||
|
|
||||||
|
第一候选为 Automation adapter,但它必须先生成逐行、可审查、可回滚的 TaskDefinition/Trigger 转换计划,不能把 D-391 的表级选择直接解释为
|
||||||
|
批量 INSERT/UPDATE。
|
||||||
|
|
||||||
|
### 7. Package 与部署边界
|
||||||
|
|
||||||
|
实现内聚在既有 `@qinglong/local-owner-cli/src/deployment/reconciliation/application/`,按 contract、plan、coordinator 三个职责文件组织。
|
||||||
|
不新增 workspace package、production dependency、binary、daemon、timer、watcher、listener 或 socket,也不把文件平铺到 package `src/`
|
||||||
|
根。基础 Edge/Standalone runtime artifact 不应携带一次性 application authority;Cluster/PostgreSQL/Kubernetes 不读取 Local application root。
|
||||||
|
|
||||||
|
## 当前实现进度
|
||||||
|
|
||||||
|
D-392 已实现三条命令、两阶段 instance CAS、八领域 content-free plan、signed authorization domain counters、数据库方向校验、`0400/0500`
|
||||||
|
terminal seal、完整 response-loss replay 和只读 verify。聚焦 reconciliation 套件为
|
||||||
|
`35 total / 33 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner 为
|
||||||
|
`257 total / 250 pass / 7 conditional skip / 0 fail`;Git 跟踪 backend 为
|
||||||
|
`1541 total / 1539 pass / 2 conditional skip / 0 fail`。包含用户未提交测试的当前工作区 backend 也以
|
||||||
|
`1542 total / 1540 pass / 2 conditional skip / 0 fail` 通过。18-package clean build/逐包测试、八项架构/部署审计与真实 Docker readonly
|
||||||
|
reconciliation `2/2` 全部通过。
|
||||||
|
|
||||||
|
十四档 artifact audit 全部 `compatible`;基础 Edge/Standalone 精确保持
|
||||||
|
`2,611,978 / 2,612,056 bytes`、319 files、58 loaded modules,证明一次性 application authority 未进入低配常驻闭包。workspace 仍为 18
|
||||||
|
packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为
|
||||||
|
`158 source / 157 nested / 1 root binary entry`。新增 3 个生产源码全部位于既有 `deployment/reconciliation/application/`,没有新增 package、
|
||||||
|
dependency、binary 或常驻对象。
|
||||||
|
|
||||||
|
本切片没有 SQL、SQLite open、数据库 schema、PostgreSQL driver/ACL/role/Pool、Cluster deployment 或 HA 拓扑改动;仍按 operator 授权额外
|
||||||
|
重跑 PostgreSQL 18.6 arm64 physical HA,以 146 gates、timeline `1 → 2` 通过。private evidence SHA-256 为
|
||||||
|
`632d71f2a5b33cf657476fbe41702064b609b12cdf2382bcddaef06f4d08279f`,独立 evidence audit 无 finding,临时 Docker container/network 已
|
||||||
|
清理。该证明是额外回归证据,不把 Local application coordinator 解释为 Cluster reconciliation authority。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### 让 D-391 review 直接调用 Automation adoption
|
||||||
|
|
||||||
|
拒绝。D-391 是 schema/table fact 授权,既有 adoption 是逐行首次接管协议;二者的冲突、幂等和 rollback 语义不等价。
|
||||||
|
|
||||||
|
### 为八个领域立即各建一个 package
|
||||||
|
|
||||||
|
拒绝。尚未形成独立发布、依赖或生命周期边界,会制造单文件/浅层 package。领域 adapter 先在现有 owner composition 的嵌套目录孵化,只有
|
||||||
|
出现稳定复用边界后才考虑拆包。
|
||||||
|
|
||||||
|
### 把全部 decision 加载成内存 JSON
|
||||||
|
|
||||||
|
拒绝。Edge 路由设备不能让内存随 schema 规模增长;签名验证与 domain summary 必须流式、固定缓冲。
|
||||||
|
|
||||||
|
### 把 application plan 当作 restart authority
|
||||||
|
|
||||||
|
拒绝。计划没有证明任何 adapter 已成功执行,也没有证明 Secret、历史或外部资产已收敛。
|
||||||
|
|
||||||
|
## 验收条件
|
||||||
|
|
||||||
|
1. 只有 exact reviewed head/review digest 可建立唯一 application fence;第二 application、rollback 与 restart 被阻断。
|
||||||
|
2. signed authorization 被重新验证并汇总为固定八领域、双数据库、固定 disposition counters;全局与领域计数严格守恒。
|
||||||
|
3. Legacy/Target 的方向矛盾 disposition 在签名前失败;blocked/informational 规则不回退。
|
||||||
|
4. application plan/receipt 不包含名称、路径、fact digest、row value、command、Secret 或 reviewer identity,且 plan 不超过 64 KiB。
|
||||||
|
5. prepare、plan、receipt、seal、head 的全部 response-loss 窗口 exact replay,竞争 application 失败关闭。
|
||||||
|
6. terminal 文件为 `0400`、目录为 `0500`;verify 只读且不打开 SQLite、不写数据库、不调用服务或网络。
|
||||||
|
7. 不新增 package/dependency/binary/常驻对象,不平铺 package `src/`;Edge 基础 closure 不增长。
|
||||||
|
8. 聚焦、Local Owner、tracked backend、18-package、架构、Docker 与十四档 artifact 门通过后,ADR 才可改为 Accepted 并阶段提交。
|
||||||
|
|
||||||
|
## 未包含
|
||||||
|
|
||||||
|
- Automation 或其他领域的行级转换、SQL/DML、备份与 rollback;
|
||||||
|
- Secret 解密/重加密、Run history 合并、Plugin/AI 外部资产复制;
|
||||||
|
- reconciliation completion、target restart 或 Legacy source 删除;
|
||||||
|
- Cluster/PostgreSQL/Kubernetes reconciliation;
|
||||||
|
- 固定物理 Edge/NAS 的断电、FTL 写放大与迁移峰值证明。
|
||||||
@@ -29,6 +29,8 @@ export type LocalCutoverInstanceHeadState =
|
|||||||
| 'reconciliation_planned'
|
| 'reconciliation_planned'
|
||||||
| 'reconciliation_review_prepared'
|
| 'reconciliation_review_prepared'
|
||||||
| 'reconciliation_reviewed'
|
| 'reconciliation_reviewed'
|
||||||
|
| 'reconciliation_application_prepared'
|
||||||
|
| 'reconciliation_application_planned'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
| 'legacy_running'
|
| 'legacy_running'
|
||||||
@@ -166,6 +168,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
|||||||
head.state !== 'reconciliation_planned' &&
|
head.state !== 'reconciliation_planned' &&
|
||||||
head.state !== 'reconciliation_review_prepared' &&
|
head.state !== 'reconciliation_review_prepared' &&
|
||||||
head.state !== 'reconciliation_reviewed' &&
|
head.state !== 'reconciliation_reviewed' &&
|
||||||
|
head.state !== 'reconciliation_application_prepared' &&
|
||||||
|
head.state !== 'reconciliation_application_planned' &&
|
||||||
head.state !== 'rollback_prepared' &&
|
head.state !== 'rollback_prepared' &&
|
||||||
head.state !== 'legacy_restart_requested' &&
|
head.state !== 'legacy_restart_requested' &&
|
||||||
head.state !== 'legacy_running' &&
|
head.state !== 'legacy_running' &&
|
||||||
@@ -338,6 +342,8 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
| 'reconciliation_planned'
|
| 'reconciliation_planned'
|
||||||
| 'reconciliation_review_prepared'
|
| 'reconciliation_review_prepared'
|
||||||
| 'reconciliation_reviewed'
|
| 'reconciliation_reviewed'
|
||||||
|
| 'reconciliation_application_prepared'
|
||||||
|
| 'reconciliation_application_planned'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
| 'legacy_running'
|
| 'legacy_running'
|
||||||
@@ -382,6 +388,8 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_planned' ||
|
current.state === 'reconciliation_planned' ||
|
||||||
current.state === 'reconciliation_review_prepared' ||
|
current.state === 'reconciliation_review_prepared' ||
|
||||||
current.state === 'reconciliation_reviewed' ||
|
current.state === 'reconciliation_reviewed' ||
|
||||||
|
current.state === 'reconciliation_application_prepared' ||
|
||||||
|
current.state === 'reconciliation_application_planned' ||
|
||||||
current.state === 'legacy_restart_requested' ||
|
current.state === 'legacy_restart_requested' ||
|
||||||
current.state === 'legacy_running' ||
|
current.state === 'legacy_running' ||
|
||||||
current.state === 'legacy_ready')
|
current.state === 'legacy_ready')
|
||||||
@@ -409,6 +417,10 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_planned') ||
|
current.state === 'reconciliation_planned') ||
|
||||||
(state === 'reconciliation_reviewed' &&
|
(state === 'reconciliation_reviewed' &&
|
||||||
current.state === 'reconciliation_review_prepared') ||
|
current.state === 'reconciliation_review_prepared') ||
|
||||||
|
(state === 'reconciliation_application_prepared' &&
|
||||||
|
current.state === 'reconciliation_reviewed') ||
|
||||||
|
(state === 'reconciliation_application_planned' &&
|
||||||
|
current.state === 'reconciliation_application_prepared') ||
|
||||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||||
(state === 'legacy_restart_requested' &&
|
(state === 'legacy_restart_requested' &&
|
||||||
current.state === 'rollback_prepared') ||
|
current.state === 'rollback_prepared') ||
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ import {
|
|||||||
verifyLocalReconciliationReview,
|
verifyLocalReconciliationReview,
|
||||||
verifyLocalReconciliationReviewCommandFile,
|
verifyLocalReconciliationReviewCommandFile,
|
||||||
} from './reconciliation/review/completion';
|
} from './reconciliation/review/completion';
|
||||||
|
import {
|
||||||
|
commitLocalReconciliationApplication,
|
||||||
|
commitLocalReconciliationApplicationCommandFile,
|
||||||
|
prepareLocalReconciliationApplication,
|
||||||
|
prepareLocalReconciliationApplicationCommandFile,
|
||||||
|
verifyLocalReconciliationApplication,
|
||||||
|
verifyLocalReconciliationApplicationCommandFile,
|
||||||
|
} from './reconciliation/application/coordinator';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
@@ -139,8 +147,42 @@ export {
|
|||||||
commitLocalReconciliationReviewCommandFile,
|
commitLocalReconciliationReviewCommandFile,
|
||||||
verifyLocalReconciliationReview,
|
verifyLocalReconciliationReview,
|
||||||
verifyLocalReconciliationReviewCommandFile,
|
verifyLocalReconciliationReviewCommandFile,
|
||||||
|
prepareLocalReconciliationApplication,
|
||||||
|
prepareLocalReconciliationApplicationCommandFile,
|
||||||
|
commitLocalReconciliationApplication,
|
||||||
|
commitLocalReconciliationApplicationCommandFile,
|
||||||
|
verifyLocalReconciliationApplication,
|
||||||
|
verifyLocalReconciliationApplicationCommandFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
normalizeLocalReconciliationApplicationCommitCommand,
|
||||||
|
normalizeLocalReconciliationApplicationPrepareCommand,
|
||||||
|
normalizeLocalReconciliationApplicationVerifyCommand,
|
||||||
|
type LocalReconciliationApplicationCommitCommand,
|
||||||
|
type LocalReconciliationApplicationOptions,
|
||||||
|
type LocalReconciliationApplicationPrepareCommand,
|
||||||
|
type LocalReconciliationApplicationPrepareResult,
|
||||||
|
type LocalReconciliationApplicationTerminalResult,
|
||||||
|
type LocalReconciliationApplicationVerifyCommand,
|
||||||
|
} from './reconciliation/application/contract';
|
||||||
|
export {
|
||||||
|
localReconciliationApplicationDirectory,
|
||||||
|
normalizeLocalReconciliationApplicationIntent,
|
||||||
|
readLocalReconciliationApplicationIntent,
|
||||||
|
type LocalReconciliationApplicationDependencies,
|
||||||
|
type LocalReconciliationApplicationIntent,
|
||||||
|
} from './reconciliation/application/coordinator';
|
||||||
|
export {
|
||||||
|
normalizeLocalReconciliationApplicationPlan,
|
||||||
|
normalizeLocalReconciliationApplicationPlanReceipt,
|
||||||
|
type LocalReconciliationApplicationDatabaseDecisionSummary,
|
||||||
|
type LocalReconciliationApplicationDomainAction,
|
||||||
|
type LocalReconciliationApplicationDomainSummary,
|
||||||
|
type LocalReconciliationApplicationPlan,
|
||||||
|
type LocalReconciliationApplicationPlanReceipt,
|
||||||
|
} from './reconciliation/application/plan';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
LocalDeploymentConfigurationError,
|
LocalDeploymentConfigurationError,
|
||||||
normalizeLocalDeploymentComposeApplyCommand,
|
normalizeLocalDeploymentComposeApplyCommand,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import {
|
|||||||
verifyLocalReconciliationCaptureCommandFile,
|
verifyLocalReconciliationCaptureCommandFile,
|
||||||
verifyLocalReconciliationPlanCommandFile,
|
verifyLocalReconciliationPlanCommandFile,
|
||||||
verifyLocalReconciliationReviewCommandFile,
|
verifyLocalReconciliationReviewCommandFile,
|
||||||
|
prepareLocalReconciliationApplicationCommandFile,
|
||||||
|
commitLocalReconciliationApplicationCommandFile,
|
||||||
|
verifyLocalReconciliationApplicationCommandFile,
|
||||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||||
prepareLocalDeploymentCommandFile,
|
prepareLocalDeploymentCommandFile,
|
||||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||||
@@ -37,7 +40,7 @@ import {
|
|||||||
} from './localDeployment';
|
} from './localDeployment';
|
||||||
|
|
||||||
const USAGE =
|
const USAGE =
|
||||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||||
|
|
||||||
async function main(argv: readonly string[]): Promise<void> {
|
async function main(argv: readonly string[]): Promise<void> {
|
||||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||||
@@ -76,6 +79,9 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
argv[0] !== 'reconciliation-review-diagnostics' &&
|
argv[0] !== 'reconciliation-review-diagnostics' &&
|
||||||
argv[0] !== 'reconciliation-review-commit' &&
|
argv[0] !== 'reconciliation-review-commit' &&
|
||||||
argv[0] !== 'reconciliation-review-verify' &&
|
argv[0] !== 'reconciliation-review-verify' &&
|
||||||
|
argv[0] !== 'reconciliation-application-prepare' &&
|
||||||
|
argv[0] !== 'reconciliation-application-commit' &&
|
||||||
|
argv[0] !== 'reconciliation-application-verify' &&
|
||||||
argv[0] !== 'compose-revision' &&
|
argv[0] !== 'compose-revision' &&
|
||||||
argv[0] !== 'compose-preflight' &&
|
argv[0] !== 'compose-preflight' &&
|
||||||
argv[0] !== 'compose-apply' &&
|
argv[0] !== 'compose-apply' &&
|
||||||
@@ -166,6 +172,12 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
? commitLocalReconciliationReviewCommandFile(argv[2]!)
|
? commitLocalReconciliationReviewCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-review-verify'
|
: argv[0] === 'reconciliation-review-verify'
|
||||||
? verifyLocalReconciliationReviewCommandFile(argv[2]!)
|
? verifyLocalReconciliationReviewCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-application-prepare'
|
||||||
|
? prepareLocalReconciliationApplicationCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-application-commit'
|
||||||
|
? commitLocalReconciliationApplicationCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-application-verify'
|
||||||
|
? verifyLocalReconciliationApplicationCommandFile(argv[2]!)
|
||||||
: argv[0] === 'compose-revision'
|
: argv[0] === 'compose-revision'
|
||||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||||
: argv[0] === 'compose-preflight'
|
: argv[0] === 'compose-preflight'
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { currentIdentity } from '../../foundation/contract';
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||||
|
|
||||||
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
|
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||||
|
const UUID_V4_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
const MAX_PATH_BYTES = 4_096;
|
||||||
|
|
||||||
|
export interface LocalReconciliationApplicationOptions {
|
||||||
|
readonly deploymentRoot: string;
|
||||||
|
readonly captureRoot: string;
|
||||||
|
readonly planRoot: string;
|
||||||
|
readonly reviewRoot: string;
|
||||||
|
readonly applicationRoot: string;
|
||||||
|
readonly issuerKeyringPath: string;
|
||||||
|
readonly allowRootService: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationApplicationPrepareCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.application.prepare';
|
||||||
|
readonly options: Readonly<LocalReconciliationApplicationOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
applicationId: string;
|
||||||
|
reviewId: string;
|
||||||
|
expectedReviewDigest: string;
|
||||||
|
expectedHeadDigest: string;
|
||||||
|
preparedAtMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationApplicationCommitCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.application.commit';
|
||||||
|
readonly options: Readonly<LocalReconciliationApplicationOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
applicationId: string;
|
||||||
|
expectedPreparationDigest: string;
|
||||||
|
expectedHeadDigest: string;
|
||||||
|
committedAtMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationApplicationVerifyCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.application.verify';
|
||||||
|
readonly options: Readonly<LocalReconciliationApplicationOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
applicationId: string;
|
||||||
|
expectedApplicationPlanDigest: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationApplicationPrepareResult {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.application.prepare';
|
||||||
|
readonly status: 'prepared' | 'existing';
|
||||||
|
readonly state: 'reconciliation_application_prepared';
|
||||||
|
readonly applicationId: string;
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
readonly instanceHeadDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationApplicationTerminalResult {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation:
|
||||||
|
| 'local.deployment.reconciliation.application.commit'
|
||||||
|
| 'local.deployment.reconciliation.application.verify';
|
||||||
|
readonly status: 'prepared' | 'existing' | 'verified';
|
||||||
|
readonly state: 'reconciliation_application_planned';
|
||||||
|
readonly applicationId: string;
|
||||||
|
readonly applicationPlanDigest: string;
|
||||||
|
readonly outcome:
|
||||||
|
| 'no_effect_ready'
|
||||||
|
| 'adapter_required'
|
||||||
|
| 'manual_required'
|
||||||
|
| 'adapter_and_manual_required';
|
||||||
|
readonly domainCount: 8;
|
||||||
|
readonly instanceHeadDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationError(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||||
|
Object.getPrototypeOf(value) !== null)
|
||||||
|
) {
|
||||||
|
configurationError(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
keys: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
) {
|
||||||
|
configurationError(`${label} shape is invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePath(value: unknown, label: string): string {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
!path.isAbsolute(value) ||
|
||||||
|
path.parse(value).root === value ||
|
||||||
|
path.normalize(value) !== value ||
|
||||||
|
value.includes('\0') ||
|
||||||
|
value.includes('//') ||
|
||||||
|
!SAFE_PATH_PATTERN.test(value) ||
|
||||||
|
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||||
|
) {
|
||||||
|
configurationError(`${label} must be a safe non-root absolute path`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(left: string, right: string): boolean {
|
||||||
|
const relative = path.relative(left, right);
|
||||||
|
return (
|
||||||
|
relative === '' ||
|
||||||
|
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function descendant(root: string, candidate: string, label: string): void {
|
||||||
|
const relative = path.relative(root, candidate);
|
||||||
|
if (
|
||||||
|
relative.length === 0 ||
|
||||||
|
relative === '..' ||
|
||||||
|
relative.startsWith(`..${path.sep}`) ||
|
||||||
|
path.isAbsolute(relative)
|
||||||
|
) {
|
||||||
|
configurationError(`${label} must be a descendant of deploymentRoot`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||||
|
configurationError(`${label} must be a SHA-256 digest`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identifier(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
|
||||||
|
configurationError(`${label} must be a UUID v4`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationApplicationOptions> {
|
||||||
|
const options = object(value, 'options');
|
||||||
|
exact(
|
||||||
|
options,
|
||||||
|
[
|
||||||
|
'allowRootService',
|
||||||
|
'applicationRoot',
|
||||||
|
'captureRoot',
|
||||||
|
'deploymentRoot',
|
||||||
|
'issuerKeyringPath',
|
||||||
|
'planRoot',
|
||||||
|
'reviewRoot',
|
||||||
|
],
|
||||||
|
'options',
|
||||||
|
);
|
||||||
|
const identity = currentIdentity();
|
||||||
|
if (
|
||||||
|
typeof options.allowRootService !== 'boolean' ||
|
||||||
|
(identity.uid === 0) !== options.allowRootService
|
||||||
|
) {
|
||||||
|
configurationError(
|
||||||
|
'reconciliation application command identity is invalid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const roots = [
|
||||||
|
safePath(options.deploymentRoot, 'deploymentRoot'),
|
||||||
|
safePath(options.captureRoot, 'captureRoot'),
|
||||||
|
safePath(options.planRoot, 'planRoot'),
|
||||||
|
safePath(options.reviewRoot, 'reviewRoot'),
|
||||||
|
safePath(options.applicationRoot, 'applicationRoot'),
|
||||||
|
];
|
||||||
|
for (let left = 0; left < roots.length; left += 1) {
|
||||||
|
for (let right = left + 1; right < roots.length; right += 1) {
|
||||||
|
if (
|
||||||
|
overlaps(roots[left]!, roots[right]!) ||
|
||||||
|
overlaps(roots[right]!, roots[left]!)
|
||||||
|
) {
|
||||||
|
configurationError(
|
||||||
|
'deployment, capture, plan, review and application roots must not overlap',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const issuerKeyringPath = safePath(
|
||||||
|
options.issuerKeyringPath,
|
||||||
|
'issuerKeyringPath',
|
||||||
|
);
|
||||||
|
descendant(roots[0]!, issuerKeyringPath, 'issuerKeyringPath');
|
||||||
|
return Object.freeze({
|
||||||
|
deploymentRoot: roots[0]!,
|
||||||
|
captureRoot: roots[1]!,
|
||||||
|
planRoot: roots[2]!,
|
||||||
|
reviewRoot: roots[3]!,
|
||||||
|
applicationRoot: roots[4]!,
|
||||||
|
issuerKeyringPath,
|
||||||
|
allowRootService: options.allowRootService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function command(
|
||||||
|
value: unknown,
|
||||||
|
operation:
|
||||||
|
| LocalReconciliationApplicationPrepareCommand['operation']
|
||||||
|
| LocalReconciliationApplicationCommitCommand['operation']
|
||||||
|
| LocalReconciliationApplicationVerifyCommand['operation'],
|
||||||
|
): Readonly<{
|
||||||
|
options: Readonly<LocalReconciliationApplicationOptions>;
|
||||||
|
request: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
|
const selected = object(value, 'reconciliation application command');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
['operation', 'options', 'request', 'schemaVersion'],
|
||||||
|
'command',
|
||||||
|
);
|
||||||
|
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||||
|
configurationError('reconciliation application command is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
options: normalizeOptions(selected.options),
|
||||||
|
request: object(selected.request, 'request'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationApplicationPrepareCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationApplicationPrepareCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.application.prepare',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
[
|
||||||
|
'applicationId',
|
||||||
|
'expectedHeadDigest',
|
||||||
|
'expectedReviewDigest',
|
||||||
|
'preparedAtMs',
|
||||||
|
'reviewId',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(selected.request.preparedAtMs) ||
|
||||||
|
(selected.request.preparedAtMs as number) < 0
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation application prepare command is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.application.prepare',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
applicationId: identifier(
|
||||||
|
selected.request.applicationId,
|
||||||
|
'applicationId',
|
||||||
|
),
|
||||||
|
reviewId: identifier(selected.request.reviewId, 'reviewId'),
|
||||||
|
expectedReviewDigest: digest(
|
||||||
|
selected.request.expectedReviewDigest,
|
||||||
|
'expectedReviewDigest',
|
||||||
|
),
|
||||||
|
expectedHeadDigest: digest(
|
||||||
|
selected.request.expectedHeadDigest,
|
||||||
|
'expectedHeadDigest',
|
||||||
|
),
|
||||||
|
preparedAtMs: selected.request.preparedAtMs as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationApplicationCommitCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationApplicationCommitCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.application.commit',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
[
|
||||||
|
'applicationId',
|
||||||
|
'committedAtMs',
|
||||||
|
'expectedHeadDigest',
|
||||||
|
'expectedPreparationDigest',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(selected.request.committedAtMs) ||
|
||||||
|
(selected.request.committedAtMs as number) < 0
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation application commit command is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.application.commit',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
applicationId: identifier(
|
||||||
|
selected.request.applicationId,
|
||||||
|
'applicationId',
|
||||||
|
),
|
||||||
|
expectedPreparationDigest: digest(
|
||||||
|
selected.request.expectedPreparationDigest,
|
||||||
|
'expectedPreparationDigest',
|
||||||
|
),
|
||||||
|
expectedHeadDigest: digest(
|
||||||
|
selected.request.expectedHeadDigest,
|
||||||
|
'expectedHeadDigest',
|
||||||
|
),
|
||||||
|
committedAtMs: selected.request.committedAtMs as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationApplicationVerifyCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationApplicationVerifyCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.application.verify',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
['applicationId', 'expectedApplicationPlanDigest'],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.application.verify',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
applicationId: identifier(
|
||||||
|
selected.request.applicationId,
|
||||||
|
'applicationId',
|
||||||
|
),
|
||||||
|
expectedApplicationPlanDigest: digest(
|
||||||
|
selected.request.expectedApplicationPlanDigest,
|
||||||
|
'expectedApplicationPlanDigest',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
+1010
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,510 @@
|
|||||||
|
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||||
|
import { cutoverDigest } from '../../cutover/targetEvidence';
|
||||||
|
import type {
|
||||||
|
LocalReconciliationReviewAuthorizationDomainDecisionCounts,
|
||||||
|
LocalReconciliationReviewAuthorizationEvidence,
|
||||||
|
} from '../review/authorization';
|
||||||
|
import {
|
||||||
|
LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS,
|
||||||
|
type LocalReconciliationReviewDisposition,
|
||||||
|
} from '../review/decisionFile';
|
||||||
|
import {
|
||||||
|
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||||
|
type LocalReconciliationPlanDomain,
|
||||||
|
} from '../planning/contract';
|
||||||
|
import type { LocalReconciliationApplicationIntent } from './coordinator';
|
||||||
|
|
||||||
|
const PLAN_SCHEMA = 'qinglong3-local-reconciliation-application-plan';
|
||||||
|
const RECEIPT_SCHEMA =
|
||||||
|
'qinglong3-local-reconciliation-application-plan-receipt';
|
||||||
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
|
const UUID_V4_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
const MAX_PLAN_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
export type LocalReconciliationApplicationDomainAction =
|
||||||
|
| 'no_effect'
|
||||||
|
| 'adapter_required'
|
||||||
|
| 'manual_external'
|
||||||
|
| 'adapter_and_manual';
|
||||||
|
|
||||||
|
export interface LocalReconciliationApplicationDatabaseDecisionSummary {
|
||||||
|
readonly decisionCount: number;
|
||||||
|
readonly dispositionCounts: Readonly<
|
||||||
|
Record<LocalReconciliationReviewDisposition, number>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationApplicationDomainSummary {
|
||||||
|
readonly domain: LocalReconciliationPlanDomain;
|
||||||
|
readonly legacy: Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
|
||||||
|
readonly target: Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
|
||||||
|
readonly action: LocalReconciliationApplicationDomainAction;
|
||||||
|
readonly summaryDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationApplicationPlan {
|
||||||
|
readonly schema: typeof PLAN_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly state: 'reconciliation_application_planned';
|
||||||
|
readonly applicationId: string;
|
||||||
|
readonly reviewId: string;
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
readonly preparedHeadDigest: string;
|
||||||
|
readonly reviewDigest: string;
|
||||||
|
readonly authorizationDigest: string;
|
||||||
|
readonly decisionSetDigest: string;
|
||||||
|
readonly decisionCount: number;
|
||||||
|
readonly committedAtMs: number;
|
||||||
|
readonly domains: readonly Readonly<LocalReconciliationApplicationDomainSummary>[];
|
||||||
|
readonly outcome:
|
||||||
|
| 'no_effect_ready'
|
||||||
|
| 'adapter_required'
|
||||||
|
| 'manual_required'
|
||||||
|
| 'adapter_and_manual_required';
|
||||||
|
readonly applicationPlanDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationApplicationPlanReceipt {
|
||||||
|
readonly schema: typeof RECEIPT_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly state: 'reconciliation_application_planned';
|
||||||
|
readonly applicationId: string;
|
||||||
|
readonly reviewId: string;
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
readonly preparedHeadDigest: string;
|
||||||
|
readonly reviewDigest: string;
|
||||||
|
readonly authorizationDigest: string;
|
||||||
|
readonly decisionSetDigest: string;
|
||||||
|
readonly decisionCount: number;
|
||||||
|
readonly applicationPlanDigest: string;
|
||||||
|
readonly outcome: LocalReconciliationApplicationPlan['outcome'];
|
||||||
|
readonly domainCount: 8;
|
||||||
|
readonly committedAtMs: number;
|
||||||
|
readonly receiptDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationError(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||||
|
Object.getPrototypeOf(value) !== null)
|
||||||
|
) {
|
||||||
|
configurationError(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
keys: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
) {
|
||||||
|
configurationError(`${label} shape is invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCount(value: unknown): value is number {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function counts(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): Readonly<Record<LocalReconciliationReviewDisposition, number>> {
|
||||||
|
const selected = object(value, label);
|
||||||
|
exact(selected, LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS, label);
|
||||||
|
if (
|
||||||
|
LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS.some(
|
||||||
|
(disposition) => !safeCount(selected[disposition]),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
configurationError(`${label} is invalid`);
|
||||||
|
}
|
||||||
|
return Object.freeze(selected) as Readonly<
|
||||||
|
Record<LocalReconciliationReviewDisposition, number>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumCounts(
|
||||||
|
selected: Readonly<Record<LocalReconciliationReviewDisposition, number>>,
|
||||||
|
): number {
|
||||||
|
const total = LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS.reduce(
|
||||||
|
(sum, disposition) => sum + BigInt(selected[disposition]),
|
||||||
|
0n,
|
||||||
|
);
|
||||||
|
if (total > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||||
|
configurationError('application decision count overflowed');
|
||||||
|
}
|
||||||
|
return Number(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseSummary(
|
||||||
|
selected: Readonly<Record<LocalReconciliationReviewDisposition, number>>,
|
||||||
|
): Readonly<LocalReconciliationApplicationDatabaseDecisionSummary> {
|
||||||
|
const dispositionCounts = Object.freeze({ ...selected });
|
||||||
|
return Object.freeze({
|
||||||
|
decisionCount: sumCounts(dispositionCounts),
|
||||||
|
dispositionCounts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function action(
|
||||||
|
selected: Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>,
|
||||||
|
): LocalReconciliationApplicationDomainAction {
|
||||||
|
const adapter =
|
||||||
|
selected.legacy.adopt_legacy +
|
||||||
|
selected.legacy.retain_both +
|
||||||
|
selected.target.retain_both >
|
||||||
|
0;
|
||||||
|
const manual =
|
||||||
|
selected.legacy.defer +
|
||||||
|
selected.legacy.manual_external +
|
||||||
|
selected.target.defer +
|
||||||
|
selected.target.manual_external >
|
||||||
|
0;
|
||||||
|
if (adapter && manual) return 'adapter_and_manual';
|
||||||
|
if (adapter) return 'adapter_required';
|
||||||
|
if (manual) return 'manual_external';
|
||||||
|
return 'no_effect';
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcome(
|
||||||
|
domains: readonly Readonly<LocalReconciliationApplicationDomainSummary>[],
|
||||||
|
): LocalReconciliationApplicationPlan['outcome'] {
|
||||||
|
const adapter = domains.some(
|
||||||
|
(domain) =>
|
||||||
|
domain.action === 'adapter_required' ||
|
||||||
|
domain.action === 'adapter_and_manual',
|
||||||
|
);
|
||||||
|
const manual = domains.some(
|
||||||
|
(domain) =>
|
||||||
|
domain.action === 'manual_external' ||
|
||||||
|
domain.action === 'adapter_and_manual',
|
||||||
|
);
|
||||||
|
if (adapter && manual) return 'adapter_and_manual_required';
|
||||||
|
if (adapter) return 'adapter_required';
|
||||||
|
if (manual) return 'manual_required';
|
||||||
|
return 'no_effect_ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationApplicationPlan(
|
||||||
|
intent: Readonly<LocalReconciliationApplicationIntent>,
|
||||||
|
authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>,
|
||||||
|
committedAtMs: number,
|
||||||
|
preparedHeadDigest: string,
|
||||||
|
): Readonly<LocalReconciliationApplicationPlan> {
|
||||||
|
if (
|
||||||
|
authorization.domainDecisionCounts.length !== 8 ||
|
||||||
|
authorization.decisionCount !==
|
||||||
|
authorization.domainDecisionCounts.reduce(
|
||||||
|
(total, selected) =>
|
||||||
|
total + sumCounts(selected.legacy) + sumCounts(selected.target),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
configurationError('authorization domain decision summary drifted');
|
||||||
|
}
|
||||||
|
const domains = Object.freeze(
|
||||||
|
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) => {
|
||||||
|
const selected = authorization.domainDecisionCounts[index];
|
||||||
|
if (!selected || selected.domain !== domain) {
|
||||||
|
configurationError('authorization domain ordering drifted');
|
||||||
|
}
|
||||||
|
const legacy = databaseSummary(selected.legacy);
|
||||||
|
const target = databaseSummary(selected.target);
|
||||||
|
const selectedAction = action(selected);
|
||||||
|
const summaryPayload = Object.freeze({
|
||||||
|
domain,
|
||||||
|
legacy,
|
||||||
|
target,
|
||||||
|
action: selectedAction,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
...summaryPayload,
|
||||||
|
summaryDigest: cutoverDigest(summaryPayload),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: PLAN_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
state: 'reconciliation_application_planned' as const,
|
||||||
|
applicationId: intent.command.request.applicationId,
|
||||||
|
reviewId: intent.command.request.reviewId,
|
||||||
|
profile: intent.profile,
|
||||||
|
preparationDigest: intent.preparationDigest,
|
||||||
|
preparedHeadDigest,
|
||||||
|
reviewDigest: intent.reviewDigest,
|
||||||
|
authorizationDigest: intent.authorizationDigest,
|
||||||
|
decisionSetDigest: intent.decisionSetDigest,
|
||||||
|
decisionCount: intent.decisionCount,
|
||||||
|
committedAtMs,
|
||||||
|
domains,
|
||||||
|
outcome: outcome(domains),
|
||||||
|
});
|
||||||
|
const plan = Object.freeze({
|
||||||
|
...payload,
|
||||||
|
applicationPlanDigest: cutoverDigest(payload),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
Buffer.byteLength(`${JSON.stringify(plan, null, 2)}\n`, 'utf8') >
|
||||||
|
MAX_PLAN_BYTES
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation application plan exceeds 64 KiB');
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDatabaseSummary(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): Readonly<LocalReconciliationApplicationDatabaseDecisionSummary> {
|
||||||
|
const selected = object(value, label);
|
||||||
|
exact(selected, ['decisionCount', 'dispositionCounts'], label);
|
||||||
|
const dispositionCounts = counts(
|
||||||
|
selected.dispositionCounts,
|
||||||
|
`${label} disposition counts`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!safeCount(selected.decisionCount) ||
|
||||||
|
selected.decisionCount !== sumCounts(dispositionCounts)
|
||||||
|
) {
|
||||||
|
configurationError(`${label} aggregate drifted`);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
decisionCount: selected.decisionCount,
|
||||||
|
dispositionCounts,
|
||||||
|
}) as Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomain(
|
||||||
|
value: unknown,
|
||||||
|
expectedDomain: LocalReconciliationPlanDomain,
|
||||||
|
): Readonly<LocalReconciliationApplicationDomainSummary> {
|
||||||
|
const selected = object(value, 'reconciliation application domain');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
['action', 'domain', 'legacy', 'summaryDigest', 'target'],
|
||||||
|
'reconciliation application domain',
|
||||||
|
);
|
||||||
|
const legacy = normalizeDatabaseSummary(selected.legacy, 'legacy summary');
|
||||||
|
const target = normalizeDatabaseSummary(selected.target, 'target summary');
|
||||||
|
const summaryPayload = Object.freeze({
|
||||||
|
domain: selected.domain,
|
||||||
|
legacy,
|
||||||
|
target,
|
||||||
|
action: selected.action,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
selected.domain !== expectedDomain ||
|
||||||
|
![
|
||||||
|
'no_effect',
|
||||||
|
'adapter_required',
|
||||||
|
'manual_external',
|
||||||
|
'adapter_and_manual',
|
||||||
|
].includes(selected.action as string) ||
|
||||||
|
typeof selected.summaryDigest !== 'string' ||
|
||||||
|
!DIGEST_PATTERN.test(selected.summaryDigest) ||
|
||||||
|
selected.summaryDigest !== cutoverDigest(summaryPayload)
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation application domain drifted');
|
||||||
|
}
|
||||||
|
const derivedAction = action({
|
||||||
|
domain: expectedDomain,
|
||||||
|
legacy: legacy.dispositionCounts,
|
||||||
|
target: target.dispositionCounts,
|
||||||
|
});
|
||||||
|
if (selected.action !== derivedAction) {
|
||||||
|
configurationError('reconciliation application domain action drifted');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
domain: expectedDomain,
|
||||||
|
legacy,
|
||||||
|
target,
|
||||||
|
action: selected.action,
|
||||||
|
summaryDigest: selected.summaryDigest,
|
||||||
|
}) as Readonly<LocalReconciliationApplicationDomainSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationApplicationPlan(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationApplicationPlan> {
|
||||||
|
const plan = object(value, 'reconciliation application plan');
|
||||||
|
exact(
|
||||||
|
plan,
|
||||||
|
[
|
||||||
|
'applicationId',
|
||||||
|
'applicationPlanDigest',
|
||||||
|
'authorizationDigest',
|
||||||
|
'committedAtMs',
|
||||||
|
'decisionCount',
|
||||||
|
'decisionSetDigest',
|
||||||
|
'domains',
|
||||||
|
'outcome',
|
||||||
|
'preparationDigest',
|
||||||
|
'preparedHeadDigest',
|
||||||
|
'profile',
|
||||||
|
'reviewDigest',
|
||||||
|
'reviewId',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'state',
|
||||||
|
],
|
||||||
|
'reconciliation application plan',
|
||||||
|
);
|
||||||
|
if (!Array.isArray(plan.domains) || plan.domains.length !== 8) {
|
||||||
|
configurationError('reconciliation application domains are invalid');
|
||||||
|
}
|
||||||
|
const domains = Object.freeze(
|
||||||
|
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) =>
|
||||||
|
normalizeDomain((plan.domains as unknown[])[index], domain),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { applicationPlanDigest, ...rawPayload } = plan;
|
||||||
|
const payload = Object.freeze({ ...rawPayload, domains });
|
||||||
|
const domainDecisionCount = domains.reduce(
|
||||||
|
(total, domain) =>
|
||||||
|
total + domain.legacy.decisionCount + domain.target.decisionCount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
plan.schema !== PLAN_SCHEMA ||
|
||||||
|
plan.schemaVersion !== 1 ||
|
||||||
|
plan.state !== 'reconciliation_application_planned' ||
|
||||||
|
typeof plan.applicationId !== 'string' ||
|
||||||
|
!UUID_V4_PATTERN.test(plan.applicationId) ||
|
||||||
|
typeof plan.reviewId !== 'string' ||
|
||||||
|
!UUID_V4_PATTERN.test(plan.reviewId) ||
|
||||||
|
(plan.profile !== 'edge' && plan.profile !== 'standalone') ||
|
||||||
|
!safeCount(plan.decisionCount) ||
|
||||||
|
plan.decisionCount !== domainDecisionCount ||
|
||||||
|
!Number.isSafeInteger(plan.committedAtMs) ||
|
||||||
|
(plan.committedAtMs as number) < 0 ||
|
||||||
|
![
|
||||||
|
'no_effect_ready',
|
||||||
|
'adapter_required',
|
||||||
|
'manual_required',
|
||||||
|
'adapter_and_manual_required',
|
||||||
|
].includes(plan.outcome as string) ||
|
||||||
|
plan.outcome !== outcome(domains) ||
|
||||||
|
[
|
||||||
|
plan.preparationDigest,
|
||||||
|
plan.preparedHeadDigest,
|
||||||
|
plan.reviewDigest,
|
||||||
|
plan.authorizationDigest,
|
||||||
|
plan.decisionSetDigest,
|
||||||
|
applicationPlanDigest,
|
||||||
|
].some(
|
||||||
|
(candidate) =>
|
||||||
|
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
|
||||||
|
) ||
|
||||||
|
cutoverDigest(payload) !== applicationPlanDigest
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation application plan drifted');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...(plan as unknown as LocalReconciliationApplicationPlan),
|
||||||
|
domains,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationApplicationPlanReceipt(
|
||||||
|
plan: Readonly<LocalReconciliationApplicationPlan>,
|
||||||
|
): Readonly<LocalReconciliationApplicationPlanReceipt> {
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: RECEIPT_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
state: 'reconciliation_application_planned' as const,
|
||||||
|
applicationId: plan.applicationId,
|
||||||
|
reviewId: plan.reviewId,
|
||||||
|
preparationDigest: plan.preparationDigest,
|
||||||
|
preparedHeadDigest: plan.preparedHeadDigest,
|
||||||
|
reviewDigest: plan.reviewDigest,
|
||||||
|
authorizationDigest: plan.authorizationDigest,
|
||||||
|
decisionSetDigest: plan.decisionSetDigest,
|
||||||
|
decisionCount: plan.decisionCount,
|
||||||
|
applicationPlanDigest: plan.applicationPlanDigest,
|
||||||
|
outcome: plan.outcome,
|
||||||
|
domainCount: 8 as const,
|
||||||
|
committedAtMs: plan.committedAtMs,
|
||||||
|
});
|
||||||
|
return Object.freeze({ ...payload, receiptDigest: cutoverDigest(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationApplicationPlanReceipt(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationApplicationPlanReceipt> {
|
||||||
|
const receipt = object(value, 'reconciliation application receipt');
|
||||||
|
exact(
|
||||||
|
receipt,
|
||||||
|
[
|
||||||
|
'applicationId',
|
||||||
|
'applicationPlanDigest',
|
||||||
|
'authorizationDigest',
|
||||||
|
'committedAtMs',
|
||||||
|
'decisionCount',
|
||||||
|
'decisionSetDigest',
|
||||||
|
'domainCount',
|
||||||
|
'outcome',
|
||||||
|
'preparationDigest',
|
||||||
|
'preparedHeadDigest',
|
||||||
|
'receiptDigest',
|
||||||
|
'reviewDigest',
|
||||||
|
'reviewId',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'state',
|
||||||
|
],
|
||||||
|
'reconciliation application receipt',
|
||||||
|
);
|
||||||
|
const { receiptDigest, ...payload } = receipt;
|
||||||
|
if (
|
||||||
|
receipt.schema !== RECEIPT_SCHEMA ||
|
||||||
|
receipt.schemaVersion !== 1 ||
|
||||||
|
receipt.state !== 'reconciliation_application_planned' ||
|
||||||
|
typeof receipt.applicationId !== 'string' ||
|
||||||
|
!UUID_V4_PATTERN.test(receipt.applicationId) ||
|
||||||
|
typeof receipt.reviewId !== 'string' ||
|
||||||
|
!UUID_V4_PATTERN.test(receipt.reviewId) ||
|
||||||
|
!safeCount(receipt.decisionCount) ||
|
||||||
|
receipt.domainCount !== 8 ||
|
||||||
|
!Number.isSafeInteger(receipt.committedAtMs) ||
|
||||||
|
(receipt.committedAtMs as number) < 0 ||
|
||||||
|
![
|
||||||
|
'no_effect_ready',
|
||||||
|
'adapter_required',
|
||||||
|
'manual_required',
|
||||||
|
'adapter_and_manual_required',
|
||||||
|
].includes(receipt.outcome as string) ||
|
||||||
|
[
|
||||||
|
receipt.preparationDigest,
|
||||||
|
receipt.preparedHeadDigest,
|
||||||
|
receipt.reviewDigest,
|
||||||
|
receipt.authorizationDigest,
|
||||||
|
receipt.decisionSetDigest,
|
||||||
|
receipt.applicationPlanDigest,
|
||||||
|
receiptDigest,
|
||||||
|
].some(
|
||||||
|
(candidate) =>
|
||||||
|
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
|
||||||
|
) ||
|
||||||
|
cutoverDigest(payload) !== receiptDigest
|
||||||
|
) {
|
||||||
|
configurationError('reconciliation application receipt drifted');
|
||||||
|
}
|
||||||
|
return receipt as unknown as Readonly<LocalReconciliationApplicationPlanReceipt>;
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
} from '@qinglong/runtime-core/security';
|
} from '@qinglong/runtime-core/security';
|
||||||
|
|
||||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||||
|
import {
|
||||||
|
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||||
|
type LocalReconciliationPlanDomain,
|
||||||
|
} from '../planning/contract';
|
||||||
import type {
|
import type {
|
||||||
LocalReconciliationReviewDecision,
|
LocalReconciliationReviewDecision,
|
||||||
LocalReconciliationReviewDisposition,
|
LocalReconciliationReviewDisposition,
|
||||||
@@ -54,9 +58,20 @@ export interface LocalReconciliationReviewAuthorizationEvidence {
|
|||||||
readonly reasonCounts: Readonly<
|
readonly reasonCounts: Readonly<
|
||||||
Record<LocalReconciliationReviewReason, number>
|
Record<LocalReconciliationReviewReason, number>
|
||||||
>;
|
>;
|
||||||
|
readonly domainDecisionCounts: readonly Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>[];
|
||||||
readonly header: Readonly<LocalReconciliationReviewAuthorizationHeader>;
|
readonly header: Readonly<LocalReconciliationReviewAuthorizationHeader>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationReviewAuthorizationDomainDecisionCounts {
|
||||||
|
readonly domain: LocalReconciliationPlanDomain;
|
||||||
|
readonly legacy: Readonly<
|
||||||
|
Record<LocalReconciliationReviewDisposition, number>
|
||||||
|
>;
|
||||||
|
readonly target: Readonly<
|
||||||
|
Record<LocalReconciliationReviewDisposition, number>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
interface SignatureRecord {
|
interface SignatureRecord {
|
||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1;
|
||||||
readonly kind: typeof SIGNATURE_KIND;
|
readonly kind: typeof SIGNATURE_KIND;
|
||||||
@@ -97,6 +112,32 @@ function zeroCounts<T extends string>(keys: readonly T[]): Record<T, number> {
|
|||||||
return Object.fromEntries(keys.map((key) => [key, 0])) as Record<T, number>;
|
return Object.fromEntries(keys.map((key) => [key, 0])) as Record<T, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function zeroDomainDecisionCounts(): Array<{
|
||||||
|
domain: LocalReconciliationPlanDomain;
|
||||||
|
legacy: Record<LocalReconciliationReviewDisposition, number>;
|
||||||
|
target: Record<LocalReconciliationReviewDisposition, number>;
|
||||||
|
}> {
|
||||||
|
return LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain) => ({
|
||||||
|
domain,
|
||||||
|
legacy: zeroCounts(DISPOSITIONS),
|
||||||
|
target: zeroCounts(DISPOSITIONS),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeDomainDecisionCounts(
|
||||||
|
counts: ReturnType<typeof zeroDomainDecisionCounts>,
|
||||||
|
): readonly Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>[] {
|
||||||
|
return Object.freeze(
|
||||||
|
counts.map((selected) =>
|
||||||
|
Object.freeze({
|
||||||
|
domain: selected.domain,
|
||||||
|
legacy: Object.freeze({ ...selected.legacy }),
|
||||||
|
target: Object.freeze({ ...selected.target }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function configurationError(message: string, cause?: unknown): never {
|
function configurationError(message: string, cause?: unknown): never {
|
||||||
throw new LocalDeploymentConfigurationError(
|
throw new LocalDeploymentConfigurationError(
|
||||||
`reconciliation review authorization ${message}`,
|
`reconciliation review authorization ${message}`,
|
||||||
@@ -429,6 +470,7 @@ export async function publishLocalReconciliationReviewAuthorization(
|
|||||||
let decisionCount = 0;
|
let decisionCount = 0;
|
||||||
const dispositionCounts = zeroCounts(DISPOSITIONS);
|
const dispositionCounts = zeroCounts(DISPOSITIONS);
|
||||||
const reasonCounts = zeroCounts(REASONS);
|
const reasonCounts = zeroCounts(REASONS);
|
||||||
|
const domainDecisionCounts = zeroDomainDecisionCounts();
|
||||||
const writeContent = (value: unknown, isDecision: boolean): void => {
|
const writeContent = (value: unknown, isDecision: boolean): void => {
|
||||||
const line = canonicalLine(value);
|
const line = canonicalLine(value);
|
||||||
try {
|
try {
|
||||||
@@ -452,6 +494,11 @@ export async function publishLocalReconciliationReviewAuthorization(
|
|||||||
decisionCount += 1;
|
decisionCount += 1;
|
||||||
dispositionCounts[selected.disposition] += 1;
|
dispositionCounts[selected.disposition] += 1;
|
||||||
reasonCounts[selected.reason] += 1;
|
reasonCounts[selected.reason] += 1;
|
||||||
|
const domain = domainDecisionCounts.find(
|
||||||
|
(candidate) => candidate.domain === selected.domain,
|
||||||
|
);
|
||||||
|
if (!domain) configurationError('decision domain is unavailable');
|
||||||
|
domain[selected.database][selected.disposition] += 1;
|
||||||
});
|
});
|
||||||
if (!DIGEST_PATTERN.test(decisionFile.decisionFileDigest)) {
|
if (!DIGEST_PATTERN.test(decisionFile.decisionFileDigest)) {
|
||||||
configurationError('decision file evidence is invalid');
|
configurationError('decision file evidence is invalid');
|
||||||
@@ -532,6 +579,7 @@ export async function publishLocalReconciliationReviewAuthorization(
|
|||||||
keyId: material.keyId,
|
keyId: material.keyId,
|
||||||
dispositionCounts: Object.freeze(dispositionCounts),
|
dispositionCounts: Object.freeze(dispositionCounts),
|
||||||
reasonCounts: Object.freeze(reasonCounts),
|
reasonCounts: Object.freeze(reasonCounts),
|
||||||
|
domainDecisionCounts: freezeDomainDecisionCounts(domainDecisionCounts),
|
||||||
header: options.header,
|
header: options.header,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -668,6 +716,7 @@ export async function verifyLocalReconciliationReviewAuthorization(
|
|||||||
}
|
}
|
||||||
const dispositionCounts = zeroCounts(DISPOSITIONS);
|
const dispositionCounts = zeroCounts(DISPOSITIONS);
|
||||||
const reasonCounts = zeroCounts(REASONS);
|
const reasonCounts = zeroCounts(REASONS);
|
||||||
|
const domainDecisionCounts = zeroDomainDecisionCounts();
|
||||||
let decisionCount = 0;
|
let decisionCount = 0;
|
||||||
let pending = nextLine();
|
let pending = nextLine();
|
||||||
if (pending === null) configurationError('signature is absent');
|
if (pending === null) configurationError('signature is absent');
|
||||||
@@ -679,6 +728,11 @@ export async function verifyLocalReconciliationReviewAuthorization(
|
|||||||
decisionCount += 1;
|
decisionCount += 1;
|
||||||
dispositionCounts[selected.disposition] += 1;
|
dispositionCounts[selected.disposition] += 1;
|
||||||
reasonCounts[selected.reason] += 1;
|
reasonCounts[selected.reason] += 1;
|
||||||
|
const domain = domainDecisionCounts.find(
|
||||||
|
(candidate) => candidate.domain === selected.domain,
|
||||||
|
);
|
||||||
|
if (!domain) configurationError('decision domain is unavailable');
|
||||||
|
domain[selected.database][selected.disposition] += 1;
|
||||||
pending.fill(0);
|
pending.fill(0);
|
||||||
pending = following;
|
pending = following;
|
||||||
}
|
}
|
||||||
@@ -740,6 +794,7 @@ export async function verifyLocalReconciliationReviewAuthorization(
|
|||||||
keyId: signature.keyId,
|
keyId: signature.keyId,
|
||||||
dispositionCounts: Object.freeze(dispositionCounts),
|
dispositionCounts: Object.freeze(dispositionCounts),
|
||||||
reasonCounts: Object.freeze(reasonCounts),
|
reasonCounts: Object.freeze(reasonCounts),
|
||||||
|
domainDecisionCounts: freezeDomainDecisionCounts(domainDecisionCounts),
|
||||||
header: normalizedHeader,
|
header: normalizedHeader,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -504,6 +504,50 @@ async function readTerminal(
|
|||||||
return Object.freeze({ authorization, review, receipt });
|
return Object.freeze({ authorization, review, receipt });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationReviewTerminal {
|
||||||
|
readonly intent: Readonly<LocalReconciliationReviewIntent>;
|
||||||
|
readonly authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>;
|
||||||
|
readonly review: Readonly<LocalReconciliationReview>;
|
||||||
|
readonly receipt: Readonly<LocalReconciliationReviewReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readLocalReconciliationReviewTerminal(
|
||||||
|
reviewRoot: string,
|
||||||
|
reviewId: string,
|
||||||
|
issuerKeyringPath: string,
|
||||||
|
uid: number,
|
||||||
|
): Promise<Readonly<LocalReconciliationReviewTerminal>> {
|
||||||
|
const selected = paths(reviewRoot, reviewId);
|
||||||
|
validateDirectory(
|
||||||
|
selected.root,
|
||||||
|
uid,
|
||||||
|
[0o500],
|
||||||
|
'reconciliationReviewDirectory',
|
||||||
|
);
|
||||||
|
validateDirectory(
|
||||||
|
selected.staging,
|
||||||
|
uid,
|
||||||
|
[0o500],
|
||||||
|
'reconciliationReviewStaging',
|
||||||
|
);
|
||||||
|
validateCatalog(selected, true);
|
||||||
|
const intent = readIntent(selected, uid);
|
||||||
|
if (
|
||||||
|
intent.command.options.reviewRoot !== reviewRoot ||
|
||||||
|
intent.command.request.reviewId !== reviewId
|
||||||
|
) {
|
||||||
|
configurationError('terminal reconciliation review path binding drifted');
|
||||||
|
}
|
||||||
|
const terminal = await readTerminal(
|
||||||
|
selected,
|
||||||
|
intent,
|
||||||
|
issuerKeyringPath,
|
||||||
|
uid,
|
||||||
|
[0o400],
|
||||||
|
);
|
||||||
|
return Object.freeze({ intent, ...terminal });
|
||||||
|
}
|
||||||
|
|
||||||
function result(
|
function result(
|
||||||
operation: LocalReconciliationReviewTerminalResult['operation'],
|
operation: LocalReconciliationReviewTerminalResult['operation'],
|
||||||
status: LocalReconciliationReviewTerminalResult['status'],
|
status: LocalReconciliationReviewTerminalResult['status'],
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ export function assertLocalReconciliationReviewDecisionMatchesFact(
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
fact.decisionRequirement === 'informational' ||
|
fact.decisionRequirement === 'informational' ||
|
||||||
|
(fact.database === 'legacy' && selected.disposition === 'retain_target') ||
|
||||||
|
(fact.database === 'target' &&
|
||||||
|
(selected.disposition === 'adopt_legacy' ||
|
||||||
|
selected.disposition === 'exclude_legacy')) ||
|
||||||
(fact.decisionRequirement === 'blocked' &&
|
(fact.decisionRequirement === 'blocked' &&
|
||||||
selected.disposition !== 'defer' &&
|
selected.disposition !== 'defer' &&
|
||||||
selected.disposition !== 'manual_external') ||
|
selected.disposition !== 'manual_external') ||
|
||||||
|
|||||||
+3
-1
@@ -753,7 +753,9 @@ function replayResult(
|
|||||||
head.state === 'reconciliation_plan_prepared' ||
|
head.state === 'reconciliation_plan_prepared' ||
|
||||||
head.state === 'reconciliation_planned' ||
|
head.state === 'reconciliation_planned' ||
|
||||||
head.state === 'reconciliation_review_prepared' ||
|
head.state === 'reconciliation_review_prepared' ||
|
||||||
head.state === 'reconciliation_reviewed');
|
head.state === 'reconciliation_reviewed' ||
|
||||||
|
head.state === 'reconciliation_application_prepared' ||
|
||||||
|
head.state === 'reconciliation_application_planned');
|
||||||
if (
|
if (
|
||||||
record.actionId !== intent.actionId ||
|
record.actionId !== intent.actionId ||
|
||||||
record.intentDigest !== intent.intentDigest ||
|
record.intentDigest !== intent.intentDigest ||
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ const { test } = require('node:test');
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
commitLocalReconciliationCapture,
|
commitLocalReconciliationCapture,
|
||||||
|
commitLocalReconciliationApplication,
|
||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
commitLocalReconciliationReview,
|
commitLocalReconciliationReview,
|
||||||
prepareLocalReconciliationCapture,
|
prepareLocalReconciliationCapture,
|
||||||
|
prepareLocalReconciliationApplication,
|
||||||
prepareLocalReconciliationPlan,
|
prepareLocalReconciliationPlan,
|
||||||
prepareLocalReconciliationReview,
|
prepareLocalReconciliationReview,
|
||||||
verifyLocalReconciliationCapture,
|
verifyLocalReconciliationCapture,
|
||||||
|
verifyLocalReconciliationApplication,
|
||||||
verifyLocalReconciliationPlan,
|
verifyLocalReconciliationPlan,
|
||||||
verifyLocalReconciliationReview,
|
verifyLocalReconciliationReview,
|
||||||
writeLocalReconciliationReviewDiagnostics,
|
writeLocalReconciliationReviewDiagnostics,
|
||||||
@@ -799,6 +802,7 @@ function writeReviewDecisionFile(state, prepared, fileName = 'review.ndjson') {
|
|||||||
for (const fact of page.records) {
|
for (const fact of page.records) {
|
||||||
if (fact.decisionRequirement === 'informational') continue;
|
if (fact.decisionRequirement === 'informational') continue;
|
||||||
const blocked = fact.decisionRequirement === 'blocked';
|
const blocked = fact.decisionRequirement === 'blocked';
|
||||||
|
const legacy = fact.database === 'legacy';
|
||||||
decisions.push({
|
decisions.push({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
kind: 'qinglong3-local-reconciliation-review-decision',
|
kind: 'qinglong3-local-reconciliation-review-decision',
|
||||||
@@ -807,9 +811,15 @@ function writeReviewDecisionFile(state, prepared, fileName = 'review.ndjson') {
|
|||||||
factKind: fact.factKind,
|
factKind: fact.factKind,
|
||||||
ordinal: fact.ordinal,
|
ordinal: fact.ordinal,
|
||||||
factDigest: fact.factDigest,
|
factDigest: fact.factDigest,
|
||||||
disposition: blocked ? 'manual_external' : 'retain_target',
|
disposition: blocked
|
||||||
|
? 'manual_external'
|
||||||
|
: legacy
|
||||||
|
? 'exclude_legacy'
|
||||||
|
: 'retain_target',
|
||||||
reason: blocked
|
reason: blocked
|
||||||
? 'external_recovery_required'
|
? 'external_recovery_required'
|
||||||
|
: legacy
|
||||||
|
? 'legacy_excluded'
|
||||||
: 'preserve_target',
|
: 'preserve_target',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -924,6 +934,67 @@ function reviewCommitFixture(t, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reviewedApplicationFixture(t, options = {}) {
|
||||||
|
const state = reviewCommitFixture(t, options);
|
||||||
|
options.mutateDecisions?.(state.reviewFile.records);
|
||||||
|
if (options.mutateDecisions) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
state.reviewFile.filePath,
|
||||||
|
`${state.reviewFile.records
|
||||||
|
.map((record) => JSON.stringify(record))
|
||||||
|
.join('\n')}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const reviewed = await commitLocalReconciliationReview(
|
||||||
|
state.command,
|
||||||
|
state.dependencies,
|
||||||
|
);
|
||||||
|
const applicationRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
`application-root-${options.reviewSuffix ?? '1'}`,
|
||||||
|
);
|
||||||
|
fs.mkdirSync(applicationRoot, { mode: 0o700 });
|
||||||
|
const applicationId =
|
||||||
|
options.applicationId ?? '00000000-0000-4000-8000-000000000401';
|
||||||
|
const prepareCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.application.prepare',
|
||||||
|
options: {
|
||||||
|
...state.reviewCommand.options,
|
||||||
|
applicationRoot,
|
||||||
|
issuerKeyringPath: state.issuerKeyringPath,
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
applicationId,
|
||||||
|
reviewId: state.reviewCommand.request.reviewId,
|
||||||
|
expectedReviewDigest: reviewed.reviewDigest,
|
||||||
|
expectedHeadDigest: reviewed.instanceHeadDigest,
|
||||||
|
preparedAtMs: state.command.request.committedAtMs + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
reviewed,
|
||||||
|
applicationRoot,
|
||||||
|
prepareApplicationCommand: prepareCommand,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applicationCommitCommand(state, prepared) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.application.commit',
|
||||||
|
options: state.prepareApplicationCommand.options,
|
||||||
|
request: {
|
||||||
|
applicationId: state.prepareApplicationCommand.request.applicationId,
|
||||||
|
expectedPreparationDigest: prepared.preparationDigest,
|
||||||
|
expectedHeadDigest: prepared.instanceHeadDigest,
|
||||||
|
committedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
||||||
const source =
|
const source =
|
||||||
mode === 'main_only_immutable'
|
mode === 'main_only_immutable'
|
||||||
@@ -2157,6 +2228,35 @@ test('review commit rejects missing and policy-invalid decisions before terminal
|
|||||||
commitLocalReconciliationReview(blocked.command, blocked.dependencies),
|
commitLocalReconciliationReview(blocked.command, blocked.dependencies),
|
||||||
/not allowed for canonical fact/,
|
/not allowed for canonical fact/,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const crossDatabase = reviewCommitFixture(t, {
|
||||||
|
planId: '00000000-0000-4000-8000-000000000390',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000391',
|
||||||
|
reviewSuffix: 'cross-database',
|
||||||
|
});
|
||||||
|
const legacyDecision = crossDatabase.reviewFile.records.find(
|
||||||
|
(record) =>
|
||||||
|
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
|
||||||
|
record.database === 'legacy' &&
|
||||||
|
record.disposition === 'exclude_legacy',
|
||||||
|
);
|
||||||
|
assert.ok(legacyDecision);
|
||||||
|
legacyDecision.disposition = 'retain_target';
|
||||||
|
legacyDecision.reason = 'preserve_target';
|
||||||
|
fs.writeFileSync(
|
||||||
|
crossDatabase.reviewFile.filePath,
|
||||||
|
`${crossDatabase.reviewFile.records
|
||||||
|
.map((record) => JSON.stringify(record))
|
||||||
|
.join('\n')}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
commitLocalReconciliationReview(
|
||||||
|
crossDatabase.command,
|
||||||
|
crossDatabase.dependencies,
|
||||||
|
),
|
||||||
|
/not allowed for canonical fact/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('review commit rejects weak principals, oversized Edge streams and decision-file drift around signing', async (t) => {
|
test('review commit rejects weak principals, oversized Edge streams and decision-file drift around signing', async (t) => {
|
||||||
@@ -2329,6 +2429,262 @@ test('review commit resumes authorization, receipt, seal and head response-loss
|
|||||||
assert.equal(headReplay.status, 'existing');
|
assert.equal(headReplay.status, 'existing');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('application coordinator plans eight content-free domains and verifies without SQLite writes', async (t) => {
|
||||||
|
const state = await reviewedApplicationFixture(t, {
|
||||||
|
planId: '00000000-0000-4000-8000-000000000401',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000402',
|
||||||
|
applicationId: '00000000-0000-4000-8000-000000000403',
|
||||||
|
reviewSuffix: 'application-terminal',
|
||||||
|
mutateDecisions(records) {
|
||||||
|
const selected = records.find(
|
||||||
|
(record) =>
|
||||||
|
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
|
||||||
|
record.database === 'legacy' &&
|
||||||
|
record.domain === 'automation' &&
|
||||||
|
record.disposition === 'exclude_legacy',
|
||||||
|
);
|
||||||
|
assert.ok(selected);
|
||||||
|
selected.disposition = 'adopt_legacy';
|
||||||
|
selected.reason = 'prefer_legacy';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const targetBefore = fs.statSync(state.targetDatabasePath, { bigint: true });
|
||||||
|
const targetBytes = fs.readFileSync(state.targetDatabasePath);
|
||||||
|
const prepared = await prepareLocalReconciliationApplication(
|
||||||
|
state.prepareApplicationCommand,
|
||||||
|
);
|
||||||
|
assert.equal(prepared.status, 'prepared');
|
||||||
|
assert.equal(prepared.state, 'reconciliation_application_prepared');
|
||||||
|
const commitCommand = applicationCommitCommand(state, prepared);
|
||||||
|
const committed = await commitLocalReconciliationApplication(commitCommand);
|
||||||
|
assert.equal(committed.status, 'prepared');
|
||||||
|
assert.equal(committed.state, 'reconciliation_application_planned');
|
||||||
|
assert.equal(committed.domainCount, 8);
|
||||||
|
assert.equal(committed.outcome, 'adapter_and_manual_required');
|
||||||
|
const applicationDirectory = path.join(
|
||||||
|
state.applicationRoot,
|
||||||
|
state.prepareApplicationCommand.request.applicationId,
|
||||||
|
);
|
||||||
|
assert.deepEqual(fs.readdirSync(applicationDirectory).sort(), [
|
||||||
|
'intent.json',
|
||||||
|
'plan.json',
|
||||||
|
'receipt.json',
|
||||||
|
'staging',
|
||||||
|
]);
|
||||||
|
assert.equal(fs.statSync(applicationDirectory).mode & 0o777, 0o500);
|
||||||
|
assert.equal(
|
||||||
|
fs.statSync(path.join(applicationDirectory, 'staging')).mode & 0o777,
|
||||||
|
0o500,
|
||||||
|
);
|
||||||
|
for (const fileName of ['intent.json', 'plan.json', 'receipt.json']) {
|
||||||
|
assert.equal(
|
||||||
|
fs.statSync(path.join(applicationDirectory, fileName)).mode & 0o777,
|
||||||
|
0o400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const planText = fs.readFileSync(
|
||||||
|
path.join(applicationDirectory, 'plan.json'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const plan = JSON.parse(planText);
|
||||||
|
assert.equal(plan.domains.length, 8);
|
||||||
|
assert.equal(
|
||||||
|
plan.domains.find((domain) => domain.domain === 'automation').action,
|
||||||
|
'adapter_required',
|
||||||
|
);
|
||||||
|
assert.equal(planText.includes('QingLong3TaskDefinitions'), false);
|
||||||
|
assert.equal(planText.includes('Crontabs'), false);
|
||||||
|
assert.equal(planText.includes('private-value'), false);
|
||||||
|
assert.equal(planText.includes('0 0 * * *'), false);
|
||||||
|
const verifyCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.application.verify',
|
||||||
|
options: state.prepareApplicationCommand.options,
|
||||||
|
request: {
|
||||||
|
applicationId: state.prepareApplicationCommand.request.applicationId,
|
||||||
|
expectedApplicationPlanDigest: committed.applicationPlanDigest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const verified = await verifyLocalReconciliationApplication(verifyCommand);
|
||||||
|
assert.equal(verified.status, 'verified');
|
||||||
|
assert.equal(verified.applicationPlanDigest, committed.applicationPlanDigest);
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
state.deploymentRoot,
|
||||||
|
state.captureCommand.request.instanceId,
|
||||||
|
state.uid,
|
||||||
|
);
|
||||||
|
assert.equal(head.state, 'reconciliation_application_planned');
|
||||||
|
assert.equal(head.sourceRecordDigest, committed.applicationPlanDigest);
|
||||||
|
const targetAfter = fs.statSync(state.targetDatabasePath, { bigint: true });
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(state.targetDatabasePath).equals(targetBytes),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(targetAfter.mtimeNs, targetBefore.mtimeNs);
|
||||||
|
assert.equal(targetAfter.ctimeNs, targetBefore.ctimeNs);
|
||||||
|
|
||||||
|
const commandPath = path.join(
|
||||||
|
state.deploymentRoot,
|
||||||
|
'application-verify.json',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
const cli = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||||
|
'reconciliation-application-verify',
|
||||||
|
'--command-file',
|
||||||
|
commandPath,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(cli.status, 0, cli.stderr);
|
||||||
|
assert.equal(JSON.parse(cli.stdout).status, 'verified');
|
||||||
|
assert.equal(cli.stdout.includes(state.applicationRoot), false);
|
||||||
|
assert.equal(cli.stdout.includes('review-owner'), false);
|
||||||
|
assert.equal(cli.stdout.includes('Crontabs'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('application coordinator resumes every publication window and fences competitors', async (t) => {
|
||||||
|
const prepareCrash = await reviewedApplicationFixture(t, {
|
||||||
|
planId: '00000000-0000-4000-8000-000000000410',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000411',
|
||||||
|
applicationId: '00000000-0000-4000-8000-000000000412',
|
||||||
|
reviewSuffix: 'application-prepare-crash',
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
prepareLocalReconciliationApplication(
|
||||||
|
prepareCrash.prepareApplicationCommand,
|
||||||
|
{
|
||||||
|
afterHeadPrepared() {
|
||||||
|
throw new Error('application prepare head response loss');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/application prepare head response loss/,
|
||||||
|
);
|
||||||
|
const preparedReplay = await prepareLocalReconciliationApplication(
|
||||||
|
prepareCrash.prepareApplicationCommand,
|
||||||
|
);
|
||||||
|
assert.equal(preparedReplay.state, 'reconciliation_application_prepared');
|
||||||
|
await assert.rejects(
|
||||||
|
prepareLocalReconciliationApplication({
|
||||||
|
...prepareCrash.prepareApplicationCommand,
|
||||||
|
request: {
|
||||||
|
...prepareCrash.prepareApplicationCommand.request,
|
||||||
|
applicationId: '00000000-0000-4000-8000-00000000041f',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/lost reviewed head compare-and-swap/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const windows = [
|
||||||
|
['plan', '000000000413', '000000000414', '000000000415'],
|
||||||
|
['receipt', '000000000416', '000000000417', '000000000418'],
|
||||||
|
['seal', '000000000419', '00000000041a', '00000000041b'],
|
||||||
|
['head', '00000000041c', '00000000041d', '00000000041e'],
|
||||||
|
];
|
||||||
|
for (const [window, planTail, reviewTail, applicationTail] of windows) {
|
||||||
|
const state = await reviewedApplicationFixture(t, {
|
||||||
|
planId: `00000000-0000-4000-8000-${planTail}`,
|
||||||
|
reviewId: `00000000-0000-4000-8000-${reviewTail}`,
|
||||||
|
applicationId: `00000000-0000-4000-8000-${applicationTail}`,
|
||||||
|
reviewSuffix: `application-${window}-crash`,
|
||||||
|
});
|
||||||
|
const prepared = await prepareLocalReconciliationApplication(
|
||||||
|
state.prepareApplicationCommand,
|
||||||
|
);
|
||||||
|
const commitCommand = applicationCommitCommand(state, prepared);
|
||||||
|
const callback =
|
||||||
|
window === 'plan'
|
||||||
|
? 'afterPlanPublished'
|
||||||
|
: window === 'receipt'
|
||||||
|
? 'afterReceiptPublished'
|
||||||
|
: window === 'seal'
|
||||||
|
? 'afterTerminalSealed'
|
||||||
|
: 'afterHeadAdvanced';
|
||||||
|
await assert.rejects(
|
||||||
|
commitLocalReconciliationApplication(commitCommand, {
|
||||||
|
[callback]() {
|
||||||
|
throw new Error(`application ${window} response loss`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`application ${window} response loss`),
|
||||||
|
);
|
||||||
|
const replay = await commitLocalReconciliationApplication(commitCommand);
|
||||||
|
assert.equal(replay.state, 'reconciliation_application_planned');
|
||||||
|
if (window === 'head') assert.equal(replay.status, 'existing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('application verify rejects digest and head drift without repairing state', async (t) => {
|
||||||
|
const state = await reviewedApplicationFixture(t, {
|
||||||
|
planId: '00000000-0000-4000-8000-000000000421',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000422',
|
||||||
|
applicationId: '00000000-0000-4000-8000-000000000423',
|
||||||
|
reviewSuffix: 'application-verify-drift',
|
||||||
|
});
|
||||||
|
const prepared = await prepareLocalReconciliationApplication(
|
||||||
|
state.prepareApplicationCommand,
|
||||||
|
);
|
||||||
|
const committed = await commitLocalReconciliationApplication(
|
||||||
|
applicationCommitCommand(state, prepared),
|
||||||
|
);
|
||||||
|
const verifyCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.application.verify',
|
||||||
|
options: state.prepareApplicationCommand.options,
|
||||||
|
request: {
|
||||||
|
applicationId: state.prepareApplicationCommand.request.applicationId,
|
||||||
|
expectedApplicationPlanDigest: 'f'.repeat(64),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const applicationDirectory = path.join(
|
||||||
|
state.applicationRoot,
|
||||||
|
state.prepareApplicationCommand.request.applicationId,
|
||||||
|
);
|
||||||
|
const before = fs.statSync(path.join(applicationDirectory, 'plan.json'), {
|
||||||
|
bigint: true,
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
verifyLocalReconciliationApplication(verifyCommand),
|
||||||
|
/expected digest drifted/,
|
||||||
|
);
|
||||||
|
const after = fs.statSync(path.join(applicationDirectory, 'plan.json'), {
|
||||||
|
bigint: true,
|
||||||
|
});
|
||||||
|
assert.equal(after.mtimeNs, before.mtimeNs);
|
||||||
|
assert.equal(after.ctimeNs, before.ctimeNs);
|
||||||
|
assert.notEqual(committed.applicationPlanDigest, 'f'.repeat(64));
|
||||||
|
|
||||||
|
const headPath = path.join(
|
||||||
|
state.deploymentRoot,
|
||||||
|
'service',
|
||||||
|
'cutover-instances',
|
||||||
|
state.captureCommand.request.instanceId,
|
||||||
|
'head.json',
|
||||||
|
);
|
||||||
|
const head = JSON.parse(fs.readFileSync(headPath, 'utf8'));
|
||||||
|
delete head.headDigest;
|
||||||
|
head.sourceRecordDigest = 'e'.repeat(64);
|
||||||
|
head.headDigest = digest(head);
|
||||||
|
fs.writeFileSync(headPath, `${JSON.stringify(head, null, 2)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
verifyLocalReconciliationApplication({
|
||||||
|
...verifyCommand,
|
||||||
|
request: {
|
||||||
|
...verifyCommand.request,
|
||||||
|
expectedApplicationPlanDigest: committed.applicationPlanDigest,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/lost terminal instance head/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'real stopped Docker target produces an independently verified bundle',
|
'real stopped Docker target produces an independently verified bundle',
|
||||||
{ skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' },
|
{ skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' },
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 155,
|
sourceFiles: 158,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 154,
|
nestedSourceFiles: 157,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user