mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(local): publish secret config reconciliation plans
This commit is contained in:
@@ -22,11 +22,15 @@
|
|||||||
diagnostics 不含 Env name/value/row body。absent、unsupported schema、Edge over-budget、旧顺序、同名连接、disabled、保留 `QL3_`、异常状态与
|
diagnostics 不含 Env name/value/row body。absent、unsupported schema、Edge over-budget、旧顺序、同名连接、disabled、保留 `QL3_`、异常状态与
|
||||||
overflow 均已覆盖。第二切片在既有 Local Owner reconciliation application 子目录增加私有 NDJSON row plan:Edge/Standalone 文件上限为 8/32 MiB,
|
overflow 均已覆盖。第二切片在既有 Local Owner reconciliation application 子目录增加私有 NDJSON row plan:Edge/Standalone 文件上限为 8/32 MiB,
|
||||||
单行上限 64 KiB;active/disabled candidate 使用不同 `legacy-db-env-*` 命名空间,目标 Secret 占用只记录 envelope 元数据组合摘要并强制
|
单行上限 64 KiB;active/disabled candidate 使用不同 `legacy-db-env-*` 命名空间,目标 Secret 占用只记录 envelope 元数据组合摘要并强制
|
||||||
`review_skip_conflict`。plan/receipt 绑定 application、独立 review authorization、sealed bundle、prepared head、row/candidate set 与文件摘要,且不含原
|
`review_skip_conflict`。第三切片将该 plan 绑定 D-391 `manual_external` review、sealed bundle、application、target projection、Automation adoption ledger
|
||||||
Env name/value、目标 ciphertext/key ID 或 row body。Local Admin 完整测试 `96/96`,Local Owner 完整测试 `277/270/7/0`;受限沙箱中的 3 个
|
的有界 content-free 投影与当前 head;`defer`、active Env 无已采纳 Legacy Task、历史 `Configs` 或 target 冲突都继续 manual。publisher 以 no-replace、
|
||||||
loopback `EPERM` 用例已在沙箱外对应测试文件 `15/15` 通过。后端完整门 `1563/1561/2/0`,18-package clean build/test
|
`0400/0500`、文件/目录 `fsync` 发布 `plan.ndjson` 与 `receipt.json`,覆盖 plan、receipt、terminal seal、head CAS 四个 response-loss 窗口,并只允许
|
||||||
`2924/2902/22/0`;package boundary、Cluster dependency、Edge import 与十四档 Local artifact audit 全部 compatible。远程第一切片 x64/arm64
|
`reconciliation_application_planned|reconciliation_automation_applied → reconciliation_secret_config_planned` 的合法单向推进;独立 verify 只读复算,不修复漂移。
|
||||||
backend 失败的共因是新增 Local Admin 嵌套文件后结构快照仍为 47/46,现已同步为 48/47;Local Owner 同步为 176/175,workspace 仍为 18 packages、
|
全部 evidence 不含原 Env name/value、目标 ciphertext/key ID 或 row body。Local Admin 完整测试 `96/96`,Local Owner 完整测试 `287/280/7/0`;
|
||||||
|
后端完整门 `1563/1561/2/0`,18-package clean build/test `2934/2912/22/0`;package boundary、Cluster dependency、Edge import 与十四档
|
||||||
|
Local artifact audit 全部 compatible,基础 Edge/Standalone 仍为 `2,611,978 / 2,612,056 bytes`、319 files、58 loaded modules,Owner-only authority
|
||||||
|
没有进入低资源常驻制品。
|
||||||
|
Local Admin 保持 48/47,Local Owner 因两个职责明确的嵌套文件增至 178/177,根目录仍只有一个 50 行 binary entry;workspace 仍为 18 packages、
|
||||||
`singleSourcePackages=[]`、`shallowSourcePackages=[]`,且只允许 exact Secret/Config row planner 导入 inspection subpath。
|
`singleSourcePackages=[]`、`shallowSourcePackages=[]`,且只允许 exact Secret/Config row planner 导入 inspection subpath。
|
||||||
|
|
||||||
D-385~D-388 的 `config.sh`/Keyv/SSH data-directory lineage 与 SQLite `Envs` 保持分离;当前无稳定生产 schema 的历史 `Configs` 表继续 sealed+manual,
|
D-385~D-388 的 `config.sh`/Keyv/SSH data-directory lineage 与 SQLite `Envs` 保持分离;当前无稳定生产 schema 的历史 `Configs` 表继续 sealed+manual,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0491:有界 Secret/Config Reconciliation 与任务环境绑定
|
# ADR-0491:有界 Secret/Config Reconciliation 与任务环境绑定
|
||||||
|
|
||||||
- 状态:Proposed(D-397 已实现 Legacy Env inspection 与私有有界 row plan,原子 application 尚未完成)
|
- 状态:Proposed(D-397 已实现 Legacy Env inspection、私有有界 row plan 与 durable plan publication;独立 signed decision 和原子 application 尚未完成)
|
||||||
- 日期:2026-08-23
|
- 日期:2026-08-23
|
||||||
- 决策:D-397
|
- 决策:D-397
|
||||||
- 关联:ADR-0073、ADR-0074、ADR-0092、ADR-0094、ADR-0480、ADR-0482、ADR-0483、ADR-0484、ADR-0485、ADR-0486、ADR-0487、ADR-0488、ADR-0490
|
- 关联:ADR-0073、ADR-0074、ADR-0092、ADR-0094、ADR-0480、ADR-0482、ADR-0483、ADR-0484、ADR-0485、ADR-0486、ADR-0487、ADR-0488、ADR-0490
|
||||||
@@ -27,7 +27,7 @@ application plan
|
|||||||
→ cross-domain completion
|
→ cross-domain completion
|
||||||
```
|
```
|
||||||
|
|
||||||
Secret/Config 不消费 Automation decision 作为自身授权。它必须重新绑定 D-391 的 `secret_and_config` facts、同一 sealed bundle、D-392 application plan、当前 target snapshot 与独立的逐候选 signed decision。强认证 User、Project Policy、Secret custody 与 Task mutation authority 都要在写事务前及事务内重新验证。
|
Secret/Config 不消费 Automation decision 作为自身授权。D-391 把 `secret_and_config` facts 标为 `blocked`,其 review action 只能是 `manual_external|defer`;D-397 专用 adapter 只接收每条 fact 都精确选择 `manual_external` 的决策流,把它重新绑定到同一 sealed bundle、D-392 application plan 与当前 target snapshot,任何 `defer` 都继续失败关闭。后续 application 还必须消费独立的逐候选 signed decision;强认证 User、Project Policy、Secret custody 与 Task mutation authority 都要在写事务前及事务内重新验证。
|
||||||
|
|
||||||
存在 active Env 时,Automation 必须已经完成,且至少一个经 `QingLong3LegacyAdoptions` 证明的 Legacy Task 可绑定;否则不得用“Secret 已保存”冒充行为迁移。只有停用 Env 的场景可以在 Automation `no_effect` 后做纯保全。
|
存在 active Env 时,Automation 必须已经完成,且至少一个经 `QingLong3LegacyAdoptions` 证明的 Legacy Task 可绑定;否则不得用“Secret 已保存”冒充行为迁移。只有停用 Env 的场景可以在 Automation `no_effect` 后做纯保全。
|
||||||
|
|
||||||
@@ -64,7 +64,9 @@ id ASC
|
|||||||
|
|
||||||
实现逐行读取,不把整张 `Envs` 或全部停用值加载到内存;active value 的在途内存由 64 KiB 合同封顶,停用值通过第二次有界扫描逐个交付。它位于既有 `@qinglong/local-admin/src/legacy-adoption/secret-and-config/`,不新增 workspace package、production dependency、daemon、timer、watcher、listener、socket、数据库连接池或 `src` 根平铺文件。
|
实现逐行读取,不把整张 `Envs` 或全部停用值加载到内存;active value 的在途内存由 64 KiB 合同封顶,停用值通过第二次有界扫描逐个交付。它位于既有 `@qinglong/local-admin/src/legacy-adoption/secret-and-config/`,不新增 workspace package、production dependency、daemon、timer、watcher、listener、socket、数据库连接池或 `src` 根平铺文件。
|
||||||
|
|
||||||
Local Owner 使用私有 NDJSON row plan 记录 header、逐行 content-free disposition、逐 candidate 目标冲突投影与 footer。Edge/Standalone plan 文件分别限制为 8 MiB/32 MiB,单行不超过 64 KiB;超过预算立即失败关闭。公开 plan/receipt 不保存原 Env name/value、目标 ciphertext、key ID 或原始 row body。active 与 disabled candidate 分别使用 `legacy-db-env-*` 和 `legacy-db-env-disabled-*` 命名空间;目标已经存在时只记录 envelope 元数据的组合摘要并进入 `review_skip_conflict`,不得读取明文、覆盖或自动改名。plan 绑定 application、独立 review authorization、sealed bundle、target projection 与 prepared head,并产生可重新计算的 row-set、candidate-set、plan-file 和 receipt digest。
|
Local Owner 使用私有 NDJSON row plan 记录 header、逐行 content-free disposition、逐 candidate 目标冲突投影与 footer。Edge/Standalone plan 文件分别限制为 8 MiB/32 MiB,单行不超过 64 KiB;超过预算立即失败关闭。公开 plan/receipt 不保存原 Env name/value、目标 ciphertext、key ID 或原始 row body。active 与 disabled candidate 分别使用 `legacy-db-env-*` 和 `legacy-db-env-disabled-*` 命名空间;目标已经存在时只记录 envelope 元数据的组合摘要并进入 `review_skip_conflict`,不得读取明文、覆盖或自动改名。plan 绑定 application、D-391 review authorization、sealed bundle、target projection、Automation adoption ledger 的有界 content-free 投影与 prepared head,并产生可重新计算的 row-set、candidate-set、adoption-set、plan-file 和 receipt digest。
|
||||||
|
|
||||||
|
durable publisher 固定写入 `<secretConfigRoot>/<secretConfigId>/{plan.ndjson,receipt.json,staging/}`,使用 no-replace publication、`0400/0500` 权限、文件与目录 `fsync`,并覆盖 plan、receipt、terminal seal、head CAS 四个 response-loss 窗口。只有 Automation 无需 adapter 时的 `reconciliation_application_planned`,或 Automation 已完成时的 `reconciliation_automation_applied`,可以单向推进到 `reconciliation_secret_config_planned`;verify 只读复算 plan/receipt/seal/head 绑定,不修复漂移。active Env 若没有至少一条已采纳 Legacy Task ledger 记录仍为 manual;历史 `Configs` 计入 `unadaptedLegacyConfigCount` 并保持 manual。
|
||||||
|
|
||||||
### 4. 原子 application 必须同时完成 custody 与行为绑定
|
### 4. 原子 application 必须同时完成 custody 与行为绑定
|
||||||
|
|
||||||
@@ -131,6 +133,6 @@ Cluster 不得把 Legacy Env 明文写入 PostgreSQL、ConfigMap、Job command
|
|||||||
|
|
||||||
## 当前验证与后续门禁
|
## 当前验证与后续门禁
|
||||||
|
|
||||||
D-397 当前两切片已经实现并测试:absent、unsupported、Edge over-budget、2.x 顺序、同名连接、disabled preservation、保留前缀、异常状态、effective overflow、candidate digest、content-free diagnostics、私有有界 row plan、目标 Secret 冲突、no-effect/manual outcome、plan/receipt drift 与 plan 字节预算。调用方 visitor 的预算异常保持原始类型,不再被误报为 SQLite 读取失败。Local Admin 完整测试为 96/96;Local Owner 完整测试为 277/270/7/0;后端完整门为 1563/1561/2/0,18-package clean build/test 为 2924/2902/22/0。package boundary、Cluster dependency、Edge import 与十四档 Local artifact audit 全部 compatible;workspace 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。第一切片远程 x64/arm64 backend 失败已定位为新增嵌套 Local Admin 文件后审阅计数仍停留在 47/46,本切片已同步 Local Admin 48/47、Local Owner 176/175,并以精确文件 + subpath 规则允许 Secret/Config planner 读取 inspection;相邻文件继续被依赖隔离门拒绝。
|
D-397 当前三切片已经实现并测试:absent、unsupported、Edge over-budget、2.x 顺序、同名连接、disabled preservation、保留前缀、异常状态、effective overflow、candidate digest、content-free diagnostics、私有有界 row plan、目标 Secret 冲突、Automation adoption projection、no-effect/manual outcome、durable no-replace publication、terminal seal、head CAS、四个 response-loss 窗口、只读 verify 与 plan/receipt drift。调用方 visitor 的预算异常保持原始类型,不再被误报为 SQLite 读取失败。Local Admin 完整测试为 96/96;Local Owner 完整测试为 287/280/7/0;后端完整门为 1563/1561/2/0,18-package clean build/test 为 2934/2912/22/0。package boundary、Cluster dependency、Edge import 与十四档 Local artifact audit 全部 compatible;基础 Edge/Standalone 仍为 2,611,978 / 2,612,056 bytes、319 files、58 loaded modules,Owner-only authority 没有进入低资源常驻制品。workspace 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。Local Admin 保持 48/47,Local Owner 随两个职责明确的嵌套文件增至 178/177;根目录仍只有一个 50 行 binary entry,没有新增平铺源文件。依赖隔离门仍只允许 exact Secret/Config row planner 导入 inspection subpath,相邻文件继续被拒绝。
|
||||||
|
|
||||||
转为 Accepted 前仍必须完成:独立 signed decision、原子 Secret/Task/Trigger/dispatch publisher、prepared/apply/rollback response-loss、completion schema 演进、18-package/boundary/artifact gates、真实 Edge 空间预算、PostgreSQL HA 与 Cluster Secret provider live gate。
|
转为 Accepted 前仍必须完成:独立 signed decision、原子 Secret/Task/Trigger/dispatch publisher、prepared/apply/rollback response-loss、completion schema 演进、18-package/boundary/artifact gates、真实 Edge 空间预算、PostgreSQL HA 与 Cluster Secret provider live gate。
|
||||||
|
|||||||
+1
-1
@@ -494,7 +494,7 @@
|
|||||||
| [ADR-0488](./ADR-0488-cross-domain-reconciliation-completion-fence.md) | 跨领域 Reconciliation 完成围栏与目标重启授权 | Accepted |
|
| [ADR-0488](./ADR-0488-cross-domain-reconciliation-completion-fence.md) | 跨领域 Reconciliation 完成围栏与目标重启授权 | Accepted |
|
||||||
| [ADR-0489](./ADR-0489-service-manager-completion-restart-lineage.md) | Service Manager 完成围栏重启谱系 | Accepted |
|
| [ADR-0489](./ADR-0489-service-manager-completion-restart-lineage.md) | Service Manager 完成围栏重启谱系 | Accepted |
|
||||||
| [ADR-0490](./ADR-0490-run-history-terminal-preservation.md) | Run History 终态保全与跨领域完成证明 | Accepted |
|
| [ADR-0490](./ADR-0490-run-history-terminal-preservation.md) | Run History 终态保全与跨领域完成证明 | Accepted |
|
||||||
| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 inspection + row plan) |
|
| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 inspection + durable plan) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type LocalCutoverInstanceHeadState =
|
|||||||
| 'reconciliation_automation_apply_prepared'
|
| 'reconciliation_automation_apply_prepared'
|
||||||
| 'reconciliation_automation_applied'
|
| 'reconciliation_automation_applied'
|
||||||
| 'reconciliation_automation_rolled_back'
|
| 'reconciliation_automation_rolled_back'
|
||||||
|
| 'reconciliation_secret_config_planned'
|
||||||
| 'reconciliation_completed'
|
| 'reconciliation_completed'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
@@ -183,6 +184,7 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
|||||||
head.state !== 'reconciliation_automation_apply_prepared' &&
|
head.state !== 'reconciliation_automation_apply_prepared' &&
|
||||||
head.state !== 'reconciliation_automation_applied' &&
|
head.state !== 'reconciliation_automation_applied' &&
|
||||||
head.state !== 'reconciliation_automation_rolled_back' &&
|
head.state !== 'reconciliation_automation_rolled_back' &&
|
||||||
|
head.state !== 'reconciliation_secret_config_planned' &&
|
||||||
head.state !== 'reconciliation_completed' &&
|
head.state !== 'reconciliation_completed' &&
|
||||||
head.state !== 'rollback_prepared' &&
|
head.state !== 'rollback_prepared' &&
|
||||||
head.state !== 'legacy_restart_requested' &&
|
head.state !== 'legacy_restart_requested' &&
|
||||||
@@ -364,6 +366,7 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
| 'reconciliation_automation_apply_prepared'
|
| 'reconciliation_automation_apply_prepared'
|
||||||
| 'reconciliation_automation_applied'
|
| 'reconciliation_automation_applied'
|
||||||
| 'reconciliation_automation_rolled_back'
|
| 'reconciliation_automation_rolled_back'
|
||||||
|
| 'reconciliation_secret_config_planned'
|
||||||
| 'reconciliation_completed'
|
| 'reconciliation_completed'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
@@ -417,6 +420,7 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_automation_apply_prepared' ||
|
current.state === 'reconciliation_automation_apply_prepared' ||
|
||||||
current.state === 'reconciliation_automation_applied' ||
|
current.state === 'reconciliation_automation_applied' ||
|
||||||
current.state === 'reconciliation_automation_rolled_back' ||
|
current.state === 'reconciliation_automation_rolled_back' ||
|
||||||
|
current.state === 'reconciliation_secret_config_planned' ||
|
||||||
current.state === 'reconciliation_completed' ||
|
current.state === 'reconciliation_completed' ||
|
||||||
current.state === 'legacy_restart_requested' ||
|
current.state === 'legacy_restart_requested' ||
|
||||||
current.state === 'legacy_running' ||
|
current.state === 'legacy_running' ||
|
||||||
@@ -462,6 +466,9 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_automation_apply_prepared') ||
|
current.state === 'reconciliation_automation_apply_prepared') ||
|
||||||
(state === 'reconciliation_automation_rolled_back' &&
|
(state === 'reconciliation_automation_rolled_back' &&
|
||||||
current.state === 'reconciliation_automation_applied') ||
|
current.state === 'reconciliation_automation_applied') ||
|
||||||
|
(state === 'reconciliation_secret_config_planned' &&
|
||||||
|
(current.state === 'reconciliation_application_planned' ||
|
||||||
|
current.state === 'reconciliation_automation_applied')) ||
|
||||||
(state === 'reconciliation_completed' &&
|
(state === 'reconciliation_completed' &&
|
||||||
(current.state === 'reconciliation_application_planned' ||
|
(current.state === 'reconciliation_application_planned' ||
|
||||||
current.state === 'reconciliation_automation_applied')) ||
|
current.state === 'reconciliation_automation_applied')) ||
|
||||||
|
|||||||
@@ -154,6 +154,13 @@ import {
|
|||||||
verifyLocalReconciliationAutomationApply,
|
verifyLocalReconciliationAutomationApply,
|
||||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||||
} from './reconciliation/application/automation/applyCoordinator';
|
} from './reconciliation/application/automation/applyCoordinator';
|
||||||
|
import {
|
||||||
|
planLocalReconciliationSecretConfig,
|
||||||
|
planLocalReconciliationSecretConfigCommandFile,
|
||||||
|
readLocalReconciliationSecretConfigTerminal,
|
||||||
|
verifyLocalReconciliationSecretConfigPlan,
|
||||||
|
verifyLocalReconciliationSecretConfigPlanCommandFile,
|
||||||
|
} from './reconciliation/application/secret-and-config/coordinator';
|
||||||
import {
|
import {
|
||||||
preserveLocalReconciliationRunHistory,
|
preserveLocalReconciliationRunHistory,
|
||||||
preserveLocalReconciliationRunHistoryCommandFile,
|
preserveLocalReconciliationRunHistoryCommandFile,
|
||||||
@@ -206,6 +213,11 @@ export {
|
|||||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||||
rollbackLocalReconciliationAutomationApply,
|
rollbackLocalReconciliationAutomationApply,
|
||||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||||
|
planLocalReconciliationSecretConfig,
|
||||||
|
planLocalReconciliationSecretConfigCommandFile,
|
||||||
|
readLocalReconciliationSecretConfigTerminal,
|
||||||
|
verifyLocalReconciliationSecretConfigPlan,
|
||||||
|
verifyLocalReconciliationSecretConfigPlanCommandFile,
|
||||||
preserveLocalReconciliationRunHistory,
|
preserveLocalReconciliationRunHistory,
|
||||||
preserveLocalReconciliationRunHistoryCommandFile,
|
preserveLocalReconciliationRunHistoryCommandFile,
|
||||||
readLocalReconciliationRunHistoryTerminal,
|
readLocalReconciliationRunHistoryTerminal,
|
||||||
@@ -325,6 +337,31 @@ export {
|
|||||||
type LocalReconciliationAutomationPlanSummary,
|
type LocalReconciliationAutomationPlanSummary,
|
||||||
type LocalReconciliationAutomationRowRequirement,
|
type LocalReconciliationAutomationRowRequirement,
|
||||||
} from './reconciliation/application/automation/rowPlan';
|
} from './reconciliation/application/automation/rowPlan';
|
||||||
|
export {
|
||||||
|
normalizeLocalReconciliationSecretConfigPlanCommand,
|
||||||
|
normalizeLocalReconciliationSecretConfigVerifyCommand,
|
||||||
|
type LocalReconciliationSecretConfigOptions,
|
||||||
|
type LocalReconciliationSecretConfigPlanCommand,
|
||||||
|
type LocalReconciliationSecretConfigPlanResult,
|
||||||
|
type LocalReconciliationSecretConfigVerifyCommand,
|
||||||
|
} from './reconciliation/application/secret-and-config/contract';
|
||||||
|
export {
|
||||||
|
type LocalReconciliationSecretConfigPlanDependencies,
|
||||||
|
type LocalReconciliationSecretConfigTerminal,
|
||||||
|
} from './reconciliation/application/secret-and-config/coordinator';
|
||||||
|
export {
|
||||||
|
MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES,
|
||||||
|
MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES,
|
||||||
|
normalizeLocalReconciliationSecretConfigPlanReceipt,
|
||||||
|
type LocalReconciliationSecretConfigCandidateRequirement,
|
||||||
|
type LocalReconciliationSecretConfigPlanCandidate,
|
||||||
|
type LocalReconciliationSecretConfigPlanFooter,
|
||||||
|
type LocalReconciliationSecretConfigPlanHeader,
|
||||||
|
type LocalReconciliationSecretConfigPlanReceipt,
|
||||||
|
type LocalReconciliationSecretConfigPlanRow,
|
||||||
|
type LocalReconciliationSecretConfigPlanSummary,
|
||||||
|
type LocalReconciliationSecretConfigTarget,
|
||||||
|
} from './reconciliation/application/secret-and-config/rowPlan';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
LocalDeploymentConfigurationError,
|
LocalDeploymentConfigurationError,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
applyLocalReconciliationAutomationCommandFile,
|
applyLocalReconciliationAutomationCommandFile,
|
||||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||||
|
planLocalReconciliationSecretConfigCommandFile,
|
||||||
|
verifyLocalReconciliationSecretConfigPlanCommandFile,
|
||||||
preserveLocalReconciliationRunHistoryCommandFile,
|
preserveLocalReconciliationRunHistoryCommandFile,
|
||||||
verifyLocalReconciliationRunHistoryCommandFile,
|
verifyLocalReconciliationRunHistoryCommandFile,
|
||||||
completeLocalReconciliationCommandFile,
|
completeLocalReconciliationCommandFile,
|
||||||
@@ -52,7 +54,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|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-run-history-preserve|reconciliation-run-history-verify|reconciliation-complete|reconciliation-complete-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|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-secret-config-plan|reconciliation-secret-config-verify|reconciliation-run-history-preserve|reconciliation-run-history-verify|reconciliation-complete|reconciliation-complete-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')) {
|
||||||
@@ -102,6 +104,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
argv[0] !== 'reconciliation-automation-apply' &&
|
argv[0] !== 'reconciliation-automation-apply' &&
|
||||||
argv[0] !== 'reconciliation-automation-apply-verify' &&
|
argv[0] !== 'reconciliation-automation-apply-verify' &&
|
||||||
argv[0] !== 'reconciliation-automation-apply-rollback' &&
|
argv[0] !== 'reconciliation-automation-apply-rollback' &&
|
||||||
|
argv[0] !== 'reconciliation-secret-config-plan' &&
|
||||||
|
argv[0] !== 'reconciliation-secret-config-verify' &&
|
||||||
argv[0] !== 'reconciliation-run-history-preserve' &&
|
argv[0] !== 'reconciliation-run-history-preserve' &&
|
||||||
argv[0] !== 'reconciliation-run-history-verify' &&
|
argv[0] !== 'reconciliation-run-history-verify' &&
|
||||||
argv[0] !== 'reconciliation-complete' &&
|
argv[0] !== 'reconciliation-complete' &&
|
||||||
@@ -218,6 +222,10 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
? verifyLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
? verifyLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-automation-apply-rollback'
|
: argv[0] === 'reconciliation-automation-apply-rollback'
|
||||||
? rollbackLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
? rollbackLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-secret-config-plan'
|
||||||
|
? planLocalReconciliationSecretConfigCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-secret-config-verify'
|
||||||
|
? verifyLocalReconciliationSecretConfigPlanCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-run-history-preserve'
|
: argv[0] === 'reconciliation-run-history-preserve'
|
||||||
? preserveLocalReconciliationRunHistoryCommandFile(argv[2]!)
|
? preserveLocalReconciliationRunHistoryCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-run-history-verify'
|
: argv[0] === 'reconciliation-run-history-verify'
|
||||||
|
|||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { currentIdentity } from '../../../foundation/contract';
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||||
|
|
||||||
|
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 SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||||
|
const MAX_PATH_BYTES = 4_096;
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigOptions {
|
||||||
|
readonly deploymentRoot: string;
|
||||||
|
readonly applicationRoot: string;
|
||||||
|
readonly secretConfigRoot: string;
|
||||||
|
readonly allowRootService: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigPlanCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.secret-config.plan';
|
||||||
|
readonly options: Readonly<LocalReconciliationSecretConfigOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
secretConfigId: string;
|
||||||
|
applicationId: string;
|
||||||
|
expectedApplicationPlanDigest: string;
|
||||||
|
expectedHeadDigest: string;
|
||||||
|
decisionFilePath: string;
|
||||||
|
projectId: string;
|
||||||
|
preparedAtMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigVerifyCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.secret-config.verify';
|
||||||
|
readonly options: Readonly<LocalReconciliationSecretConfigOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
secretConfigId: string;
|
||||||
|
expectedSecretConfigPlanDigest: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigPlanResult {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation:
|
||||||
|
| LocalReconciliationSecretConfigPlanCommand['operation']
|
||||||
|
| LocalReconciliationSecretConfigVerifyCommand['operation'];
|
||||||
|
readonly status: 'prepared' | 'existing' | 'verified';
|
||||||
|
readonly state: 'reconciliation_secret_config_planned';
|
||||||
|
readonly secretConfigId: string;
|
||||||
|
readonly secretConfigPlanDigest: string;
|
||||||
|
readonly outcome: 'ready' | 'manual_required' | 'no_effect';
|
||||||
|
readonly rowCount: number;
|
||||||
|
readonly eligibleBindingCount: number;
|
||||||
|
readonly eligiblePreservationCount: number;
|
||||||
|
readonly targetConflictCount: number;
|
||||||
|
readonly adoptedLegacyTaskCount: number;
|
||||||
|
readonly unadaptedLegacyConfigCount: number;
|
||||||
|
readonly instanceHeadDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationError(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation secret config ${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 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 lowercase UUID v4`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectId(value: unknown): string {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
value.length < 1 ||
|
||||||
|
value.length > 128 ||
|
||||||
|
/[\u0000-\u001f\u007f]/u.test(value)
|
||||||
|
) {
|
||||||
|
configurationError('projectId is invalid');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigOptions> {
|
||||||
|
const options = object(value, 'options');
|
||||||
|
exact(
|
||||||
|
options,
|
||||||
|
[
|
||||||
|
'allowRootService',
|
||||||
|
'applicationRoot',
|
||||||
|
'deploymentRoot',
|
||||||
|
'secretConfigRoot',
|
||||||
|
],
|
||||||
|
'options',
|
||||||
|
);
|
||||||
|
const identity = currentIdentity();
|
||||||
|
if (
|
||||||
|
typeof options.allowRootService !== 'boolean' ||
|
||||||
|
(identity.uid === 0) !== options.allowRootService
|
||||||
|
) {
|
||||||
|
configurationError('command identity is invalid');
|
||||||
|
}
|
||||||
|
const roots = [
|
||||||
|
safePath(options.deploymentRoot, 'deploymentRoot'),
|
||||||
|
safePath(options.applicationRoot, 'applicationRoot'),
|
||||||
|
safePath(options.secretConfigRoot, 'secretConfigRoot'),
|
||||||
|
];
|
||||||
|
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('authority roots overlap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
deploymentRoot: roots[0]!,
|
||||||
|
applicationRoot: roots[1]!,
|
||||||
|
secretConfigRoot: roots[2]!,
|
||||||
|
allowRootService: options.allowRootService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function command(
|
||||||
|
value: unknown,
|
||||||
|
operation:
|
||||||
|
| LocalReconciliationSecretConfigPlanCommand['operation']
|
||||||
|
| LocalReconciliationSecretConfigVerifyCommand['operation'],
|
||||||
|
): Readonly<{
|
||||||
|
options: Readonly<LocalReconciliationSecretConfigOptions>;
|
||||||
|
request: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
|
const selected = object(value, 'command');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
['operation', 'options', 'request', 'schemaVersion'],
|
||||||
|
'command',
|
||||||
|
);
|
||||||
|
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||||
|
configurationError('command version or operation is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
options: normalizeOptions(selected.options),
|
||||||
|
request: object(selected.request, 'request'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationSecretConfigPlanCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigPlanCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.secret-config.plan',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
[
|
||||||
|
'applicationId',
|
||||||
|
'decisionFilePath',
|
||||||
|
'expectedApplicationPlanDigest',
|
||||||
|
'expectedHeadDigest',
|
||||||
|
'preparedAtMs',
|
||||||
|
'projectId',
|
||||||
|
'secretConfigId',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
const decisionFilePath = safePath(
|
||||||
|
selected.request.decisionFilePath,
|
||||||
|
'decisionFilePath',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
selected.options.deploymentRoot,
|
||||||
|
selected.options.applicationRoot,
|
||||||
|
selected.options.secretConfigRoot,
|
||||||
|
].some(
|
||||||
|
(root) =>
|
||||||
|
overlaps(root, decisionFilePath) || overlaps(decisionFilePath, root),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
configurationError('decisionFilePath overlaps an authority root');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(selected.request.preparedAtMs) ||
|
||||||
|
(selected.request.preparedAtMs as number) < 0
|
||||||
|
) {
|
||||||
|
configurationError('preparedAtMs is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.plan',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
secretConfigId: identifier(
|
||||||
|
selected.request.secretConfigId,
|
||||||
|
'secretConfigId',
|
||||||
|
),
|
||||||
|
applicationId: identifier(
|
||||||
|
selected.request.applicationId,
|
||||||
|
'applicationId',
|
||||||
|
),
|
||||||
|
expectedApplicationPlanDigest: digest(
|
||||||
|
selected.request.expectedApplicationPlanDigest,
|
||||||
|
'expectedApplicationPlanDigest',
|
||||||
|
),
|
||||||
|
expectedHeadDigest: digest(
|
||||||
|
selected.request.expectedHeadDigest,
|
||||||
|
'expectedHeadDigest',
|
||||||
|
),
|
||||||
|
decisionFilePath,
|
||||||
|
projectId: projectId(selected.request.projectId),
|
||||||
|
preparedAtMs: selected.request.preparedAtMs as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationSecretConfigVerifyCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigVerifyCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.secret-config.verify',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
['expectedSecretConfigPlanDigest', 'secretConfigId'],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.verify',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
secretConfigId: identifier(
|
||||||
|
selected.request.secretConfigId,
|
||||||
|
'secretConfigId',
|
||||||
|
),
|
||||||
|
expectedSecretConfigPlanDigest: digest(
|
||||||
|
selected.request.expectedSecretConfigPlanDigest,
|
||||||
|
'expectedSecretConfigPlanDigest',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
+863
@@ -0,0 +1,863 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||||
|
|
||||||
|
import { currentIdentity } from '../../../foundation/contract';
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||||
|
import {
|
||||||
|
ensurePrivateDirectory,
|
||||||
|
publishExactFile,
|
||||||
|
syncPublishedDirectory,
|
||||||
|
validatePrivateDirectory,
|
||||||
|
} from '../../../foundation/files';
|
||||||
|
import {
|
||||||
|
advanceLocalCutoverInstanceHead,
|
||||||
|
readLocalCutoverInstanceHead,
|
||||||
|
type LocalCutoverInstanceHead,
|
||||||
|
} from '../../../cutover/instanceLineage';
|
||||||
|
import { readLocalReconciliationPlanTerminal } from '../../planning/preparation';
|
||||||
|
import {
|
||||||
|
assertLocalReconciliationReviewDecisionMatchesFact,
|
||||||
|
withLocalReconciliationReviewDecisionFile,
|
||||||
|
} from '../../review/decisionFile';
|
||||||
|
import { visitLocalReconciliationDiagnosticFacts } from '../../review/diagnostics';
|
||||||
|
import { withLocalReconciliationSealedDatabase } from '../../sealed-bundle/reader';
|
||||||
|
import {
|
||||||
|
readLocalReconciliationApplicationTerminal,
|
||||||
|
type LocalReconciliationApplicationTerminal,
|
||||||
|
} from '../coordinator';
|
||||||
|
import {
|
||||||
|
normalizeLocalReconciliationSecretConfigPlanCommand,
|
||||||
|
normalizeLocalReconciliationSecretConfigVerifyCommand,
|
||||||
|
type LocalReconciliationSecretConfigPlanCommand,
|
||||||
|
type LocalReconciliationSecretConfigPlanResult,
|
||||||
|
} from './contract';
|
||||||
|
import {
|
||||||
|
buildLocalReconciliationSecretConfigPlanReceipt,
|
||||||
|
hashLocalReconciliationSecretConfigPlanFile,
|
||||||
|
MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES,
|
||||||
|
MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES,
|
||||||
|
normalizeLocalReconciliationSecretConfigPlanReceipt,
|
||||||
|
writeLocalReconciliationSecretConfigPlan,
|
||||||
|
type LocalReconciliationSecretConfigPlanHeader,
|
||||||
|
type LocalReconciliationSecretConfigPlanReceipt,
|
||||||
|
} from './rowPlan';
|
||||||
|
|
||||||
|
const MAX_RECEIPT_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigPlanDependencies {
|
||||||
|
readonly beforeDatabaseOpen?: (
|
||||||
|
kind: 'legacy' | 'target',
|
||||||
|
mode: 'main_only_immutable' | 'wal_shm_readonly',
|
||||||
|
cacheKiB: 2_048 | 8_192,
|
||||||
|
) => void;
|
||||||
|
readonly afterDatabaseClose?: (kind: 'legacy' | 'target') => void;
|
||||||
|
readonly afterPlanPublished?: () => void;
|
||||||
|
readonly afterReceiptPublished?: () => void;
|
||||||
|
readonly afterTerminalSealed?: () => void;
|
||||||
|
readonly afterHeadAdvanced?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretConfigPaths {
|
||||||
|
readonly root: string;
|
||||||
|
readonly staging: string;
|
||||||
|
readonly plan: string;
|
||||||
|
readonly planStage: string;
|
||||||
|
readonly receipt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretConfigReviewAuthority {
|
||||||
|
readonly tableDisposition: 'absent' | 'manual_external';
|
||||||
|
readonly unadaptedLegacyConfigCount: number;
|
||||||
|
readonly decisionFileDigest: string;
|
||||||
|
readonly confirmDecisionFileIdentity: () => void;
|
||||||
|
readonly planTerminal: ReturnType<typeof readLocalReconciliationPlanTerminal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationError(message: string, cause?: unknown): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation secret config ${message}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function secretConfigPaths(
|
||||||
|
secretConfigRoot: string,
|
||||||
|
secretConfigId: string,
|
||||||
|
): Readonly<SecretConfigPaths> {
|
||||||
|
const root = path.join(secretConfigRoot, secretConfigId);
|
||||||
|
const staging = path.join(root, 'staging');
|
||||||
|
return Object.freeze({
|
||||||
|
root,
|
||||||
|
staging,
|
||||||
|
plan: path.join(root, 'plan.ndjson'),
|
||||||
|
planStage: path.join(staging, 'plan.ndjson.stage'),
|
||||||
|
receipt: path.join(root, 'receipt.json'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCatalog(
|
||||||
|
selected: Readonly<SecretConfigPaths>,
|
||||||
|
terminal: boolean,
|
||||||
|
): void {
|
||||||
|
const allowed = new Set([
|
||||||
|
'plan.ndjson',
|
||||||
|
'receipt.json',
|
||||||
|
'staging',
|
||||||
|
...(!terminal ? ['.receipt.json.ql3-deploy-stage'] : []),
|
||||||
|
]);
|
||||||
|
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
||||||
|
if (!allowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||||
|
configurationError('plan root contains unknown material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stagingEntries = fs.readdirSync(selected.staging);
|
||||||
|
if (
|
||||||
|
terminal
|
||||||
|
? stagingEntries.length !== 0
|
||||||
|
: stagingEntries.some((entry) => entry !== 'plan.ndjson.stage')
|
||||||
|
) {
|
||||||
|
configurationError('plan staging contains unknown material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDirectory(
|
||||||
|
directory: string,
|
||||||
|
uid: number,
|
||||||
|
modes: readonly number[],
|
||||||
|
label: string,
|
||||||
|
): number {
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.lstatSync(directory);
|
||||||
|
} catch (error) {
|
||||||
|
return configurationError(`${label} is unavailable`, error);
|
||||||
|
}
|
||||||
|
const mode = stat.mode & 0o777;
|
||||||
|
if (
|
||||||
|
!stat.isDirectory() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.uid !== uid ||
|
||||||
|
!modes.includes(mode) ||
|
||||||
|
fs.realpathSync(directory) !== directory
|
||||||
|
) {
|
||||||
|
configurationError(`${label} identity is invalid`);
|
||||||
|
}
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReceipt(
|
||||||
|
filePath: string,
|
||||||
|
uid: number,
|
||||||
|
allowedModes: readonly number[],
|
||||||
|
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
let bytes: Buffer | undefined;
|
||||||
|
try {
|
||||||
|
const before = fs.lstatSync(filePath, { bigint: true });
|
||||||
|
if (
|
||||||
|
!before.isFile() ||
|
||||||
|
before.isSymbolicLink() ||
|
||||||
|
Number(before.uid) !== uid ||
|
||||||
|
!allowedModes.includes(Number(before.mode) & 0o777) ||
|
||||||
|
before.nlink !== 1n ||
|
||||||
|
before.size < 2n ||
|
||||||
|
before.size > BigInt(MAX_RECEIPT_BYTES)
|
||||||
|
) {
|
||||||
|
configurationError('receipt identity is invalid');
|
||||||
|
}
|
||||||
|
descriptor = fs.openSync(
|
||||||
|
filePath,
|
||||||
|
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||||
|
);
|
||||||
|
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||||
|
if (
|
||||||
|
opened.dev !== before.dev ||
|
||||||
|
opened.ino !== before.ino ||
|
||||||
|
opened.size !== before.size
|
||||||
|
) {
|
||||||
|
configurationError('receipt changed while opening');
|
||||||
|
}
|
||||||
|
bytes = fs.readFileSync(descriptor);
|
||||||
|
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||||
|
const current = fs.lstatSync(filePath, { bigint: true });
|
||||||
|
if (
|
||||||
|
after.dev !== before.dev ||
|
||||||
|
after.ino !== before.ino ||
|
||||||
|
after.size !== before.size ||
|
||||||
|
current.dev !== before.dev ||
|
||||||
|
current.ino !== before.ino ||
|
||||||
|
current.mtimeNs !== before.mtimeNs ||
|
||||||
|
current.ctimeNs !== before.ctimeNs ||
|
||||||
|
current.mode !== before.mode ||
|
||||||
|
current.nlink !== before.nlink
|
||||||
|
) {
|
||||||
|
configurationError('receipt changed while reading');
|
||||||
|
}
|
||||||
|
return normalizeLocalReconciliationSecretConfigPlanReceipt(
|
||||||
|
JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
return configurationError('receipt cannot be read', error);
|
||||||
|
} finally {
|
||||||
|
bytes?.fill(0);
|
||||||
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePlanFile(
|
||||||
|
filePath: string,
|
||||||
|
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||||
|
uid: number,
|
||||||
|
allowedModes: readonly number[],
|
||||||
|
): void {
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
try {
|
||||||
|
const before = fs.lstatSync(filePath, { bigint: true });
|
||||||
|
if (
|
||||||
|
!before.isFile() ||
|
||||||
|
before.isSymbolicLink() ||
|
||||||
|
Number(before.uid) !== uid ||
|
||||||
|
!allowedModes.includes(Number(before.mode) & 0o777) ||
|
||||||
|
before.nlink !== 1n ||
|
||||||
|
before.size !== BigInt(receipt.planFileBytes)
|
||||||
|
) {
|
||||||
|
configurationError('plan file identity is invalid');
|
||||||
|
}
|
||||||
|
descriptor = fs.openSync(
|
||||||
|
filePath,
|
||||||
|
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||||
|
);
|
||||||
|
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||||
|
if (
|
||||||
|
opened.dev !== before.dev ||
|
||||||
|
opened.ino !== before.ino ||
|
||||||
|
opened.size !== before.size ||
|
||||||
|
hashLocalReconciliationSecretConfigPlanFile(
|
||||||
|
descriptor,
|
||||||
|
receipt.planFileBytes,
|
||||||
|
) !== receipt.planFileDigest
|
||||||
|
) {
|
||||||
|
configurationError('plan file content drifted');
|
||||||
|
}
|
||||||
|
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||||
|
const current = fs.lstatSync(filePath, { bigint: true });
|
||||||
|
if (
|
||||||
|
after.dev !== before.dev ||
|
||||||
|
after.ino !== before.ino ||
|
||||||
|
after.mtimeNs !== before.mtimeNs ||
|
||||||
|
after.ctimeNs !== before.ctimeNs ||
|
||||||
|
current.dev !== before.dev ||
|
||||||
|
current.ino !== before.ino ||
|
||||||
|
current.mtimeNs !== before.mtimeNs ||
|
||||||
|
current.ctimeNs !== before.ctimeNs
|
||||||
|
) {
|
||||||
|
configurationError('plan file changed while verifying');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
configurationError('plan file cannot be verified', error);
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiptContents(
|
||||||
|
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||||
|
): string {
|
||||||
|
const contents = `${JSON.stringify(receipt, null, 2)}\n`;
|
||||||
|
if (Buffer.byteLength(contents, 'utf8') > MAX_RECEIPT_BYTES) {
|
||||||
|
configurationError('receipt exceeds 64 KiB');
|
||||||
|
}
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedPriorState(
|
||||||
|
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
|
): 'reconciliation_application_planned' | 'reconciliation_automation_applied' {
|
||||||
|
const automation = terminal.plan.domains.find(
|
||||||
|
(selected) => selected.domain === 'automation',
|
||||||
|
);
|
||||||
|
if (!automation) configurationError('Automation domain summary is missing');
|
||||||
|
return automation.action === 'adapter_required' ||
|
||||||
|
automation.action === 'adapter_and_manual'
|
||||||
|
? 'reconciliation_automation_applied'
|
||||||
|
: 'reconciliation_application_planned';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateApplicationBinding(
|
||||||
|
command: Readonly<LocalReconciliationSecretConfigPlanCommand>,
|
||||||
|
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
|
head: Readonly<LocalCutoverInstanceHead>,
|
||||||
|
): void {
|
||||||
|
const secretConfig = terminal.plan.domains.find(
|
||||||
|
(selected) => selected.domain === 'secret_and_config',
|
||||||
|
);
|
||||||
|
const priorState = expectedPriorState(terminal);
|
||||||
|
if (
|
||||||
|
terminal.intent.command.options.deploymentRoot !==
|
||||||
|
command.options.deploymentRoot ||
|
||||||
|
terminal.intent.command.options.applicationRoot !==
|
||||||
|
command.options.applicationRoot ||
|
||||||
|
terminal.plan.applicationId !== command.request.applicationId ||
|
||||||
|
terminal.plan.applicationPlanDigest !==
|
||||||
|
command.request.expectedApplicationPlanDigest ||
|
||||||
|
terminal.head.state !== 'reconciliation_application_planned' ||
|
||||||
|
terminal.head.sourceRecordDigest !== terminal.plan.applicationPlanDigest ||
|
||||||
|
!secretConfig ||
|
||||||
|
secretConfig.action !== 'manual_external' ||
|
||||||
|
head.state !== priorState ||
|
||||||
|
head.headDigest !== command.request.expectedHeadDigest ||
|
||||||
|
(priorState === 'reconciliation_application_planned' &&
|
||||||
|
head.sourceRecordDigest !== terminal.plan.applicationPlanDigest) ||
|
||||||
|
command.request.preparedAtMs < terminal.plan.committedAtMs ||
|
||||||
|
command.request.preparedAtMs < head.updatedAtMs
|
||||||
|
) {
|
||||||
|
configurationError('plan is detached from its ordered application head');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewAuthority(
|
||||||
|
command: Readonly<LocalReconciliationSecretConfigPlanCommand>,
|
||||||
|
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
|
dependencies: LocalReconciliationSecretConfigPlanDependencies,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<SecretConfigReviewAuthority> {
|
||||||
|
const planTerminal = readLocalReconciliationPlanTerminal(
|
||||||
|
terminal.review.intent.command.options.planRoot,
|
||||||
|
terminal.review.intent.command.request.planId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
let envDisposition: 'absent' | 'manual_external' = 'absent';
|
||||||
|
let envSeen = false;
|
||||||
|
let unadaptedLegacyConfigCount = 0;
|
||||||
|
const reviewed = withLocalReconciliationReviewDecisionFile(
|
||||||
|
command.request.decisionFilePath,
|
||||||
|
{
|
||||||
|
reviewId: terminal.review.review.reviewId,
|
||||||
|
profile: terminal.plan.profile,
|
||||||
|
planDigest: planTerminal.plan.planDigest,
|
||||||
|
preparationDigest: terminal.review.intent.preparationDigest,
|
||||||
|
},
|
||||||
|
(cursor) => {
|
||||||
|
for (const database of ['legacy', 'target'] as const) {
|
||||||
|
const opened = withLocalReconciliationSealedDatabase(
|
||||||
|
planTerminal.bundle,
|
||||||
|
database,
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
(client) =>
|
||||||
|
visitLocalReconciliationDiagnosticFacts(
|
||||||
|
client,
|
||||||
|
database,
|
||||||
|
(fact) => {
|
||||||
|
if (fact.decisionRequirement === 'informational') return;
|
||||||
|
const decision = cursor.next();
|
||||||
|
if (decision === null) {
|
||||||
|
configurationError('decision file omitted a canonical fact');
|
||||||
|
}
|
||||||
|
assertLocalReconciliationReviewDecisionMatchesFact(
|
||||||
|
decision,
|
||||||
|
fact,
|
||||||
|
);
|
||||||
|
if (fact.domain !== 'secret_and_config') return;
|
||||||
|
if (decision.disposition !== 'manual_external') {
|
||||||
|
configurationError(
|
||||||
|
'Secret/Config facts require explicit external custody review',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fact.database === 'legacy' &&
|
||||||
|
fact.factKind === 'table' &&
|
||||||
|
fact.tableName === 'Envs'
|
||||||
|
) {
|
||||||
|
if (envSeen) {
|
||||||
|
configurationError('legacy Envs authority is ambiguous');
|
||||||
|
}
|
||||||
|
envSeen = true;
|
||||||
|
envDisposition = 'manual_external';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fact.database === 'legacy' &&
|
||||||
|
fact.factKind === 'table' &&
|
||||||
|
fact.tableName === 'Configs'
|
||||||
|
) {
|
||||||
|
unadaptedLegacyConfigCount += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (opened === null) {
|
||||||
|
configurationError('manual-required SQLite topology cannot be adapted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
reviewed.evidence.fileDigest !==
|
||||||
|
terminal.review.authorization.decisionFileDigest ||
|
||||||
|
reviewed.evidence.decisionCount !==
|
||||||
|
terminal.review.authorization.decisionCount
|
||||||
|
) {
|
||||||
|
configurationError('signed review authority drifted');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
tableDisposition: envDisposition,
|
||||||
|
unadaptedLegacyConfigCount,
|
||||||
|
decisionFileDigest: reviewed.evidence.fileDigest,
|
||||||
|
confirmDecisionFileIdentity: reviewed.confirmIdentity,
|
||||||
|
planTerminal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxPlanBytes(profile: 'edge' | 'standalone'): number {
|
||||||
|
return profile === 'edge'
|
||||||
|
? MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES
|
||||||
|
: MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishPlan(
|
||||||
|
selected: Readonly<SecretConfigPaths>,
|
||||||
|
command: Readonly<LocalReconciliationSecretConfigPlanCommand>,
|
||||||
|
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
|
head: Readonly<LocalCutoverInstanceHead>,
|
||||||
|
authority: Readonly<SecretConfigReviewAuthority>,
|
||||||
|
dependencies: LocalReconciliationSecretConfigPlanDependencies,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
let createdStage = false;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(
|
||||||
|
selected.planStage,
|
||||||
|
fs.constants.O_CREAT |
|
||||||
|
fs.constants.O_EXCL |
|
||||||
|
fs.constants.O_WRONLY |
|
||||||
|
(fs.constants.O_NOFOLLOW ?? 0),
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
createdStage = true;
|
||||||
|
fs.fchmodSync(descriptor, 0o600);
|
||||||
|
const header: Omit<
|
||||||
|
LocalReconciliationSecretConfigPlanHeader,
|
||||||
|
'headerDigest'
|
||||||
|
> = Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
kind: 'qinglong3-local-reconciliation-secret-config-plan-header',
|
||||||
|
secretConfigId: command.request.secretConfigId,
|
||||||
|
applicationId: command.request.applicationId,
|
||||||
|
applicationPlanDigest: terminal.plan.applicationPlanDigest,
|
||||||
|
reviewDigest: terminal.review.review.reviewDigest,
|
||||||
|
reviewAuthorizationDigest:
|
||||||
|
terminal.review.authorization.authorizationDigest,
|
||||||
|
reviewDecisionSetDigest: terminal.review.authorization.decisionSetDigest,
|
||||||
|
reviewDecisionFileDigest: authority.decisionFileDigest,
|
||||||
|
bundleDigest: authority.planTerminal.bundle.receipt.bundleDigest,
|
||||||
|
bundleFingerprintDigest: authority.planTerminal.bundle.fingerprintDigest,
|
||||||
|
profile: terminal.plan.profile,
|
||||||
|
projectId: command.request.projectId,
|
||||||
|
tableDisposition: authority.tableDisposition,
|
||||||
|
unadaptedLegacyConfigCount: authority.unadaptedLegacyConfigCount,
|
||||||
|
preparedHeadDigest: head.headDigest,
|
||||||
|
preparedAtMs: command.request.preparedAtMs,
|
||||||
|
});
|
||||||
|
const generated = withLocalReconciliationSealedDatabase(
|
||||||
|
authority.planTerminal.bundle,
|
||||||
|
'target',
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
(target) =>
|
||||||
|
withLocalReconciliationSealedDatabase(
|
||||||
|
authority.planTerminal.bundle,
|
||||||
|
'legacy',
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
(legacy) =>
|
||||||
|
writeLocalReconciliationSecretConfigPlan({
|
||||||
|
descriptor: descriptor!,
|
||||||
|
maxBytes: maxPlanBytes(terminal.plan.profile),
|
||||||
|
header,
|
||||||
|
legacy,
|
||||||
|
target,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (generated === null || generated === undefined) {
|
||||||
|
configurationError('manual-required SQLite topology cannot be planned');
|
||||||
|
}
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
descriptor = undefined;
|
||||||
|
authority.confirmDecisionFileIdentity();
|
||||||
|
const receipt = buildLocalReconciliationSecretConfigPlanReceipt(
|
||||||
|
generated.header,
|
||||||
|
generated.footer,
|
||||||
|
generated.fileBytes,
|
||||||
|
generated.fileDigest,
|
||||||
|
);
|
||||||
|
if (fs.existsSync(selected.plan)) {
|
||||||
|
validatePlanFile(selected.plan, receipt, uid, [0o600]);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
fs.linkSync(selected.planStage, selected.plan);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||||
|
validatePlanFile(selected.plan, receipt, uid, [0o600]);
|
||||||
|
}
|
||||||
|
syncPublishedDirectory(selected.root);
|
||||||
|
}
|
||||||
|
fs.unlinkSync(selected.planStage);
|
||||||
|
createdStage = false;
|
||||||
|
syncPublishedDirectory(selected.staging);
|
||||||
|
return receipt;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
return configurationError('plan cannot be published', error);
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||||
|
if (createdStage) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(selected.planStage);
|
||||||
|
} catch {
|
||||||
|
// A complete stage remains recoverable; a partial stage fails closed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sealFile(filePath: string, uid: number): void {
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
try {
|
||||||
|
const before = fs.lstatSync(filePath, { bigint: true });
|
||||||
|
if (
|
||||||
|
!before.isFile() ||
|
||||||
|
before.isSymbolicLink() ||
|
||||||
|
Number(before.uid) !== uid ||
|
||||||
|
![0o600, 0o400].includes(Number(before.mode) & 0o777) ||
|
||||||
|
before.nlink !== 1n
|
||||||
|
) {
|
||||||
|
configurationError('terminal file cannot be sealed');
|
||||||
|
}
|
||||||
|
descriptor = fs.openSync(
|
||||||
|
filePath,
|
||||||
|
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||||
|
);
|
||||||
|
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||||
|
if (
|
||||||
|
opened.dev !== before.dev ||
|
||||||
|
opened.ino !== before.ino ||
|
||||||
|
opened.size !== before.size
|
||||||
|
) {
|
||||||
|
configurationError('terminal file changed while sealing');
|
||||||
|
}
|
||||||
|
if ((Number(opened.mode) & 0o777) !== 0o400) {
|
||||||
|
fs.fchmodSync(descriptor, 0o400);
|
||||||
|
}
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sealDirectory(directory: string, uid: number): void {
|
||||||
|
const mode = validateDirectory(
|
||||||
|
directory,
|
||||||
|
uid,
|
||||||
|
[0o700, 0o500],
|
||||||
|
'terminal directory',
|
||||||
|
);
|
||||||
|
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
||||||
|
try {
|
||||||
|
if (mode !== 0o500) fs.fchmodSync(descriptor, 0o500);
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sealTerminal(selected: Readonly<SecretConfigPaths>, uid: number): void {
|
||||||
|
if (fs.readdirSync(selected.staging).length !== 0) {
|
||||||
|
configurationError('staging must be empty before terminal seal');
|
||||||
|
}
|
||||||
|
sealFile(selected.plan, uid);
|
||||||
|
sealFile(selected.receipt, uid);
|
||||||
|
sealDirectory(selected.staging, uid);
|
||||||
|
sealDirectory(selected.root, uid);
|
||||||
|
validateCatalog(selected, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceHead(
|
||||||
|
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
|
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalCutoverInstanceHead> {
|
||||||
|
return advanceLocalCutoverInstanceHead(
|
||||||
|
{
|
||||||
|
options: {
|
||||||
|
deploymentRoot: terminal.intent.command.options.deploymentRoot,
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
cutoverId: terminal.intent.cutoverId,
|
||||||
|
profile: terminal.intent.profile,
|
||||||
|
instanceId: terminal.intent.instanceId,
|
||||||
|
expectedActivationDigest: terminal.intent.activationDigest,
|
||||||
|
requestedAtMs: receipt.preparedAtMs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
uid,
|
||||||
|
'reconciliation_secret_config_planned',
|
||||||
|
terminal.intent.generation,
|
||||||
|
receipt.secretConfigPlanDigest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(
|
||||||
|
operation: LocalReconciliationSecretConfigPlanResult['operation'],
|
||||||
|
status: LocalReconciliationSecretConfigPlanResult['status'],
|
||||||
|
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||||
|
head: Readonly<LocalCutoverInstanceHead>,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigPlanResult> {
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation,
|
||||||
|
status,
|
||||||
|
state: 'reconciliation_secret_config_planned',
|
||||||
|
secretConfigId: receipt.secretConfigId,
|
||||||
|
secretConfigPlanDigest: receipt.secretConfigPlanDigest,
|
||||||
|
outcome: receipt.outcome,
|
||||||
|
rowCount: receipt.rowCount,
|
||||||
|
eligibleBindingCount: receipt.eligibleBindingCount,
|
||||||
|
eligiblePreservationCount: receipt.eligiblePreservationCount,
|
||||||
|
targetConflictCount: receipt.targetConflictCount,
|
||||||
|
adoptedLegacyTaskCount: receipt.adoptedLegacyTaskCount,
|
||||||
|
unadaptedLegacyConfigCount: receipt.unadaptedLegacyConfigCount,
|
||||||
|
instanceHeadDigest: head.headDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTerminalBinding(
|
||||||
|
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||||
|
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
|
secretConfigId: string,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
receipt.secretConfigId !== secretConfigId ||
|
||||||
|
receipt.applicationId !== terminal.plan.applicationId ||
|
||||||
|
receipt.applicationPlanDigest !== terminal.plan.applicationPlanDigest
|
||||||
|
) {
|
||||||
|
configurationError('terminal plan binding drifted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigTerminal {
|
||||||
|
readonly receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>;
|
||||||
|
readonly planPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLocalReconciliationSecretConfigTerminal(
|
||||||
|
secretConfigRoot: string,
|
||||||
|
secretConfigId: string,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigTerminal> {
|
||||||
|
const selected = secretConfigPaths(secretConfigRoot, secretConfigId);
|
||||||
|
validateDirectory(selected.root, uid, [0o500], 'Secret/Config plan root');
|
||||||
|
validateDirectory(selected.staging, uid, [0o500], 'Secret/Config staging');
|
||||||
|
validateCatalog(selected, true);
|
||||||
|
const receipt = readReceipt(selected.receipt, uid, [0o400]);
|
||||||
|
if (receipt.secretConfigId !== secretConfigId) {
|
||||||
|
configurationError('Secret/Config terminal identity drifted');
|
||||||
|
}
|
||||||
|
validatePlanFile(selected.plan, receipt, uid, [0o400]);
|
||||||
|
return Object.freeze({ receipt, planPath: selected.plan });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function planLocalReconciliationSecretConfig(
|
||||||
|
value: unknown,
|
||||||
|
dependencies: LocalReconciliationSecretConfigPlanDependencies = {},
|
||||||
|
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
||||||
|
const command = normalizeLocalReconciliationSecretConfigPlanCommand(value);
|
||||||
|
const identity = currentIdentity();
|
||||||
|
for (const [directory, label] of [
|
||||||
|
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||||
|
[command.options.applicationRoot, 'applicationRoot'],
|
||||||
|
[command.options.secretConfigRoot, 'secretConfigRoot'],
|
||||||
|
] as const) {
|
||||||
|
validatePrivateDirectory(directory, identity.uid, label);
|
||||||
|
}
|
||||||
|
const terminal = await readLocalReconciliationApplicationTerminal(
|
||||||
|
command.options.applicationRoot,
|
||||||
|
command.request.applicationId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
const selected = secretConfigPaths(
|
||||||
|
command.options.secretConfigRoot,
|
||||||
|
command.request.secretConfigId,
|
||||||
|
);
|
||||||
|
if (fs.existsSync(selected.receipt)) {
|
||||||
|
validateDirectory(
|
||||||
|
selected.root,
|
||||||
|
identity.uid,
|
||||||
|
[0o700, 0o500],
|
||||||
|
'Secret/Config plan root',
|
||||||
|
);
|
||||||
|
validateDirectory(
|
||||||
|
selected.staging,
|
||||||
|
identity.uid,
|
||||||
|
[0o700, 0o500],
|
||||||
|
'Secret/Config staging',
|
||||||
|
);
|
||||||
|
validateCatalog(selected, false);
|
||||||
|
const receipt = readReceipt(selected.receipt, identity.uid, [0o600, 0o400]);
|
||||||
|
validateTerminalBinding(
|
||||||
|
receipt,
|
||||||
|
terminal,
|
||||||
|
command.request.secretConfigId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
receipt.applicationPlanDigest !==
|
||||||
|
command.request.expectedApplicationPlanDigest ||
|
||||||
|
receipt.preparedHeadDigest !== command.request.expectedHeadDigest ||
|
||||||
|
receipt.preparedAtMs !== command.request.preparedAtMs
|
||||||
|
) {
|
||||||
|
configurationError('terminal plan is not an exact command replay');
|
||||||
|
}
|
||||||
|
validatePlanFile(selected.plan, receipt, identity.uid, [0o600, 0o400]);
|
||||||
|
let head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
terminal.intent.instanceId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
const existing = head.state === 'reconciliation_secret_config_planned';
|
||||||
|
if (!existing) validateApplicationBinding(command, terminal, head);
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
head.sourceRecordDigest !== receipt.secretConfigPlanDigest
|
||||||
|
) {
|
||||||
|
configurationError('terminal plan head digest drifted');
|
||||||
|
}
|
||||||
|
sealTerminal(selected, identity.uid);
|
||||||
|
dependencies.afterTerminalSealed?.();
|
||||||
|
head = existing ? head : advanceHead(terminal, receipt, identity.uid);
|
||||||
|
dependencies.afterHeadAdvanced?.();
|
||||||
|
return result(
|
||||||
|
command.operation,
|
||||||
|
existing ? 'existing' : 'prepared',
|
||||||
|
receipt,
|
||||||
|
head,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
terminal.intent.instanceId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
validateApplicationBinding(command, terminal, head);
|
||||||
|
const authority = reviewAuthority(
|
||||||
|
command,
|
||||||
|
terminal,
|
||||||
|
dependencies,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
ensurePrivateDirectory(
|
||||||
|
selected.root,
|
||||||
|
identity.uid,
|
||||||
|
'secretConfigPlanDirectory',
|
||||||
|
);
|
||||||
|
ensurePrivateDirectory(
|
||||||
|
selected.staging,
|
||||||
|
identity.uid,
|
||||||
|
'secretConfigPlanStaging',
|
||||||
|
);
|
||||||
|
validateCatalog(selected, false);
|
||||||
|
const receipt = publishPlan(
|
||||||
|
selected,
|
||||||
|
command,
|
||||||
|
terminal,
|
||||||
|
head,
|
||||||
|
authority,
|
||||||
|
dependencies,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
dependencies.afterPlanPublished?.();
|
||||||
|
authority.confirmDecisionFileIdentity();
|
||||||
|
publishExactFile(
|
||||||
|
selected.receipt,
|
||||||
|
receiptContents(receipt),
|
||||||
|
0o600,
|
||||||
|
identity.uid,
|
||||||
|
'reconciliation secret config receipt',
|
||||||
|
);
|
||||||
|
dependencies.afterReceiptPublished?.();
|
||||||
|
sealTerminal(selected, identity.uid);
|
||||||
|
dependencies.afterTerminalSealed?.();
|
||||||
|
const advanced = advanceHead(terminal, receipt, identity.uid);
|
||||||
|
dependencies.afterHeadAdvanced?.();
|
||||||
|
return result(command.operation, 'prepared', receipt, advanced);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLocalReconciliationSecretConfigPlan(
|
||||||
|
value: unknown,
|
||||||
|
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
||||||
|
const command = normalizeLocalReconciliationSecretConfigVerifyCommand(value);
|
||||||
|
const identity = currentIdentity();
|
||||||
|
for (const [directory, label] of [
|
||||||
|
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||||
|
[command.options.applicationRoot, 'applicationRoot'],
|
||||||
|
[command.options.secretConfigRoot, 'secretConfigRoot'],
|
||||||
|
] as const) {
|
||||||
|
validatePrivateDirectory(directory, identity.uid, label);
|
||||||
|
}
|
||||||
|
const selected = secretConfigPaths(
|
||||||
|
command.options.secretConfigRoot,
|
||||||
|
command.request.secretConfigId,
|
||||||
|
);
|
||||||
|
validateDirectory(selected.root, identity.uid, [0o500], 'Secret/Config plan root');
|
||||||
|
validateDirectory(selected.staging, identity.uid, [0o500], 'Secret/Config staging');
|
||||||
|
validateCatalog(selected, true);
|
||||||
|
const receipt = readReceipt(selected.receipt, identity.uid, [0o400]);
|
||||||
|
if (
|
||||||
|
receipt.secretConfigPlanDigest !==
|
||||||
|
command.request.expectedSecretConfigPlanDigest
|
||||||
|
) {
|
||||||
|
configurationError('expected Secret/Config plan digest drifted');
|
||||||
|
}
|
||||||
|
const terminal = await readLocalReconciliationApplicationTerminal(
|
||||||
|
command.options.applicationRoot,
|
||||||
|
receipt.applicationId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
validateTerminalBinding(receipt, terminal, command.request.secretConfigId);
|
||||||
|
validatePlanFile(selected.plan, receipt, identity.uid, [0o400]);
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
terminal.intent.instanceId,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
head.state !== 'reconciliation_secret_config_planned' ||
|
||||||
|
head.sourceRecordDigest !== receipt.secretConfigPlanDigest
|
||||||
|
) {
|
||||||
|
configurationError('Secret/Config plan is detached from the instance head');
|
||||||
|
}
|
||||||
|
return result(command.operation, 'verified', receipt, head);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planLocalReconciliationSecretConfigCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
dependencies: LocalReconciliationSecretConfigPlanDependencies = {},
|
||||||
|
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
||||||
|
return planLocalReconciliationSecretConfig(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyLocalReconciliationSecretConfigPlanCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
||||||
|
return verifyLocalReconciliationSecretConfigPlan(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
);
|
||||||
|
}
|
||||||
+184
-3
@@ -24,6 +24,8 @@ 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}$/;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
const MAX_LINE_BYTES = 64 * 1024;
|
const MAX_LINE_BYTES = 64 * 1024;
|
||||||
const HASH_BUFFER_BYTES = 64 * 1024;
|
const HASH_BUFFER_BYTES = 64 * 1024;
|
||||||
|
const MAX_EDGE_AUTOMATION_ADOPTION_RECORDS = 128;
|
||||||
|
const MAX_STANDALONE_AUTOMATION_ADOPTION_RECORDS = 512;
|
||||||
export const MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES =
|
export const MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES =
|
||||||
8 * 1024 * 1024;
|
8 * 1024 * 1024;
|
||||||
export const MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES =
|
export const MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES =
|
||||||
@@ -43,7 +45,8 @@ export interface LocalReconciliationSecretConfigPlanHeader {
|
|||||||
readonly bundleFingerprintDigest: string;
|
readonly bundleFingerprintDigest: string;
|
||||||
readonly profile: 'edge' | 'standalone';
|
readonly profile: 'edge' | 'standalone';
|
||||||
readonly projectId: string;
|
readonly projectId: string;
|
||||||
readonly tableDisposition: 'adopt_legacy' | 'retain_both';
|
readonly tableDisposition: 'absent' | 'manual_external';
|
||||||
|
readonly unadaptedLegacyConfigCount: number;
|
||||||
readonly preparedHeadDigest: string;
|
readonly preparedHeadDigest: string;
|
||||||
readonly preparedAtMs: number;
|
readonly preparedAtMs: number;
|
||||||
readonly headerDigest: string;
|
readonly headerDigest: string;
|
||||||
@@ -99,6 +102,9 @@ export interface LocalReconciliationSecretConfigPlanSummary {
|
|||||||
readonly eligibleBindingCount: number;
|
readonly eligibleBindingCount: number;
|
||||||
readonly eligiblePreservationCount: number;
|
readonly eligiblePreservationCount: number;
|
||||||
readonly targetConflictCount: number;
|
readonly targetConflictCount: number;
|
||||||
|
readonly automationAdoptionRecordCount: number;
|
||||||
|
readonly adoptedLegacyTaskCount: number;
|
||||||
|
readonly unadaptedLegacyConfigCount: number;
|
||||||
readonly outcome: 'ready' | 'manual_required' | 'no_effect';
|
readonly outcome: 'ready' | 'manual_required' | 'no_effect';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +116,7 @@ export interface LocalReconciliationSecretConfigPlanFooter
|
|||||||
readonly legacyInventoryDigest: string;
|
readonly legacyInventoryDigest: string;
|
||||||
readonly rowSetDigest: string;
|
readonly rowSetDigest: string;
|
||||||
readonly candidateSetDigest: string;
|
readonly candidateSetDigest: string;
|
||||||
|
readonly automationAdoptionSetDigest: string;
|
||||||
readonly secretConfigPlanDigest: string;
|
readonly secretConfigPlanDigest: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +132,7 @@ export interface LocalReconciliationSecretConfigPlanReceipt
|
|||||||
readonly legacyInventoryDigest: string;
|
readonly legacyInventoryDigest: string;
|
||||||
readonly rowSetDigest: string;
|
readonly rowSetDigest: string;
|
||||||
readonly candidateSetDigest: string;
|
readonly candidateSetDigest: string;
|
||||||
|
readonly automationAdoptionSetDigest: string;
|
||||||
readonly secretConfigPlanDigest: string;
|
readonly secretConfigPlanDigest: string;
|
||||||
readonly planFileBytes: number;
|
readonly planFileBytes: number;
|
||||||
readonly planFileDigest: string;
|
readonly planFileDigest: string;
|
||||||
@@ -200,6 +208,154 @@ function bytesDigest(value: unknown, length: number, label: string): string {
|
|||||||
return createHash('sha256').update(value).digest('hex');
|
return createHash('sha256').update(value).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adoptionText(
|
||||||
|
row: Readonly<Record<string, unknown>>,
|
||||||
|
key: string,
|
||||||
|
pattern?: RegExp,
|
||||||
|
): string {
|
||||||
|
const value = row[key];
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
value.length < 1 ||
|
||||||
|
value.length > 128 ||
|
||||||
|
(pattern !== undefined && !pattern.test(value))
|
||||||
|
) {
|
||||||
|
fail(`target Automation adoption ${key} drifted`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adoptionCount(
|
||||||
|
row: Readonly<Record<string, unknown>>,
|
||||||
|
key: string,
|
||||||
|
maximum: number,
|
||||||
|
): number {
|
||||||
|
const value = row[key];
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
(value as number) < 0 ||
|
||||||
|
(value as number) > maximum
|
||||||
|
) {
|
||||||
|
fail(`target Automation adoption ${key} drifted`);
|
||||||
|
}
|
||||||
|
return value as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetAutomationAdoptionProjection(
|
||||||
|
target: DatabaseSync,
|
||||||
|
projectId: string,
|
||||||
|
profile: 'edge' | 'standalone',
|
||||||
|
): Readonly<{
|
||||||
|
recordCount: number;
|
||||||
|
adoptedTaskCount: number;
|
||||||
|
setDigest: string;
|
||||||
|
}> {
|
||||||
|
const maximumRecords =
|
||||||
|
profile === 'edge'
|
||||||
|
? MAX_EDGE_AUTOMATION_ADOPTION_RECORDS
|
||||||
|
: MAX_STANDALONE_AUTOMATION_ADOPTION_RECORDS;
|
||||||
|
const hash = createHash('sha256').update(
|
||||||
|
'qinglong3.local-reconciliation-secret-config-automation-adoption-set.v1\0',
|
||||||
|
);
|
||||||
|
let recordCount = 0;
|
||||||
|
let adoptedTaskCount = 0;
|
||||||
|
try {
|
||||||
|
const rows = target
|
||||||
|
.prepare(
|
||||||
|
`SELECT "mutation_id" AS "mutationId",
|
||||||
|
"decision_id" AS "decisionId",
|
||||||
|
"plan_digest" AS "planDigest",
|
||||||
|
"inventory_digest" AS "inventoryDigest",
|
||||||
|
"decision_digest" AS "decisionDigest",
|
||||||
|
"receipt_digest" AS "receiptDigest",
|
||||||
|
"authorization_file_digest" AS "authorizationFileDigest",
|
||||||
|
"publication_digest" AS "publicationDigest",
|
||||||
|
"row_count" AS "rowCount",
|
||||||
|
"adopted_task_count" AS "adoptedTaskCount",
|
||||||
|
"adopted_trigger_count" AS "adoptedTriggerCount",
|
||||||
|
"skipped_count" AS "skippedCount",
|
||||||
|
"audit_event_id" AS "auditEventId",
|
||||||
|
"created_at_ms" AS "createdAtMs"
|
||||||
|
FROM "QingLong3LegacyAdoptions"
|
||||||
|
WHERE "project_id" = ?
|
||||||
|
ORDER BY "created_at_ms" ASC, "mutation_id" ASC`,
|
||||||
|
)
|
||||||
|
.iterate(projectId) as Iterable<Readonly<Record<string, unknown>>>;
|
||||||
|
for (const row of rows) {
|
||||||
|
recordCount += 1;
|
||||||
|
if (recordCount > maximumRecords) {
|
||||||
|
fail('target Automation adoption projection exceeds profile budget');
|
||||||
|
}
|
||||||
|
const rowCount = adoptionCount(row, 'rowCount', 100_000);
|
||||||
|
const selectedAdoptedTaskCount = adoptionCount(
|
||||||
|
row,
|
||||||
|
'adoptedTaskCount',
|
||||||
|
rowCount,
|
||||||
|
);
|
||||||
|
const skippedCount = adoptionCount(row, 'skippedCount', rowCount);
|
||||||
|
if (selectedAdoptedTaskCount + skippedCount !== rowCount) {
|
||||||
|
fail('target Automation adoption row accounting drifted');
|
||||||
|
}
|
||||||
|
adoptedTaskCount += selectedAdoptedTaskCount;
|
||||||
|
if (!Number.isSafeInteger(adoptedTaskCount)) {
|
||||||
|
fail('target Automation adoption task count overflowed');
|
||||||
|
}
|
||||||
|
const payload = Object.freeze({
|
||||||
|
mutationId: adoptionText(row, 'mutationId', UUID_V4_PATTERN),
|
||||||
|
decisionId: adoptionText(
|
||||||
|
row,
|
||||||
|
'decisionId',
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
),
|
||||||
|
planDigest: adoptionText(row, 'planDigest', DIGEST_PATTERN),
|
||||||
|
inventoryDigest: adoptionText(
|
||||||
|
row,
|
||||||
|
'inventoryDigest',
|
||||||
|
DIGEST_PATTERN,
|
||||||
|
),
|
||||||
|
decisionDigest: adoptionText(row, 'decisionDigest', DIGEST_PATTERN),
|
||||||
|
receiptDigest: adoptionText(row, 'receiptDigest', DIGEST_PATTERN),
|
||||||
|
authorizationFileDigest: adoptionText(
|
||||||
|
row,
|
||||||
|
'authorizationFileDigest',
|
||||||
|
DIGEST_PATTERN,
|
||||||
|
),
|
||||||
|
publicationDigest: adoptionText(
|
||||||
|
row,
|
||||||
|
'publicationDigest',
|
||||||
|
DIGEST_PATTERN,
|
||||||
|
),
|
||||||
|
rowCount,
|
||||||
|
adoptedTaskCount: selectedAdoptedTaskCount,
|
||||||
|
adoptedTriggerCount: adoptionCount(
|
||||||
|
row,
|
||||||
|
'adoptedTriggerCount',
|
||||||
|
500_000,
|
||||||
|
),
|
||||||
|
skippedCount,
|
||||||
|
auditEventId: adoptionText(row, 'auditEventId', UUID_V4_PATTERN),
|
||||||
|
createdAtMs: adoptionCount(
|
||||||
|
row,
|
||||||
|
'createdAtMs',
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (payload.auditEventId !== payload.mutationId) {
|
||||||
|
fail('target Automation adoption audit binding drifted');
|
||||||
|
}
|
||||||
|
hash.update('\0').update(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
return fail('target Automation adoption projection is unavailable', error);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
recordCount,
|
||||||
|
adoptedTaskCount,
|
||||||
|
setDigest: hash.digest('hex'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function targetSecret(
|
function targetSecret(
|
||||||
target: DatabaseSync,
|
target: DatabaseSync,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
@@ -402,6 +558,11 @@ export function writeLocalReconciliationSecretConfigPlan(
|
|||||||
append(candidate, 'candidate');
|
append(candidate, 'candidate');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const automationAdoption = targetAutomationAdoptionProjection(
|
||||||
|
options.target,
|
||||||
|
header.projectId,
|
||||||
|
header.profile,
|
||||||
|
);
|
||||||
const summary: LocalReconciliationSecretConfigPlanSummary = Object.freeze({
|
const summary: LocalReconciliationSecretConfigPlanSummary = Object.freeze({
|
||||||
tableState: inventory.tableState,
|
tableState: inventory.tableState,
|
||||||
rowCount: inventory.rowCount,
|
rowCount: inventory.rowCount,
|
||||||
@@ -415,10 +576,17 @@ export function writeLocalReconciliationSecretConfigPlan(
|
|||||||
eligibleBindingCount,
|
eligibleBindingCount,
|
||||||
eligiblePreservationCount,
|
eligiblePreservationCount,
|
||||||
targetConflictCount,
|
targetConflictCount,
|
||||||
|
automationAdoptionRecordCount: automationAdoption.recordCount,
|
||||||
|
adoptedLegacyTaskCount: automationAdoption.adoptedTaskCount,
|
||||||
|
unadaptedLegacyConfigCount: header.unadaptedLegacyConfigCount,
|
||||||
outcome:
|
outcome:
|
||||||
inventory.tableState === 'absent' || inventory.rowCount === 0
|
(inventory.tableState === 'absent' || inventory.rowCount === 0) &&
|
||||||
|
header.unadaptedLegacyConfigCount === 0
|
||||||
? ('no_effect' as const)
|
? ('no_effect' as const)
|
||||||
: !inventory.mutationReady || targetConflictCount > 0
|
: !inventory.mutationReady ||
|
||||||
|
targetConflictCount > 0 ||
|
||||||
|
header.unadaptedLegacyConfigCount > 0 ||
|
||||||
|
(eligibleBindingCount > 0 && automationAdoption.adoptedTaskCount < 1)
|
||||||
? ('manual_required' as const)
|
? ('manual_required' as const)
|
||||||
: ('ready' as const),
|
: ('ready' as const),
|
||||||
});
|
});
|
||||||
@@ -430,6 +598,7 @@ export function writeLocalReconciliationSecretConfigPlan(
|
|||||||
legacyInventoryDigest: inventory.inventoryDigest,
|
legacyInventoryDigest: inventory.inventoryDigest,
|
||||||
rowSetDigest: rowHash.digest('hex'),
|
rowSetDigest: rowHash.digest('hex'),
|
||||||
candidateSetDigest: candidateHash.digest('hex'),
|
candidateSetDigest: candidateHash.digest('hex'),
|
||||||
|
automationAdoptionSetDigest: automationAdoption.setDigest,
|
||||||
});
|
});
|
||||||
const footer = Object.freeze({
|
const footer = Object.freeze({
|
||||||
...footerPayload,
|
...footerPayload,
|
||||||
@@ -464,6 +633,7 @@ export function buildLocalReconciliationSecretConfigPlanReceipt(
|
|||||||
legacyInventoryDigest: footer.legacyInventoryDigest,
|
legacyInventoryDigest: footer.legacyInventoryDigest,
|
||||||
rowSetDigest: footer.rowSetDigest,
|
rowSetDigest: footer.rowSetDigest,
|
||||||
candidateSetDigest: footer.candidateSetDigest,
|
candidateSetDigest: footer.candidateSetDigest,
|
||||||
|
automationAdoptionSetDigest: footer.automationAdoptionSetDigest,
|
||||||
secretConfigPlanDigest: footer.secretConfigPlanDigest,
|
secretConfigPlanDigest: footer.secretConfigPlanDigest,
|
||||||
planFileBytes,
|
planFileBytes,
|
||||||
planFileDigest,
|
planFileDigest,
|
||||||
@@ -479,6 +649,9 @@ export function buildLocalReconciliationSecretConfigPlanReceipt(
|
|||||||
eligibleBindingCount: footer.eligibleBindingCount,
|
eligibleBindingCount: footer.eligibleBindingCount,
|
||||||
eligiblePreservationCount: footer.eligiblePreservationCount,
|
eligiblePreservationCount: footer.eligiblePreservationCount,
|
||||||
targetConflictCount: footer.targetConflictCount,
|
targetConflictCount: footer.targetConflictCount,
|
||||||
|
automationAdoptionRecordCount: footer.automationAdoptionRecordCount,
|
||||||
|
adoptedLegacyTaskCount: footer.adoptedLegacyTaskCount,
|
||||||
|
unadaptedLegacyConfigCount: footer.unadaptedLegacyConfigCount,
|
||||||
outcome: footer.outcome,
|
outcome: footer.outcome,
|
||||||
preparedAtMs: header.preparedAtMs,
|
preparedAtMs: header.preparedAtMs,
|
||||||
});
|
});
|
||||||
@@ -493,8 +666,11 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
|
|||||||
[
|
[
|
||||||
'activeGroupCount',
|
'activeGroupCount',
|
||||||
'activeRowCount',
|
'activeRowCount',
|
||||||
|
'adoptedLegacyTaskCount',
|
||||||
'applicationId',
|
'applicationId',
|
||||||
'applicationPlanDigest',
|
'applicationPlanDigest',
|
||||||
|
'automationAdoptionRecordCount',
|
||||||
|
'automationAdoptionSetDigest',
|
||||||
'bindingReadyCount',
|
'bindingReadyCount',
|
||||||
'candidateSetDigest',
|
'candidateSetDigest',
|
||||||
'disabledRowCount',
|
'disabledRowCount',
|
||||||
@@ -519,6 +695,7 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
|
|||||||
'state',
|
'state',
|
||||||
'tableState',
|
'tableState',
|
||||||
'targetConflictCount',
|
'targetConflictCount',
|
||||||
|
'unadaptedLegacyConfigCount',
|
||||||
],
|
],
|
||||||
'receipt',
|
'receipt',
|
||||||
);
|
);
|
||||||
@@ -537,6 +714,7 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
|
|||||||
receipt.legacyInventoryDigest,
|
receipt.legacyInventoryDigest,
|
||||||
receipt.rowSetDigest,
|
receipt.rowSetDigest,
|
||||||
receipt.candidateSetDigest,
|
receipt.candidateSetDigest,
|
||||||
|
receipt.automationAdoptionSetDigest,
|
||||||
receipt.secretConfigPlanDigest,
|
receipt.secretConfigPlanDigest,
|
||||||
receipt.planFileDigest,
|
receipt.planFileDigest,
|
||||||
receiptDigest,
|
receiptDigest,
|
||||||
@@ -556,6 +734,9 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
|
|||||||
receipt.eligibleBindingCount,
|
receipt.eligibleBindingCount,
|
||||||
receipt.eligiblePreservationCount,
|
receipt.eligiblePreservationCount,
|
||||||
receipt.targetConflictCount,
|
receipt.targetConflictCount,
|
||||||
|
receipt.automationAdoptionRecordCount,
|
||||||
|
receipt.adoptedLegacyTaskCount,
|
||||||
|
receipt.unadaptedLegacyConfigCount,
|
||||||
receipt.planFileBytes,
|
receipt.planFileBytes,
|
||||||
receipt.preparedAtMs,
|
receipt.preparedAtMs,
|
||||||
].every((count) => Number.isSafeInteger(count) && (count as number) >= 0) ||
|
].every((count) => Number.isSafeInteger(count) && (count as number) >= 0) ||
|
||||||
|
|||||||
+5
-1
@@ -771,7 +771,11 @@ function replayResult(
|
|||||||
head.state === 'reconciliation_application_planned' ||
|
head.state === 'reconciliation_application_planned' ||
|
||||||
head.state === 'reconciliation_automation_planned' ||
|
head.state === 'reconciliation_automation_planned' ||
|
||||||
head.state === 'reconciliation_automation_decision_prepared' ||
|
head.state === 'reconciliation_automation_decision_prepared' ||
|
||||||
head.state === 'reconciliation_automation_reviewed');
|
head.state === 'reconciliation_automation_reviewed' ||
|
||||||
|
head.state === 'reconciliation_automation_apply_prepared' ||
|
||||||
|
head.state === 'reconciliation_automation_applied' ||
|
||||||
|
head.state === 'reconciliation_automation_rolled_back' ||
|
||||||
|
head.state === 'reconciliation_secret_config_planned');
|
||||||
const completionRestartPendingHead =
|
const completionRestartPendingHead =
|
||||||
(record.state === 'target_active' || record.state === 'manual_required') &&
|
(record.state === 'target_active' || record.state === 'manual_required') &&
|
||||||
completionFence !== undefined &&
|
completionFence !== undefined &&
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const {
|
|||||||
readLocalReconciliationAutomationDecisionTerminal,
|
readLocalReconciliationAutomationDecisionTerminal,
|
||||||
rollbackLocalReconciliationAutomationApply,
|
rollbackLocalReconciliationAutomationApply,
|
||||||
planLocalReconciliationAutomation,
|
planLocalReconciliationAutomation,
|
||||||
|
planLocalReconciliationSecretConfig,
|
||||||
prepareLocalReconciliationPlan,
|
prepareLocalReconciliationPlan,
|
||||||
prepareLocalReconciliationReview,
|
prepareLocalReconciliationReview,
|
||||||
verifyLocalReconciliationCapture,
|
verifyLocalReconciliationCapture,
|
||||||
@@ -29,6 +30,7 @@ const {
|
|||||||
verifyLocalReconciliationAutomationDecision,
|
verifyLocalReconciliationAutomationDecision,
|
||||||
verifyLocalReconciliationAutomationApply,
|
verifyLocalReconciliationAutomationApply,
|
||||||
verifyLocalReconciliationAutomationPlan,
|
verifyLocalReconciliationAutomationPlan,
|
||||||
|
verifyLocalReconciliationSecretConfigPlan,
|
||||||
verifyLocalReconciliationCompletion,
|
verifyLocalReconciliationCompletion,
|
||||||
verifyLocalReconciliationPlan,
|
verifyLocalReconciliationPlan,
|
||||||
verifyLocalReconciliationReview,
|
verifyLocalReconciliationReview,
|
||||||
@@ -791,6 +793,58 @@ function automationReadyDatabaseInitializer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function secretConfigDatabaseInitializer({
|
||||||
|
active = false,
|
||||||
|
configs = false,
|
||||||
|
} = {}) {
|
||||||
|
return ({ legacySourcePath, recoveryPath, targetDatabasePath }) => {
|
||||||
|
const legacy = new DatabaseSync(legacySourcePath);
|
||||||
|
legacy.exec(`
|
||||||
|
CREATE TABLE "Envs" (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
value TEXT,
|
||||||
|
status INTEGER,
|
||||||
|
position REAL,
|
||||||
|
"isPinned" INTEGER,
|
||||||
|
"createdAt" TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO "Envs" VALUES (
|
||||||
|
1,
|
||||||
|
'${active ? 'ACTIVE_TOKEN' : 'DISABLED_TOKEN'}',
|
||||||
|
'private-secret-value',
|
||||||
|
${active ? 0 : 1},
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
'2026-01-01'
|
||||||
|
);
|
||||||
|
${
|
||||||
|
configs
|
||||||
|
? 'CREATE TABLE "Configs" (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO "Configs" VALUES (1, \'private-config-value\');'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
legacy.close();
|
||||||
|
fs.chmodSync(legacySourcePath, 0o600);
|
||||||
|
fs.copyFileSync(legacySourcePath, recoveryPath);
|
||||||
|
fs.chmodSync(recoveryPath, 0o600);
|
||||||
|
|
||||||
|
const migration = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
`require('@qinglong/local-sqlite/migration')
|
||||||
|
.migrateLocalSqlitePath({ databasePath: process.argv[1], profile: 'edge' })
|
||||||
|
.catch((error) => { console.error(error); process.exitCode = 1; });`,
|
||||||
|
targetDatabasePath,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8', cwd: path.join(__dirname, '..') },
|
||||||
|
);
|
||||||
|
assert.equal(migration.status, 0, migration.stderr);
|
||||||
|
fs.chmodSync(targetDatabasePath, 0o600);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mutateAutomationTarget({ targetDatabasePath }, occupied = false) {
|
function mutateAutomationTarget({ targetDatabasePath }, occupied = false) {
|
||||||
const target = new DatabaseSync(targetDatabasePath);
|
const target = new DatabaseSync(targetDatabasePath);
|
||||||
if (occupied) {
|
if (occupied) {
|
||||||
@@ -1167,6 +1221,69 @@ function applicationCommitCommand(state, prepared) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function secretConfigPlanFixture(t, options = {}) {
|
||||||
|
const suffix = options.suffix ?? 'plan';
|
||||||
|
const state = await reviewedApplicationFixture(t, {
|
||||||
|
planId:
|
||||||
|
options.planId ?? '00000000-0000-4000-8000-000000000421',
|
||||||
|
reviewId:
|
||||||
|
options.reviewId ?? '00000000-0000-4000-8000-000000000422',
|
||||||
|
applicationId:
|
||||||
|
options.applicationId ?? '00000000-0000-4000-8000-000000000423',
|
||||||
|
reviewSuffix: `secret-config-${suffix}`,
|
||||||
|
createDefaultSidecars: false,
|
||||||
|
initializeDatabases: secretConfigDatabaseInitializer({
|
||||||
|
active: options.active === true,
|
||||||
|
configs: options.configs === true,
|
||||||
|
}),
|
||||||
|
mutateTarget({ targetDatabasePath }) {
|
||||||
|
const target = new DatabaseSync(targetDatabasePath);
|
||||||
|
target.exec('PRAGMA user_version=1');
|
||||||
|
target.close();
|
||||||
|
return Object.freeze({});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const preparedApplication = await prepareLocalReconciliationApplication(
|
||||||
|
state.prepareApplicationCommand,
|
||||||
|
);
|
||||||
|
const application = await commitLocalReconciliationApplication(
|
||||||
|
applicationCommitCommand(state, preparedApplication),
|
||||||
|
);
|
||||||
|
const secretConfigRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
`secret-config-plan-${suffix}`,
|
||||||
|
);
|
||||||
|
fs.mkdirSync(secretConfigRoot, { mode: 0o700 });
|
||||||
|
const secretConfigId =
|
||||||
|
options.secretConfigId ?? '00000000-0000-4000-8000-000000000424';
|
||||||
|
const command = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.plan',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.deploymentRoot,
|
||||||
|
applicationRoot: state.applicationRoot,
|
||||||
|
secretConfigRoot,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
secretConfigId,
|
||||||
|
applicationId: application.applicationId,
|
||||||
|
expectedApplicationPlanDigest: application.applicationPlanDigest,
|
||||||
|
expectedHeadDigest: application.instanceHeadDigest,
|
||||||
|
decisionFilePath: state.reviewFile.filePath,
|
||||||
|
projectId: 'default',
|
||||||
|
preparedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
application,
|
||||||
|
secretConfigRoot,
|
||||||
|
secretConfigId,
|
||||||
|
secretConfigCommand: command,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function plannedAutomationFixture(t, options = {}) {
|
async function plannedAutomationFixture(t, options = {}) {
|
||||||
const suffix = options.suffix ?? 'decision';
|
const suffix = options.suffix ?? 'decision';
|
||||||
const state = await reviewedApplicationFixture(t, {
|
const state = await reviewedApplicationFixture(t, {
|
||||||
@@ -3197,6 +3314,186 @@ test('completion fence seals all eight no-effect domains before target restart',
|
|||||||
assert.equal(activeHead.generation, 2);
|
assert.equal(activeHead.generation, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Secret/Config plan publishes, seals, verifies and stays content-free', async (t) => {
|
||||||
|
const state = await secretConfigPlanFixture(t, { suffix: 'terminal' });
|
||||||
|
const opened = [];
|
||||||
|
const closed = [];
|
||||||
|
const planned = await planLocalReconciliationSecretConfig(
|
||||||
|
state.secretConfigCommand,
|
||||||
|
{
|
||||||
|
beforeDatabaseOpen(kind, mode, cacheKiB) {
|
||||||
|
opened.push({ kind, mode, cacheKiB });
|
||||||
|
},
|
||||||
|
afterDatabaseClose(kind) {
|
||||||
|
closed.push(kind);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(planned.status, 'prepared');
|
||||||
|
assert.equal(planned.state, 'reconciliation_secret_config_planned');
|
||||||
|
assert.equal(planned.outcome, 'ready');
|
||||||
|
assert.equal(planned.rowCount, 1);
|
||||||
|
assert.equal(planned.eligibleBindingCount, 0);
|
||||||
|
assert.equal(planned.eligiblePreservationCount, 1);
|
||||||
|
assert.equal(planned.adoptedLegacyTaskCount, 0);
|
||||||
|
assert.deepEqual(opened, [
|
||||||
|
{ kind: 'legacy', mode: 'main_only_immutable', cacheKiB: 2048 },
|
||||||
|
{ kind: 'target', mode: 'main_only_immutable', cacheKiB: 2048 },
|
||||||
|
{ kind: 'target', mode: 'main_only_immutable', cacheKiB: 2048 },
|
||||||
|
{ kind: 'legacy', mode: 'main_only_immutable', cacheKiB: 2048 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(closed, ['legacy', 'target', 'legacy', 'target']);
|
||||||
|
|
||||||
|
const root = path.join(state.secretConfigRoot, state.secretConfigId);
|
||||||
|
assert.deepEqual(fs.readdirSync(root).sort(), [
|
||||||
|
'plan.ndjson',
|
||||||
|
'receipt.json',
|
||||||
|
'staging',
|
||||||
|
]);
|
||||||
|
assert.equal(fs.statSync(root).mode & 0o777, 0o500);
|
||||||
|
assert.equal(fs.statSync(path.join(root, 'staging')).mode & 0o777, 0o500);
|
||||||
|
assert.equal(fs.statSync(path.join(root, 'plan.ndjson')).mode & 0o777, 0o400);
|
||||||
|
assert.equal(fs.statSync(path.join(root, 'receipt.json')).mode & 0o777, 0o400);
|
||||||
|
const serialized = fs.readFileSync(path.join(root, 'plan.ndjson'), 'utf8');
|
||||||
|
for (const privateValue of [
|
||||||
|
'DISABLED_TOKEN',
|
||||||
|
'private-secret-value',
|
||||||
|
state.targetDatabasePath,
|
||||||
|
state.reviewFile.filePath,
|
||||||
|
]) {
|
||||||
|
assert.equal(serialized.includes(privateValue), false);
|
||||||
|
}
|
||||||
|
const receipt = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(root, 'receipt.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
assert.equal(receipt.unadaptedLegacyConfigCount, 0);
|
||||||
|
assert.match(receipt.automationAdoptionSetDigest, /^[0-9a-f]{64}$/);
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
state.deploymentRoot,
|
||||||
|
state.captureCommand.request.instanceId,
|
||||||
|
state.uid,
|
||||||
|
);
|
||||||
|
assert.equal(head.state, 'reconciliation_secret_config_planned');
|
||||||
|
assert.equal(head.sourceRecordDigest, planned.secretConfigPlanDigest);
|
||||||
|
|
||||||
|
const verifyCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.verify',
|
||||||
|
options: state.secretConfigCommand.options,
|
||||||
|
request: {
|
||||||
|
secretConfigId: state.secretConfigId,
|
||||||
|
expectedSecretConfigPlanDigest: planned.secretConfigPlanDigest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const verified = await verifyLocalReconciliationSecretConfigPlan(
|
||||||
|
verifyCommand,
|
||||||
|
);
|
||||||
|
assert.equal(verified.status, 'verified');
|
||||||
|
assert.equal(
|
||||||
|
(await planLocalReconciliationSecretConfig(state.secretConfigCommand))
|
||||||
|
.status,
|
||||||
|
'existing',
|
||||||
|
);
|
||||||
|
|
||||||
|
const commandPath = path.join(
|
||||||
|
state.deploymentRoot,
|
||||||
|
'secret-config-verify-command.json',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
const cli = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||||
|
'reconciliation-secret-config-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.secretConfigRoot), false);
|
||||||
|
assert.equal(cli.stdout.includes('DISABLED_TOKEN'), false);
|
||||||
|
assert.equal(cli.stderr, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Secret/Config plan recovers exact publication response loss windows', async (t) => {
|
||||||
|
for (const [hook, finalStatus] of [
|
||||||
|
['afterPlanPublished', 'prepared'],
|
||||||
|
['afterReceiptPublished', 'prepared'],
|
||||||
|
['afterTerminalSealed', 'prepared'],
|
||||||
|
['afterHeadAdvanced', 'existing'],
|
||||||
|
]) {
|
||||||
|
await t.test(hook, async (subtest) => {
|
||||||
|
const state = await secretConfigPlanFixture(subtest, { suffix: hook });
|
||||||
|
let fault = true;
|
||||||
|
await assert.rejects(
|
||||||
|
planLocalReconciliationSecretConfig(state.secretConfigCommand, {
|
||||||
|
[hook]() {
|
||||||
|
if (fault) {
|
||||||
|
fault = false;
|
||||||
|
throw new Error(`secret-config-${hook}-fault`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`secret-config-${hook}-fault`),
|
||||||
|
);
|
||||||
|
const recovered = await planLocalReconciliationSecretConfig(
|
||||||
|
state.secretConfigCommand,
|
||||||
|
);
|
||||||
|
assert.equal(recovered.status, finalStatus);
|
||||||
|
assert.equal(recovered.outcome, 'ready');
|
||||||
|
const verified = await verifyLocalReconciliationSecretConfigPlan({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.verify',
|
||||||
|
options: state.secretConfigCommand.options,
|
||||||
|
request: {
|
||||||
|
secretConfigId: state.secretConfigId,
|
||||||
|
expectedSecretConfigPlanDigest: recovered.secretConfigPlanDigest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(verified.status, 'verified');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Secret/Config plan keeps active Env and unknown Configs manual', async (t) => {
|
||||||
|
await t.test('active without adopted task', async (subtest) => {
|
||||||
|
const state = await secretConfigPlanFixture(subtest, {
|
||||||
|
suffix: 'active-manual',
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
const planned = await planLocalReconciliationSecretConfig(
|
||||||
|
state.secretConfigCommand,
|
||||||
|
);
|
||||||
|
assert.equal(planned.outcome, 'manual_required');
|
||||||
|
assert.equal(planned.eligibleBindingCount, 1);
|
||||||
|
assert.equal(planned.adoptedLegacyTaskCount, 0);
|
||||||
|
});
|
||||||
|
await t.test('historical Configs', async (subtest) => {
|
||||||
|
const state = await secretConfigPlanFixture(subtest, {
|
||||||
|
suffix: 'configs-manual',
|
||||||
|
configs: true,
|
||||||
|
});
|
||||||
|
const planned = await planLocalReconciliationSecretConfig(
|
||||||
|
state.secretConfigCommand,
|
||||||
|
);
|
||||||
|
assert.equal(planned.outcome, 'manual_required');
|
||||||
|
assert.equal(planned.unadaptedLegacyConfigCount, 1);
|
||||||
|
const serialized = fs.readFileSync(
|
||||||
|
path.join(
|
||||||
|
state.secretConfigRoot,
|
||||||
|
state.secretConfigId,
|
||||||
|
'plan.ndjson',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.equal(serialized.includes('private-config-value'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('completion fence retains automation rollback backup while other domains remain manual', async (t) => {
|
test('completion fence retains automation rollback backup while other domains remain manual', async (t) => {
|
||||||
const state = await appliedAutomationFixture(t, {
|
const state = await appliedAutomationFixture(t, {
|
||||||
suffix: 'completion-fence',
|
suffix: 'completion-fence',
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const HEADER = Object.freeze({
|
|||||||
bundleFingerprintDigest: '1'.repeat(64),
|
bundleFingerprintDigest: '1'.repeat(64),
|
||||||
profile: 'edge',
|
profile: 'edge',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
tableDisposition: 'adopt_legacy',
|
tableDisposition: 'manual_external',
|
||||||
|
unadaptedLegacyConfigCount: 0,
|
||||||
preparedHeadDigest: '2'.repeat(64),
|
preparedHeadDigest: '2'.repeat(64),
|
||||||
preparedAtMs: 1_780_000_000_000,
|
preparedAtMs: 1_780_000_000_000,
|
||||||
});
|
});
|
||||||
@@ -60,11 +61,60 @@ function databases() {
|
|||||||
created_at_ms INTEGER NOT NULL,
|
created_at_ms INTEGER NOT NULL,
|
||||||
PRIMARY KEY (project_id, secret_name, version)
|
PRIMARY KEY (project_id, secret_name, version)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE "QingLong3LegacyAdoptions" (
|
||||||
|
mutation_id TEXT PRIMARY KEY,
|
||||||
|
decision_id TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
plan_digest TEXT NOT NULL,
|
||||||
|
inventory_digest TEXT NOT NULL,
|
||||||
|
decision_digest TEXT NOT NULL,
|
||||||
|
receipt_digest TEXT NOT NULL,
|
||||||
|
authorization_file_digest TEXT NOT NULL,
|
||||||
|
publication_digest TEXT NOT NULL,
|
||||||
|
row_count INTEGER NOT NULL,
|
||||||
|
adopted_task_count INTEGER NOT NULL,
|
||||||
|
adopted_trigger_count INTEGER NOT NULL,
|
||||||
|
skipped_count INTEGER NOT NULL,
|
||||||
|
audit_event_id TEXT NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
return { legacy, target };
|
return { legacy, target };
|
||||||
}
|
}
|
||||||
|
|
||||||
function writePlan(t, legacy, target, maxBytes = 8 * 1024 * 1024) {
|
function insertAutomationAdoption(target, adoptedTaskCount = 1) {
|
||||||
|
const mutationId = '30000000-0000-4000-8000-000000000003';
|
||||||
|
target
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3LegacyAdoptions" VALUES
|
||||||
|
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
mutationId,
|
||||||
|
'019b0000-0000-7000-8000-000000000001',
|
||||||
|
HEADER.projectId,
|
||||||
|
'3'.repeat(64),
|
||||||
|
'4'.repeat(64),
|
||||||
|
'5'.repeat(64),
|
||||||
|
'6'.repeat(64),
|
||||||
|
'7'.repeat(64),
|
||||||
|
'8'.repeat(64),
|
||||||
|
adoptedTaskCount,
|
||||||
|
adoptedTaskCount,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
mutationId,
|
||||||
|
HEADER.preparedAtMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePlan(
|
||||||
|
t,
|
||||||
|
legacy,
|
||||||
|
target,
|
||||||
|
maxBytes = 8 * 1024 * 1024,
|
||||||
|
header = HEADER,
|
||||||
|
) {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-plan-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-plan-'));
|
||||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
const filePath = path.join(directory, 'plan.ndjson');
|
const filePath = path.join(directory, 'plan.ndjson');
|
||||||
@@ -74,7 +124,7 @@ function writePlan(t, legacy, target, maxBytes = 8 * 1024 * 1024) {
|
|||||||
result = writeLocalReconciliationSecretConfigPlan({
|
result = writeLocalReconciliationSecretConfigPlan({
|
||||||
descriptor,
|
descriptor,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
header: HEADER,
|
header,
|
||||||
legacy,
|
legacy,
|
||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
@@ -107,6 +157,7 @@ test('writes a content-free Env plan with separate active and disabled candidate
|
|||||||
(2, 'TOKEN', 'pinned-secret', 0, 1, 1, '2026-01-02'),
|
(2, 'TOKEN', 'pinned-secret', 0, 1, 1, '2026-01-02'),
|
||||||
(3, 'DISABLED_TOKEN', 'disabled-secret', 1, 0, 0, '2026-01-03');
|
(3, 'DISABLED_TOKEN', 'disabled-secret', 1, 0, 0, '2026-01-03');
|
||||||
`);
|
`);
|
||||||
|
insertAutomationAdoption(target);
|
||||||
|
|
||||||
const { result, records, serialized } = writePlan(t, legacy, target);
|
const { result, records, serialized } = writePlan(t, legacy, target);
|
||||||
assert.equal(result.footer.outcome, 'ready');
|
assert.equal(result.footer.outcome, 'ready');
|
||||||
@@ -114,6 +165,9 @@ test('writes a content-free Env plan with separate active and disabled candidate
|
|||||||
assert.equal(result.footer.eligibleBindingCount, 1);
|
assert.equal(result.footer.eligibleBindingCount, 1);
|
||||||
assert.equal(result.footer.eligiblePreservationCount, 1);
|
assert.equal(result.footer.eligiblePreservationCount, 1);
|
||||||
assert.equal(result.footer.targetConflictCount, 0);
|
assert.equal(result.footer.targetConflictCount, 0);
|
||||||
|
assert.equal(result.footer.automationAdoptionRecordCount, 1);
|
||||||
|
assert.equal(result.footer.adoptedLegacyTaskCount, 1);
|
||||||
|
assert.match(result.footer.automationAdoptionSetDigest, /^[0-9a-f]{64}$/);
|
||||||
const candidates = records.filter((record) =>
|
const candidates = records.filter((record) =>
|
||||||
record.kind.endsWith('-candidate'),
|
record.kind.endsWith('-candidate'),
|
||||||
);
|
);
|
||||||
@@ -234,6 +288,28 @@ test('makes absent Envs no-effect and malformed Env manual', (t) => {
|
|||||||
assert.equal(manual.serialized.includes('private-value'), false);
|
assert.equal(manual.serialized.includes('private-value'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps active Env and historical Configs manual without adoption authority', (t) => {
|
||||||
|
const { legacy, target } = databases();
|
||||||
|
t.after(() => legacy.close());
|
||||||
|
t.after(() => target.close());
|
||||||
|
legacy.exec(
|
||||||
|
`INSERT INTO "Envs" VALUES
|
||||||
|
(1, 'TOKEN', 'private-value', 0, 1, 0, '2026-01-01')`,
|
||||||
|
);
|
||||||
|
const withoutAdoption = writePlan(t, legacy, target);
|
||||||
|
assert.equal(withoutAdoption.result.footer.outcome, 'manual_required');
|
||||||
|
assert.equal(withoutAdoption.result.footer.adoptedLegacyTaskCount, 0);
|
||||||
|
assert.equal(withoutAdoption.serialized.includes('private-value'), false);
|
||||||
|
|
||||||
|
insertAutomationAdoption(target);
|
||||||
|
const withConfigs = writePlan(t, legacy, target, 8 * 1024 * 1024, {
|
||||||
|
...HEADER,
|
||||||
|
unadaptedLegacyConfigCount: 1,
|
||||||
|
});
|
||||||
|
assert.equal(withConfigs.result.footer.outcome, 'manual_required');
|
||||||
|
assert.equal(withConfigs.result.footer.unadaptedLegacyConfigCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('fails closed before exceeding the plan byte budget', (t) => {
|
test('fails closed before exceeding the plan byte budget', (t) => {
|
||||||
const { legacy, target } = databases();
|
const { legacy, target } = databases();
|
||||||
t.after(() => legacy.close());
|
t.after(() => legacy.close());
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 176,
|
sourceFiles: 178,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 175,
|
nestedSourceFiles: 177,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user