mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): complete local secret config apply lifecycle
This commit is contained in:
@@ -42,18 +42,25 @@
|
|||||||
Task/Trigger head、Plugin ownership 与每 Task Trigger 数量,然后同时写入加密 Secret、content-free audit、Task rev2、local dispatch、Trigger rev2、schedule
|
Task/Trigger head、Plugin ownership 与每 Task Trigger 数量,然后同时写入加密 Secret、content-free audit、Task rev2、local dispatch、Trigger rev2、schedule
|
||||||
和 receipt。item 使用 deferred parent FK 流式落库,最终 receipt 才关闭父引用,因此最多 100,000 Task/500,000 Trigger 不进入 JS 全集或 O(N×M) 扫描。
|
和 receipt。item 使用 deferred parent FK 流式落库,最终 receipt 才关闭父引用,因此最多 100,000 Task/500,000 Trigger 不进入 JS 全集或 O(N×M) 扫描。
|
||||||
commit response-loss exact replay 会重验 Secret envelope、Task/Trigger durable head 与 schedule;Secret 占用、provenance 缺项和提交前 authority 漂移均回滚全部 DML。
|
commit response-loss exact replay 会重验 Secret envelope、Task/Trigger durable head 与 schedule;Secret 占用、provenance 缺项和提交前 authority 漂移均回滚全部 DML。
|
||||||
|
第七切片在同一 Secret/Config application 子域补齐 Owner `prepared → apply → rollback`,仍不新增 package、dependency 或常驻进程。Owner 只持有私有
|
||||||
|
ciphertext-only `materials.ndjson`,单行上限 64 KiB,Edge/Standalone 总量上限 4/16 MiB,且 keyring 必须位于 deployment root 之外;intent 之前的孤儿
|
||||||
|
material 丢弃并重新生成,intent 之后只重放同一 ciphertext。编排先复验 stopped proof、同一 reviewer 的 5 分钟内 `local_console` 强认证并完成 write-before
|
||||||
|
SQLite backup,随后单向推进 `reconciliation_secret_config_reviewed → reconciliation_secret_config_apply_prepared → reconciliation_secret_config_applied`。
|
||||||
|
material、backup、prepared head、数据库 commit、receipt、applied head 与 seal 的 response-loss 均精确恢复;rollback 只从 applied 恢复 exact 写前 snapshot,
|
||||||
|
再覆盖 restore、rollback receipt/head/seal 的 response-loss 并推进 `reconciliation_secret_config_rolled_back`。ENOSPC、权限错误或 backup 漂移发生在 head/DML 前,
|
||||||
|
适用于小型路由设备;该一次性 Owner authority 不进入常驻 Application 制品。
|
||||||
全部 evidence 不含原 Env name/value、目标 ciphertext/key ID 或 row body。v52 Local SQLite 完整测试为 `247/247`,publisher 定向回归 `6/6`;fresh Edge
|
全部 evidence 不含原 Env name/value、目标 ciphertext/key ID 或 row body。v52 Local SQLite 完整测试为 `247/247`,publisher 定向回归 `6/6`;fresh Edge
|
||||||
readiness 为 contract v52、104 migrations、89 required tables、SQLite 3.53.3、`DELETE` journal。Local Owner 为
|
readiness 为 contract v52、104 migrations、89 required tables、SQLite 3.53.3、`DELETE` journal。Local Admin 为 `96/96`,Local Owner 为
|
||||||
`296 total / 289 pass / 7 conditional skip / 0 fail`;18-package clean build 与逐包顺序测试单次退出 0,完整 backend 为
|
`297 total / 290 pass / 7 conditional skip / 0 fail`;18-package clean build 与逐包顺序测试单次退出 0,完整 backend 为
|
||||||
`1566 total / 1564 pass / 2 conditional skip / 0 fail`。package boundary、Cluster dependency/legacy boundary、122-module Edge import、本地镜像与
|
`1567 total / 1565 pass / 2 conditional skip / 0 fail`。package boundary、精确 Cluster dependency/legacy boundary、122-module Edge import、本地镜像与
|
||||||
`14/14` Local artifact audit 全部 compatible;基础 Edge/Standalone 为 `2,635,529 / 2,635,607 bytes`、323 files、58 loaded modules,距 4 MiB
|
`14/14` Local artifact audit 全部 compatible;基础 Edge/Standalone 为 `2,635,529 / 2,635,607 bytes`、323 files、58 loaded modules,距 4 MiB
|
||||||
上限仍分别保留 `1,558,775 / 1,558,697 bytes`,且闭包只有 Local SQLite、runtime-core 与 SemVer,没有 Cluster/PostgreSQL 依赖。
|
上限仍分别保留 `1,558,775 / 1,558,697 bytes`,且闭包只有 Local SQLite、runtime-core 与 SemVer,没有 Cluster/PostgreSQL 依赖。
|
||||||
Local SQLite 为 209 source / 208 nested / 1 root public export;Local Owner 保持 184/183,根目录仍只有一个 50 行 binary entry;workspace 仍为
|
Local SQLite 为 209 source / 208 nested / 1 root public export;Local Admin 为 49/48/1,Local Owner 为 188/187/1,根目录仍只有一个 50 行 binary entry;workspace 仍为
|
||||||
18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。本切片不改 PostgreSQL schema、连接、role、Pool、容器或 Kubernetes 拓扑,
|
18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。本切片不改 PostgreSQL schema、连接、role、Pool、容器或 Kubernetes 拓扑,
|
||||||
因而不重跑且不重新占有 PostgreSQL HA 证明;相邻已通过的 remote CI/HA 只作为基线。
|
因而不重跑且不重新占有 PostgreSQL HA 证明;相邻已通过的 remote CI/HA 只作为基线。
|
||||||
|
|
||||||
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,
|
||||||
不猜字段。后续切片必须完成 Owner prepared/apply/rollback、写前 backup、receipt/head/seal response-loss、completion 下一 schema 与备份回收。D-397 apply
|
不猜字段。后续切片必须完成 completion 下一 schema 与跨领域完成后的备份回收、真实 Edge 空间证据和 Cluster Secret provider live gate。D-397 apply
|
||||||
只声明 sealed source retained 且 `physicalErasureGuaranteed=false`;明文销毁必须在 restart/
|
只声明 sealed source retained 且 `physicalErasureGuaranteed=false`;明文销毁必须在 restart/
|
||||||
readiness、观察窗和 rollback retention 之后另行强认证。Cluster 必须使用 PostgreSQL SERIALIZABLE ledger、外部 KMS/Secret provider 与 HA evidence,
|
readiness、观察窗和 rollback retention 之后另行强认证。Cluster 必须使用 PostgreSQL SERIALIZABLE ledger、外部 KMS/Secret provider 与 HA evidence,
|
||||||
不复用 Local SQLite/POSIX authority,也不得把明文写入 PostgreSQL、ConfigMap、Pod env 或 Job command。
|
不复用 Local SQLite/POSIX authority,也不得把明文写入 PostgreSQL、ConfigMap、Pod env 或 Job command。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0491:有界 Secret/Config Reconciliation 与任务环境绑定
|
# ADR-0491:有界 Secret/Config Reconciliation 与任务环境绑定
|
||||||
|
|
||||||
- 状态:Proposed(D-397 已实现 Legacy Env inspection、私有有界 row plan、durable plan publication、独立 signed decision、逐项 Automation adoption provenance 与 Local SQLite 原子 application publisher;Owner prepared/apply/rollback 编排尚未完成)
|
- 状态:Proposed(D-397 已实现 Legacy Env inspection、私有有界 row plan、durable plan publication、独立 signed decision、逐项 Automation adoption provenance、Local SQLite 原子 application publisher 与 Owner prepared/apply/rollback 编排;跨领域 completion schema、真实 Edge 空间证据和 Cluster Secret provider live gate 尚未完成)
|
||||||
- 日期: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
|
||||||
@@ -72,9 +72,9 @@ durable publisher 固定写入 `<secretConfigRoot>/<secretConfigId>/{plan.ndjson
|
|||||||
|
|
||||||
独立决策使用私有 NDJSON decision file,每个候选必须按 ordinal/digest 精确选择 `apply_active_binding/reviewed_active_binding`、`preserve_disabled/reviewed_disabled_preservation` 或 `skip/operator_excluded|target_conflict|security_review_required`。任何 `skip` 都把终态 outcome 降为 `manual_required`,不能被 application 当成部分成功;`no_effect` 不需要决策,manual/conflict plan 也不能通过强认证升级。签名授权使用与 D-391 review 相同的强认证 User,认证年龄最多 5 分钟、授权生命期最多 30 分钟,并以独立 HMAC domain 绑定 decision、Secret/Config plan、candidate set、application、preparation、prepared head、sealed bundle、reviewer 与时间。Edge/Standalone decision/authorization 文件分别限制为 1 MiB/4 MiB,沿用 owner-only `0700/0600`、sealed `0500/0400`、no-replace 与 `fsync`。lineage 单向推进 `reconciliation_secret_config_planned → reconciliation_secret_config_decision_prepared → reconciliation_secret_config_reviewed`;prepare/commit 的全部 publication response-loss 窗口都精确重放且不重复认证,terminal verify 只读复算 sealed decision、authorization、receipt 与当前 reviewed head。
|
独立决策使用私有 NDJSON decision file,每个候选必须按 ordinal/digest 精确选择 `apply_active_binding/reviewed_active_binding`、`preserve_disabled/reviewed_disabled_preservation` 或 `skip/operator_excluded|target_conflict|security_review_required`。任何 `skip` 都把终态 outcome 降为 `manual_required`,不能被 application 当成部分成功;`no_effect` 不需要决策,manual/conflict plan 也不能通过强认证升级。签名授权使用与 D-391 review 相同的强认证 User,认证年龄最多 5 分钟、授权生命期最多 30 分钟,并以独立 HMAC domain 绑定 decision、Secret/Config plan、candidate set、application、preparation、prepared head、sealed bundle、reviewer 与时间。Edge/Standalone decision/authorization 文件分别限制为 1 MiB/4 MiB,沿用 owner-only `0700/0600`、sealed `0500/0400`、no-replace 与 `fsync`。lineage 单向推进 `reconciliation_secret_config_planned → reconciliation_secret_config_decision_prepared → reconciliation_secret_config_reviewed`;prepare/commit 的全部 publication response-loss 窗口都精确重放且不重复认证,terminal verify 只读复算 sealed decision、authorization、receipt 与当前 reviewed head。
|
||||||
|
|
||||||
### 4. 原子 application 必须同时完成 custody 与行为绑定
|
### 4. 原子 application 同时完成 custody 与行为绑定
|
||||||
|
|
||||||
后续 D-397 application 必须在一个 `BEGIN IMMEDIATE` 事务内完成:
|
D-397 application 已在一个 `BEGIN IMMEDIATE` 事务内完成:
|
||||||
|
|
||||||
1. 复验 Project/RoleBinding fence、signed decision、sealed source、target snapshot 与当前 instance head;
|
1. 复验 Project/RoleBinding fence、signed decision、sealed source、target snapshot 与当前 instance head;
|
||||||
2. 为每个 active effective Env 写入加密 Local Secret envelope、content-free `secret.create` audit 与 adoption item;
|
2. 为每个 active effective Env 写入加密 Local Secret envelope、content-free `secret.create` audit 与 adoption item;
|
||||||
@@ -85,7 +85,11 @@ durable publisher 固定写入 `<secretConfigRoot>/<secretConfigId>/{plan.ndjson
|
|||||||
|
|
||||||
任一 Secret、Task、Trigger、dispatch、audit、ledger 或 fence 冲突都回滚整个事务。禁止先提交 Secret 再逐任务修补,也禁止在现有 Task revision 上原地改 JSON。目标已有同名/同源 Secret、非 Legacy Task、Plugin-owned Task 或用户在 stopped window 中产生的 revision 都按冲突处理,不自动覆盖或重命名。
|
任一 Secret、Task、Trigger、dispatch、audit、ledger 或 fence 冲突都回滚整个事务。禁止先提交 Secret 再逐任务修补,也禁止在现有 Task revision 上原地改 JSON。目标已有同名/同源 Secret、非 Legacy Task、Plugin-owned Task 或用户在 stopped window 中产生的 revision 都按冲突处理,不自动覆盖或重命名。
|
||||||
|
|
||||||
由于该 adapter 执行 DML,它需要独立的 prepared/applied/rolled-back lineage 与写前 target backup;无 DML 的 Run History preservation 继续只绑定最新 head。跨领域 completion 后才可回收 Automation 与 Secret/Config 两份 rollback material。空间不足必须在 prepare 前失败,不得在低配设备上边写边赌。
|
Owner 编排保留在既有 `local-owner-cli/deployment/reconciliation/application/secret-and-config/application/` 子域,不新增 package、常驻进程或依赖。它只把密文写入私有 `materials.ndjson`,单行不超过 64 KiB,Edge/Standalone 文件分别不超过 4/16 MiB;POSIX keyring 必须位于 deployment root 之外。编排在推进 head 前重新证明 stopped state、同一 reviewer 的 `local_console` 强认证与最多 5 分钟认证年龄,并先创建、校验 write-before SQLite v52 backup。空间不足、权限错误或 backup 漂移均发生在 prepared head 与任何 DML 之前,不得在低配设备上边写边赌。
|
||||||
|
|
||||||
|
lineage 单向推进 `reconciliation_secret_config_reviewed → reconciliation_secret_config_apply_prepared → reconciliation_secret_config_applied`;回滚只允许从 applied 精确恢复写前 SQLite snapshot,再推进到 `reconciliation_secret_config_rolled_back`。material、backup、prepared head、数据库 commit、receipt、applied head、seal,以及 restore、rollback receipt/head/seal 的每个 response-loss 窗口都通过 immutable digest 与 durable target state 精确重放:intent 之前的孤儿密文会丢弃并重新生成,intent 之后只复用同一 ciphertext;数据库 commit 丢失响应时由 v52 publisher receipt 复验,不重复 DML。回滚保持原 SQLite 文件 identity 证明,任何 receipt/head/seal 漂移都失败关闭。
|
||||||
|
|
||||||
|
无 DML 的 Run History preservation 继续只绑定最新 head。跨领域 completion 后才可回收 Automation 与 Secret/Config 两份 rollback material。
|
||||||
|
|
||||||
### 5. preserve、destroy 与 completion
|
### 5. preserve、destroy 与 completion
|
||||||
|
|
||||||
@@ -137,8 +141,8 @@ 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 冲突、Automation adoption projection、no-effect/manual outcome、durable no-replace publication、terminal seal、head CAS、逐候选独立 signed decision、同一强认证 reviewer、decision/authorization byte bound、`skip → manual_required`、prepare/commit response-loss exact replay、只读 terminal verify、v51 逐 Task/Trigger adoption provenance,以及 v52 Local SQLite 原子 application publisher。v52 在一个 `BEGIN IMMEDIATE` 内复验 Project/RoleBinding、外部 authority、逐 Task/Trigger provenance、当前 head、Plugin ownership 与 Trigger 数量,流式写入加密 Secret、content-free audit、Task rev2、dispatch、Trigger rev2、schedule 和四类 application ledger;deferred parent FK 允许最多 100,000 Task/500,000 Trigger 逐项发布而不在 JS 堆保留全集。commit response-loss 通过 durable receipt exact replay,并重新验证 Secret envelope、Task/Trigger head 与 schedule;目标占用、provenance 缺项、提交前 authority 漂移均回滚全部 DML。
|
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、逐候选独立 signed decision、同一强认证 reviewer、decision/authorization byte bound、`skip → manual_required`、prepare/commit response-loss exact replay、只读 terminal verify、v51 逐 Task/Trigger adoption provenance、v52 Local SQLite 原子 application publisher,以及 Owner prepared/apply/rollback orchestration。v52 在一个 `BEGIN IMMEDIATE` 内复验 Project/RoleBinding、外部 authority、逐 Task/Trigger provenance、当前 head、Plugin ownership 与 Trigger 数量,流式写入加密 Secret、content-free audit、Task rev2、dispatch、Trigger rev2、schedule 和四类 application ledger;deferred parent FK 允许最多 100,000 Task/500,000 Trigger 逐项发布而不在 JS 堆保留全集。Owner 在写前固定 backup 与 stopped proof,以有界 ciphertext-only material 连接 reviewed decision 和 publisher,并覆盖 apply/rollback 全部 response-loss 窗口。commit response-loss 通过 durable receipt exact replay,并重新验证 Secret envelope、Task/Trigger head 与 schedule;目标占用、provenance 缺项、提交前 authority 漂移均回滚全部 DML,rollback 则恢复写前 snapshot。
|
||||||
|
|
||||||
本切片当前验证:Local SQLite `247/247`,其中 Secret/Config application publisher 定向回归 `6/6`;fresh Edge readiness 为 contract v52、104 migrations、89 required tables、SQLite 3.53.3、`DELETE` journal。Local Owner 为 `296 total / 289 pass / 7 conditional skip / 0 fail`;18-package clean build 与逐包顺序测试单次退出 0,完整 backend 为 `1566 total / 1564 pass / 2 conditional skip / 0 fail`。package boundary、Cluster dependency/legacy boundary、122-module Edge import、本地镜像与 `14/14` Local artifact audit 全部 compatible;基础 Edge/Standalone 为 `2,635,529 / 2,635,607 bytes`、323 files、58 loaded modules,且没有 Cluster/PostgreSQL 闭包。本切片不改 PostgreSQL schema、连接、role、Pool、容器或 Kubernetes 拓扑,因此不重跑且不重新占有 PostgreSQL HA 证明。
|
本切片当前验证:Local SQLite `247/247`,其中 Secret/Config application publisher 定向回归 `6/6`;fresh Edge readiness 为 contract v52、104 migrations、89 required tables、SQLite 3.53.3、`DELETE` journal。Local Admin 为 `96/96`,Local Owner 为 `297 total / 290 pass / 7 conditional skip / 0 fail`;18-package clean build 与逐包顺序测试单次退出 0,完整 backend 为 `1567 total / 1565 pass / 2 conditional skip / 0 fail`。package boundary、精确 Cluster dependency/legacy boundary、122-module Edge import、本地镜像与 `14/14` Local artifact audit 全部 compatible;Local Admin 为 49 source / 48 nested / 1 root export,Local Owner 为 188/187/1,workspace 仍为 18 packages 且没有单文件或浅层 package。基础 Edge/Standalone 为 `2,635,529 / 2,635,607 bytes`、323 files、58 loaded modules,且没有 Cluster/PostgreSQL 闭包。本切片不改 PostgreSQL schema、连接、role、Pool、容器或 Kubernetes 拓扑,因此不重跑且不重新占有 PostgreSQL HA 证明。
|
||||||
|
|
||||||
转为 Accepted 前仍必须完成:Owner prepared/apply/rollback 与写前 backup、application receipt/head/seal response-loss、completion schema 演进、真实 Edge 空间预算与 Cluster Secret provider live gate;本切片的 Local SQLite publisher 或 PostgreSQL HA 证据不得冒充 Owner 编排、外部密钥托管或跨领域 completion 已完成。
|
转为 Accepted 前仍必须完成:completion schema 演进及 completion 后 rollback material 回收、真实 Edge 空间预算证据与 Cluster Secret provider live gate;本切片的 Local Owner 编排或 PostgreSQL HA 证据不得冒充外部密钥托管或跨领域 completion 已完成。
|
||||||
|
|||||||
+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 + durable plan + signed decision) |
|
| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 local apply/rollback complete;completion/Cluster gate pending) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,11 @@
|
|||||||
"require": "./dist/legacy-adoption/secret-and-config/environmentInspection.js",
|
"require": "./dist/legacy-adoption/secret-and-config/environmentInspection.js",
|
||||||
"default": "./dist/legacy-adoption/secret-and-config/environmentInspection.js"
|
"default": "./dist/legacy-adoption/secret-and-config/environmentInspection.js"
|
||||||
},
|
},
|
||||||
|
"./reconciliation-secret-and-config-application": {
|
||||||
|
"types": "./dist/legacy-adoption/secret-and-config/reconciliationSecretConfigApplication.d.ts",
|
||||||
|
"require": "./dist/legacy-adoption/secret-and-config/reconciliationSecretConfigApplication.js",
|
||||||
|
"default": "./dist/legacy-adoption/secret-and-config/reconciliationSecretConfigApplication.js"
|
||||||
|
},
|
||||||
"./reconciliation-automation-decision": {
|
"./reconciliation-automation-decision": {
|
||||||
"types": "./dist/legacy-adoption/reconciliationAutomationDecision.d.ts",
|
"types": "./dist/legacy-adoption/reconciliationAutomationDecision.d.ts",
|
||||||
"require": "./dist/legacy-adoption/reconciliationAutomationDecision.js",
|
"require": "./dist/legacy-adoption/reconciliationAutomationDecision.js",
|
||||||
|
|||||||
+612
@@ -0,0 +1,612 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
import {
|
||||||
|
openLocalSqliteSecretConfigApplicationDatabase,
|
||||||
|
type LocalSecretConfigApplicationReceipt,
|
||||||
|
type LocalSecretConfigApplicationSecret,
|
||||||
|
} from '@qinglong/local-sqlite/secret-config-application';
|
||||||
|
import {
|
||||||
|
encryptLocalSecretEnvelope,
|
||||||
|
ownedLocalSecretKeyMaterial,
|
||||||
|
} from '@qinglong/local-secret';
|
||||||
|
import {
|
||||||
|
LOCAL_SECRET_ALGORITHM,
|
||||||
|
normalizeLocalSecretEnvelope,
|
||||||
|
type LocalSecretEnvelope,
|
||||||
|
type LocalSecretKeyProvider,
|
||||||
|
} from '@qinglong/runtime-core/local-secret';
|
||||||
|
import {
|
||||||
|
ProjectPolicyEngine,
|
||||||
|
ProjectPolicyUnavailableError,
|
||||||
|
} from '@qinglong/runtime-core/project-policy';
|
||||||
|
import {
|
||||||
|
normalizeSecurityPrincipal,
|
||||||
|
type SecurityPolicyDecision,
|
||||||
|
type SecurityPrincipal,
|
||||||
|
} from '@qinglong/runtime-core/security';
|
||||||
|
import {
|
||||||
|
normalizeSecurityAuditRecord,
|
||||||
|
type SecurityAuditRecord,
|
||||||
|
} from '@qinglong/runtime-core/security-audit';
|
||||||
|
|
||||||
|
import {
|
||||||
|
visitLegacyEnvironmentAdoption,
|
||||||
|
type LegacyEnvironmentCandidate,
|
||||||
|
} from './environmentInspection';
|
||||||
|
|
||||||
|
const DIGEST = /^[0-9a-f]{64}$/;
|
||||||
|
const UUID_V4 =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
|
const STRONG_USER_ASSURANCES = new Set(['local_console']);
|
||||||
|
|
||||||
|
export type ReconciliationSecretConfigDecisionDisposition =
|
||||||
|
| 'apply_active_binding'
|
||||||
|
| 'preserve_disabled'
|
||||||
|
| 'skip';
|
||||||
|
|
||||||
|
export interface ReconciliationSecretConfigDecision {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly kind: 'qinglong3-local-reconciliation-secret-config-decision';
|
||||||
|
readonly candidateOrdinal: number;
|
||||||
|
readonly candidateDigest: string;
|
||||||
|
readonly disposition: ReconciliationSecretConfigDecisionDisposition;
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconciliationSecretConfigRequirement {
|
||||||
|
readonly candidateOrdinal: number;
|
||||||
|
readonly candidateType: 'active_binding' | 'disabled_preservation';
|
||||||
|
readonly candidateDigest: string;
|
||||||
|
readonly sourceSetDigest: string;
|
||||||
|
readonly proposedSecretName: string;
|
||||||
|
readonly requirement:
|
||||||
|
| 'review_apply_binding'
|
||||||
|
| 'review_preserve_disabled'
|
||||||
|
| 'review_skip_conflict';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedReconciliationSecretConfigMaterial {
|
||||||
|
readonly ordinal: number;
|
||||||
|
readonly disposition: 'active_binding' | 'disabled_preservation';
|
||||||
|
readonly candidateDigest: string;
|
||||||
|
readonly sourceSetDigest: string;
|
||||||
|
readonly environmentName?: string;
|
||||||
|
readonly envelope: Readonly<LocalSecretEnvelope>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrepareReconciliationSecretConfigApplicationOptions {
|
||||||
|
readonly sourceClient: DatabaseSync;
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly mutationId: string;
|
||||||
|
readonly appliedAtMs: number;
|
||||||
|
readonly expectedLegacyInventoryDigest: string;
|
||||||
|
readonly decisions: readonly Readonly<ReconciliationSecretConfigDecision>[];
|
||||||
|
readonly openRequirements: () => Iterable<ReconciliationSecretConfigRequirement>;
|
||||||
|
readonly keyProvider: LocalSecretKeyProvider;
|
||||||
|
readonly visitMaterial: (
|
||||||
|
material: Readonly<PreparedReconciliationSecretConfigMaterial>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedReconciliationSecretConfigApplicationEvidence {
|
||||||
|
readonly legacyInventoryDigest: string;
|
||||||
|
readonly secretCount: number;
|
||||||
|
readonly activeBindingCount: number;
|
||||||
|
readonly disabledPreservationCount: number;
|
||||||
|
readonly materialSetDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyPreparedReconciliationSecretConfigApplicationOptions {
|
||||||
|
readonly databasePath: string;
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly mutationId: string;
|
||||||
|
readonly requestId: string;
|
||||||
|
readonly secretConfigPlanDigest: string;
|
||||||
|
readonly decisionDigest: string;
|
||||||
|
readonly candidateSetDigest: string;
|
||||||
|
readonly automationAdoptionSetDigest: string;
|
||||||
|
readonly materials: readonly Readonly<PreparedReconciliationSecretConfigMaterial>[];
|
||||||
|
readonly principal: Readonly<SecurityPrincipal>;
|
||||||
|
readonly appliedAtMs: number;
|
||||||
|
readonly authorizationAtMs?: number;
|
||||||
|
readonly busyTimeoutMs?: number;
|
||||||
|
readonly confirmAuthenticationAuthority: () => void | Promise<void>;
|
||||||
|
readonly confirmPreparedAuthority: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyPreparedReconciliationSecretConfigApplicationResult {
|
||||||
|
readonly status: 'inserted' | 'existing';
|
||||||
|
readonly receipt: Readonly<LocalSecretConfigApplicationReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReconciliationSecretConfigApplicationError extends Error {
|
||||||
|
readonly code = 'RECONCILIATION_SECRET_CONFIG_APPLICATION_INVALID';
|
||||||
|
|
||||||
|
constructor(message: string, readonly cause?: unknown) {
|
||||||
|
super(`Reconciliation Secret/Config application is invalid: ${message}`);
|
||||||
|
this.name = 'ReconciliationSecretConfigApplicationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReconciliationSecretConfigApplicationAuthenticationError extends Error {
|
||||||
|
readonly code =
|
||||||
|
'RECONCILIATION_SECRET_CONFIG_APPLICATION_AUTHENTICATION_REQUIRED';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('Reconciliation Secret/Config application requires its reviewer');
|
||||||
|
this.name = 'ReconciliationSecretConfigApplicationAuthenticationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReconciliationSecretConfigApplicationAuthorizationError extends Error {
|
||||||
|
readonly code = 'RECONCILIATION_SECRET_CONFIG_APPLICATION_FORBIDDEN';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('Reconciliation Secret/Config application is not authorized');
|
||||||
|
this.name = 'ReconciliationSecretConfigApplicationAuthorizationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReconciliationSecretConfigApplicationUnavailableError extends Error {
|
||||||
|
readonly code = 'RECONCILIATION_SECRET_CONFIG_APPLICATION_UNAVAILABLE';
|
||||||
|
|
||||||
|
constructor(readonly cause?: unknown) {
|
||||||
|
super('Reconciliation Secret/Config application is unavailable');
|
||||||
|
this.name = 'ReconciliationSecretConfigApplicationUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string, cause?: unknown): never {
|
||||||
|
throw new ReconciliationSecretConfigApplicationError(message, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(value: object, expected: readonly string[]): boolean {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const keys = [...expected].sort();
|
||||||
|
return (
|
||||||
|
actual.length === keys.length &&
|
||||||
|
actual.every((key, index) => key === keys[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deterministicMutationId(batch: string, identity: string): string {
|
||||||
|
const bytes = createHash('sha256')
|
||||||
|
.update('qinglong3.secret-config-application-material.v1\0')
|
||||||
|
.update(batch)
|
||||||
|
.update('\0')
|
||||||
|
.update(identity)
|
||||||
|
.digest()
|
||||||
|
.subarray(0, 16);
|
||||||
|
bytes[6] = ((bytes[6] as number) & 0x0f) | 0x40;
|
||||||
|
bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80;
|
||||||
|
const hex = bytes.toString('hex');
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iterator<T>(value: Iterable<T>, label: string): Iterator<T> {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
(typeof value !== 'object' && typeof value !== 'function') ||
|
||||||
|
typeof value[Symbol.iterator] !== 'function'
|
||||||
|
) {
|
||||||
|
return fail(`${label} is invalid`);
|
||||||
|
}
|
||||||
|
const selected = value[Symbol.iterator]();
|
||||||
|
if (!selected || typeof selected.next !== 'function') {
|
||||||
|
return fail(`${label} is invalid`);
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequirement(
|
||||||
|
value: ReconciliationSecretConfigRequirement,
|
||||||
|
): Readonly<ReconciliationSecretConfigRequirement> {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
!exact(value, [
|
||||||
|
'candidateDigest',
|
||||||
|
'candidateOrdinal',
|
||||||
|
'candidateType',
|
||||||
|
'proposedSecretName',
|
||||||
|
'requirement',
|
||||||
|
'sourceSetDigest',
|
||||||
|
]) ||
|
||||||
|
!Number.isSafeInteger(value.candidateOrdinal) ||
|
||||||
|
value.candidateOrdinal < 1 ||
|
||||||
|
(value.candidateType !== 'active_binding' &&
|
||||||
|
value.candidateType !== 'disabled_preservation') ||
|
||||||
|
!DIGEST.test(value.candidateDigest) ||
|
||||||
|
!DIGEST.test(value.sourceSetDigest) ||
|
||||||
|
typeof value.proposedSecretName !== 'string' ||
|
||||||
|
value.proposedSecretName.length < 1 ||
|
||||||
|
![
|
||||||
|
'review_apply_binding',
|
||||||
|
'review_preserve_disabled',
|
||||||
|
'review_skip_conflict',
|
||||||
|
].includes(value.requirement)
|
||||||
|
) {
|
||||||
|
return fail('plan requirement is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({ ...value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDecision(
|
||||||
|
value: ReconciliationSecretConfigDecision,
|
||||||
|
ordinal: number,
|
||||||
|
): Readonly<ReconciliationSecretConfigDecision> {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
!exact(value, [
|
||||||
|
'candidateDigest',
|
||||||
|
'candidateOrdinal',
|
||||||
|
'disposition',
|
||||||
|
'kind',
|
||||||
|
'reason',
|
||||||
|
'schemaVersion',
|
||||||
|
]) ||
|
||||||
|
value.schemaVersion !== 1 ||
|
||||||
|
value.kind !== 'qinglong3-local-reconciliation-secret-config-decision' ||
|
||||||
|
value.candidateOrdinal !== ordinal ||
|
||||||
|
!DIGEST.test(value.candidateDigest) ||
|
||||||
|
!['apply_active_binding', 'preserve_disabled', 'skip'].includes(
|
||||||
|
value.disposition,
|
||||||
|
) ||
|
||||||
|
typeof value.reason !== 'string'
|
||||||
|
) {
|
||||||
|
return fail('review decision is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({ ...value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDecision(
|
||||||
|
decision: Readonly<ReconciliationSecretConfigDecision>,
|
||||||
|
requirement: Readonly<ReconciliationSecretConfigRequirement>,
|
||||||
|
candidate: Readonly<LegacyEnvironmentCandidate>,
|
||||||
|
): void {
|
||||||
|
const expectedDisposition =
|
||||||
|
requirement.requirement === 'review_apply_binding'
|
||||||
|
? 'apply_active_binding'
|
||||||
|
: requirement.requirement === 'review_preserve_disabled'
|
||||||
|
? 'preserve_disabled'
|
||||||
|
: 'skip';
|
||||||
|
const sourceSetDigest =
|
||||||
|
candidate.kind === 'active_binding'
|
||||||
|
? candidate.sourceSetDigest
|
||||||
|
: candidate.sourceDigest;
|
||||||
|
if (
|
||||||
|
decision.candidateOrdinal !== requirement.candidateOrdinal ||
|
||||||
|
decision.candidateDigest !== requirement.candidateDigest ||
|
||||||
|
decision.candidateDigest !== candidate.candidateDigest ||
|
||||||
|
decision.disposition !== expectedDisposition ||
|
||||||
|
requirement.candidateType !== candidate.kind ||
|
||||||
|
requirement.sourceSetDigest !== sourceSetDigest
|
||||||
|
) {
|
||||||
|
fail('decision, plan and sealed candidate are detached');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePreparedReconciliationSecretConfigMaterial(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<PreparedReconciliationSecretConfigMaterial> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return fail('prepared material must be an object');
|
||||||
|
}
|
||||||
|
const selected = value as Record<string, unknown>;
|
||||||
|
const active = selected.disposition === 'active_binding';
|
||||||
|
if (
|
||||||
|
!exact(selected, [
|
||||||
|
'candidateDigest',
|
||||||
|
'disposition',
|
||||||
|
'envelope',
|
||||||
|
...(active ? ['environmentName'] : []),
|
||||||
|
'ordinal',
|
||||||
|
'sourceSetDigest',
|
||||||
|
]) ||
|
||||||
|
!Number.isSafeInteger(selected.ordinal) ||
|
||||||
|
(selected.ordinal as number) < 1 ||
|
||||||
|
(!active && selected.disposition !== 'disabled_preservation') ||
|
||||||
|
!DIGEST.test(selected.candidateDigest as string) ||
|
||||||
|
!DIGEST.test(selected.sourceSetDigest as string) ||
|
||||||
|
(active &&
|
||||||
|
(typeof selected.environmentName !== 'string' ||
|
||||||
|
selected.environmentName.length < 1))
|
||||||
|
) {
|
||||||
|
return fail('prepared material is invalid');
|
||||||
|
}
|
||||||
|
let envelope: Readonly<LocalSecretEnvelope>;
|
||||||
|
try {
|
||||||
|
envelope = normalizeLocalSecretEnvelope(
|
||||||
|
selected.envelope as LocalSecretEnvelope,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return fail('prepared material envelope is invalid', error);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
ordinal: selected.ordinal as number,
|
||||||
|
disposition: selected.disposition as
|
||||||
|
| 'active_binding'
|
||||||
|
| 'disabled_preservation',
|
||||||
|
candidateDigest: selected.candidateDigest as string,
|
||||||
|
sourceSetDigest: selected.sourceSetDigest as string,
|
||||||
|
...(active ? { environmentName: selected.environmentName as string } : {}),
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareReconciliationSecretConfigApplication(
|
||||||
|
options: Readonly<PrepareReconciliationSecretConfigApplicationOptions>,
|
||||||
|
): Promise<Readonly<PreparedReconciliationSecretConfigApplicationEvidence>> {
|
||||||
|
if (
|
||||||
|
!options ||
|
||||||
|
typeof options !== 'object' ||
|
||||||
|
(options.profile !== 'edge' && options.profile !== 'standalone') ||
|
||||||
|
!UUID_V4.test(options.mutationId) ||
|
||||||
|
!DIGEST.test(options.expectedLegacyInventoryDigest) ||
|
||||||
|
!Number.isSafeInteger(options.appliedAtMs) ||
|
||||||
|
options.appliedAtMs < 0 ||
|
||||||
|
!Array.isArray(options.decisions) ||
|
||||||
|
options.decisions.length < 1 ||
|
||||||
|
typeof options.openRequirements !== 'function' ||
|
||||||
|
typeof options.visitMaterial !== 'function' ||
|
||||||
|
!options.keyProvider ||
|
||||||
|
typeof options.keyProvider.active !== 'function'
|
||||||
|
) {
|
||||||
|
fail('preparation options are invalid');
|
||||||
|
}
|
||||||
|
const requirements = iterator(
|
||||||
|
options.openRequirements(),
|
||||||
|
'plan requirement stream',
|
||||||
|
);
|
||||||
|
const decisions = options.decisions.map((decision, index) =>
|
||||||
|
normalizeDecision(decision, index + 1),
|
||||||
|
);
|
||||||
|
if (decisions.some((decision) => decision.disposition === 'skip')) {
|
||||||
|
fail('manual-required decision cannot enter application');
|
||||||
|
}
|
||||||
|
const material = ownedLocalSecretKeyMaterial(
|
||||||
|
await options.keyProvider.active(),
|
||||||
|
);
|
||||||
|
const materialHash = createHash('sha256').update(
|
||||||
|
'qinglong3.reconciliation-secret-config-material-set.v1\0',
|
||||||
|
);
|
||||||
|
let secretCount = 0;
|
||||||
|
let activeBindingCount = 0;
|
||||||
|
let disabledPreservationCount = 0;
|
||||||
|
try {
|
||||||
|
const inventory = visitLegacyEnvironmentAdoption(options.sourceClient, {
|
||||||
|
profile: options.profile,
|
||||||
|
visitCandidate(candidate) {
|
||||||
|
const requirementNext = requirements.next();
|
||||||
|
if (requirementNext.done) fail('sealed source exceeds plan candidates');
|
||||||
|
const requirement = normalizeRequirement(requirementNext.value);
|
||||||
|
const decision = decisions[requirement.candidateOrdinal - 1];
|
||||||
|
if (!decision) fail('decision stream is shorter than its plan');
|
||||||
|
assertDecision(decision, requirement, candidate);
|
||||||
|
if (decision.disposition === 'skip') return;
|
||||||
|
secretCount += 1;
|
||||||
|
const secretMutationId = deterministicMutationId(
|
||||||
|
options.mutationId,
|
||||||
|
`${requirement.candidateOrdinal}\0${requirement.candidateDigest}`,
|
||||||
|
);
|
||||||
|
const envelope = encryptLocalSecretEnvelope(
|
||||||
|
{
|
||||||
|
projectId: options.projectId,
|
||||||
|
name: requirement.proposedSecretName,
|
||||||
|
version: 1,
|
||||||
|
mutationId: secretMutationId,
|
||||||
|
keyId: material.keyId,
|
||||||
|
algorithm: LOCAL_SECRET_ALGORITHM,
|
||||||
|
createdAtMs: options.appliedAtMs,
|
||||||
|
},
|
||||||
|
candidate.value,
|
||||||
|
material.key,
|
||||||
|
);
|
||||||
|
const prepared = Object.freeze({
|
||||||
|
ordinal: secretCount,
|
||||||
|
disposition: candidate.kind,
|
||||||
|
candidateDigest: candidate.candidateDigest,
|
||||||
|
sourceSetDigest: requirement.sourceSetDigest,
|
||||||
|
...(candidate.kind === 'active_binding'
|
||||||
|
? { environmentName: candidate.environmentName }
|
||||||
|
: {}),
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
materialHash.update('\0').update(JSON.stringify(prepared));
|
||||||
|
options.visitMaterial(prepared);
|
||||||
|
if (candidate.kind === 'active_binding') activeBindingCount += 1;
|
||||||
|
else disabledPreservationCount += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const extraRequirement = requirements.next();
|
||||||
|
if (!extraRequirement.done || decisions.length !== secretCount) {
|
||||||
|
fail('decision, plan and sealed candidate counts differ');
|
||||||
|
}
|
||||||
|
if (inventory.inventoryDigest !== options.expectedLegacyInventoryDigest) {
|
||||||
|
fail('sealed legacy inventory drifted');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
legacyInventoryDigest: inventory.inventoryDigest,
|
||||||
|
secretCount,
|
||||||
|
activeBindingCount,
|
||||||
|
disabledPreservationCount,
|
||||||
|
materialSetDigest: materialHash.digest('hex'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
material.key.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function strongPrincipal(
|
||||||
|
value: Readonly<SecurityPrincipal>,
|
||||||
|
atMs: number,
|
||||||
|
): Readonly<SecurityPrincipal> {
|
||||||
|
let principal: Readonly<SecurityPrincipal>;
|
||||||
|
try {
|
||||||
|
principal = normalizeSecurityPrincipal(value, atMs);
|
||||||
|
} catch {
|
||||||
|
throw new ReconciliationSecretConfigApplicationAuthenticationError();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
principal.subject.type !== 'user' ||
|
||||||
|
!STRONG_USER_ASSURANCES.has(principal.assurance)
|
||||||
|
) {
|
||||||
|
throw new ReconciliationSecretConfigApplicationAuthenticationError();
|
||||||
|
}
|
||||||
|
return principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function audit(
|
||||||
|
eventId: string,
|
||||||
|
operationId: 'secret-config.apply' | 'secret.create',
|
||||||
|
options: Readonly<ApplyPreparedReconciliationSecretConfigApplicationOptions>,
|
||||||
|
principal: Readonly<SecurityPrincipal>,
|
||||||
|
decision: Readonly<SecurityPolicyDecision>,
|
||||||
|
): Readonly<SecurityAuditRecord> {
|
||||||
|
return normalizeSecurityAuditRecord({
|
||||||
|
eventId,
|
||||||
|
requestId: options.requestId,
|
||||||
|
operationId,
|
||||||
|
projectId: options.projectId,
|
||||||
|
subject: principal.subject,
|
||||||
|
authenticationId: principal.authenticationId,
|
||||||
|
outcome: 'allowed',
|
||||||
|
reasons: decision.reasons,
|
||||||
|
fence: decision.fence,
|
||||||
|
occurredAtMs: options.appliedAtMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyPreparedReconciliationSecretConfigApplication(
|
||||||
|
options: Readonly<ApplyPreparedReconciliationSecretConfigApplicationOptions>,
|
||||||
|
): Promise<Readonly<ApplyPreparedReconciliationSecretConfigApplicationResult>> {
|
||||||
|
if (
|
||||||
|
!options ||
|
||||||
|
typeof options !== 'object' ||
|
||||||
|
!UUID_V4.test(options.mutationId) ||
|
||||||
|
!REQUEST_ID.test(options.requestId) ||
|
||||||
|
(options.profile !== 'edge' && options.profile !== 'standalone') ||
|
||||||
|
![
|
||||||
|
options.secretConfigPlanDigest,
|
||||||
|
options.decisionDigest,
|
||||||
|
options.candidateSetDigest,
|
||||||
|
options.automationAdoptionSetDigest,
|
||||||
|
].every((entry) => DIGEST.test(entry)) ||
|
||||||
|
!Array.isArray(options.materials) ||
|
||||||
|
options.materials.length < 1 ||
|
||||||
|
!Number.isSafeInteger(options.appliedAtMs) ||
|
||||||
|
options.appliedAtMs < 0 ||
|
||||||
|
(options.authorizationAtMs !== undefined &&
|
||||||
|
(!Number.isSafeInteger(options.authorizationAtMs) ||
|
||||||
|
options.authorizationAtMs < options.appliedAtMs)) ||
|
||||||
|
typeof options.confirmAuthenticationAuthority !== 'function' ||
|
||||||
|
typeof options.confirmPreparedAuthority !== 'function'
|
||||||
|
) {
|
||||||
|
fail('apply options are invalid');
|
||||||
|
}
|
||||||
|
const materials = options.materials.map(
|
||||||
|
normalizePreparedReconciliationSecretConfigMaterial,
|
||||||
|
);
|
||||||
|
if (materials.some((entry, index) => entry.ordinal !== index + 1)) {
|
||||||
|
fail('prepared material ordinal drifted');
|
||||||
|
}
|
||||||
|
const database = await openLocalSqliteSecretConfigApplicationDatabase({
|
||||||
|
databasePath: options.databasePath,
|
||||||
|
profile: options.profile,
|
||||||
|
...(options.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const principal = strongPrincipal(
|
||||||
|
options.principal,
|
||||||
|
options.authorizationAtMs ?? options.appliedAtMs,
|
||||||
|
);
|
||||||
|
await options.confirmAuthenticationAuthority();
|
||||||
|
const policy = new ProjectPolicyEngine(database.projectPolicy);
|
||||||
|
let decision: Readonly<SecurityPolicyDecision>;
|
||||||
|
try {
|
||||||
|
decision = await policy.authorize(
|
||||||
|
principal,
|
||||||
|
options.projectId,
|
||||||
|
'secret.manage',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ProjectPolicyUnavailableError) {
|
||||||
|
throw new ReconciliationSecretConfigApplicationUnavailableError(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (decision.effect !== 'allow') {
|
||||||
|
throw new ReconciliationSecretConfigApplicationAuthorizationError();
|
||||||
|
}
|
||||||
|
if (!decision.fence || decision.fence.bindingVersion === null) {
|
||||||
|
throw new ReconciliationSecretConfigApplicationUnavailableError();
|
||||||
|
}
|
||||||
|
const secrets: Readonly<LocalSecretConfigApplicationSecret>[] =
|
||||||
|
materials.map((entry) =>
|
||||||
|
Object.freeze({
|
||||||
|
...entry,
|
||||||
|
audit: audit(
|
||||||
|
entry.envelope.mutationId,
|
||||||
|
'secret.create',
|
||||||
|
options,
|
||||||
|
principal,
|
||||||
|
decision,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const publication = await database.publisher.publish({
|
||||||
|
mutationId: options.mutationId,
|
||||||
|
projectId: options.projectId,
|
||||||
|
profile: options.profile,
|
||||||
|
secretConfigPlanDigest: options.secretConfigPlanDigest,
|
||||||
|
decisionDigest: options.decisionDigest,
|
||||||
|
candidateSetDigest: options.candidateSetDigest,
|
||||||
|
automationAdoptionSetDigest: options.automationAdoptionSetDigest,
|
||||||
|
subject: principal.subject,
|
||||||
|
fence: decision.fence,
|
||||||
|
audit: audit(
|
||||||
|
options.mutationId,
|
||||||
|
'secret-config.apply',
|
||||||
|
options,
|
||||||
|
principal,
|
||||||
|
decision,
|
||||||
|
),
|
||||||
|
secrets,
|
||||||
|
appliedAtMs: options.appliedAtMs,
|
||||||
|
async confirmExternalAuthority() {
|
||||||
|
await options.confirmAuthenticationAuthority();
|
||||||
|
await options.confirmPreparedAuthority();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
status: publication.status,
|
||||||
|
receipt: publication.application.receipt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof ReconciliationSecretConfigApplicationError ||
|
||||||
|
error instanceof
|
||||||
|
ReconciliationSecretConfigApplicationAuthenticationError ||
|
||||||
|
error instanceof
|
||||||
|
ReconciliationSecretConfigApplicationAuthorizationError ||
|
||||||
|
error instanceof ReconciliationSecretConfigApplicationUnavailableError
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ReconciliationSecretConfigApplicationUnavailableError(error);
|
||||||
|
} finally {
|
||||||
|
await database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ export type LocalCutoverInstanceHeadState =
|
|||||||
| 'reconciliation_secret_config_planned'
|
| 'reconciliation_secret_config_planned'
|
||||||
| 'reconciliation_secret_config_decision_prepared'
|
| 'reconciliation_secret_config_decision_prepared'
|
||||||
| 'reconciliation_secret_config_reviewed'
|
| 'reconciliation_secret_config_reviewed'
|
||||||
|
| 'reconciliation_secret_config_apply_prepared'
|
||||||
|
| 'reconciliation_secret_config_applied'
|
||||||
|
| 'reconciliation_secret_config_rolled_back'
|
||||||
| 'reconciliation_completed'
|
| 'reconciliation_completed'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
@@ -189,6 +192,9 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
|||||||
head.state !== 'reconciliation_secret_config_planned' &&
|
head.state !== 'reconciliation_secret_config_planned' &&
|
||||||
head.state !== 'reconciliation_secret_config_decision_prepared' &&
|
head.state !== 'reconciliation_secret_config_decision_prepared' &&
|
||||||
head.state !== 'reconciliation_secret_config_reviewed' &&
|
head.state !== 'reconciliation_secret_config_reviewed' &&
|
||||||
|
head.state !== 'reconciliation_secret_config_apply_prepared' &&
|
||||||
|
head.state !== 'reconciliation_secret_config_applied' &&
|
||||||
|
head.state !== 'reconciliation_secret_config_rolled_back' &&
|
||||||
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' &&
|
||||||
@@ -373,6 +379,9 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
| 'reconciliation_secret_config_planned'
|
| 'reconciliation_secret_config_planned'
|
||||||
| 'reconciliation_secret_config_decision_prepared'
|
| 'reconciliation_secret_config_decision_prepared'
|
||||||
| 'reconciliation_secret_config_reviewed'
|
| 'reconciliation_secret_config_reviewed'
|
||||||
|
| 'reconciliation_secret_config_apply_prepared'
|
||||||
|
| 'reconciliation_secret_config_applied'
|
||||||
|
| 'reconciliation_secret_config_rolled_back'
|
||||||
| 'reconciliation_completed'
|
| 'reconciliation_completed'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
@@ -429,6 +438,9 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_secret_config_planned' ||
|
current.state === 'reconciliation_secret_config_planned' ||
|
||||||
current.state === 'reconciliation_secret_config_decision_prepared' ||
|
current.state === 'reconciliation_secret_config_decision_prepared' ||
|
||||||
current.state === 'reconciliation_secret_config_reviewed' ||
|
current.state === 'reconciliation_secret_config_reviewed' ||
|
||||||
|
current.state === 'reconciliation_secret_config_apply_prepared' ||
|
||||||
|
current.state === 'reconciliation_secret_config_applied' ||
|
||||||
|
current.state === 'reconciliation_secret_config_rolled_back' ||
|
||||||
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' ||
|
||||||
@@ -481,6 +493,12 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_secret_config_planned') ||
|
current.state === 'reconciliation_secret_config_planned') ||
|
||||||
(state === 'reconciliation_secret_config_reviewed' &&
|
(state === 'reconciliation_secret_config_reviewed' &&
|
||||||
current.state === 'reconciliation_secret_config_decision_prepared') ||
|
current.state === 'reconciliation_secret_config_decision_prepared') ||
|
||||||
|
(state === 'reconciliation_secret_config_apply_prepared' &&
|
||||||
|
current.state === 'reconciliation_secret_config_reviewed') ||
|
||||||
|
(state === 'reconciliation_secret_config_applied' &&
|
||||||
|
current.state === 'reconciliation_secret_config_apply_prepared') ||
|
||||||
|
(state === 'reconciliation_secret_config_rolled_back' &&
|
||||||
|
current.state === 'reconciliation_secret_config_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')) ||
|
||||||
|
|||||||
@@ -170,6 +170,14 @@ import {
|
|||||||
verifyLocalReconciliationSecretConfigDecision,
|
verifyLocalReconciliationSecretConfigDecision,
|
||||||
verifyLocalReconciliationSecretConfigDecisionCommandFile,
|
verifyLocalReconciliationSecretConfigDecisionCommandFile,
|
||||||
} from './reconciliation/application/secret-and-config/decisionCoordinator';
|
} from './reconciliation/application/secret-and-config/decisionCoordinator';
|
||||||
|
import {
|
||||||
|
applyLocalReconciliationSecretConfig,
|
||||||
|
applyLocalReconciliationSecretConfigCommandFile,
|
||||||
|
rollbackLocalReconciliationSecretConfigApply,
|
||||||
|
rollbackLocalReconciliationSecretConfigApplyCommandFile,
|
||||||
|
verifyLocalReconciliationSecretConfigApply,
|
||||||
|
verifyLocalReconciliationSecretConfigApplyCommandFile,
|
||||||
|
} from './reconciliation/application/secret-and-config/application/coordinator';
|
||||||
import {
|
import {
|
||||||
preserveLocalReconciliationRunHistory,
|
preserveLocalReconciliationRunHistory,
|
||||||
preserveLocalReconciliationRunHistoryCommandFile,
|
preserveLocalReconciliationRunHistoryCommandFile,
|
||||||
@@ -234,6 +242,12 @@ export {
|
|||||||
verifyLocalReconciliationSecretConfigDecision,
|
verifyLocalReconciliationSecretConfigDecision,
|
||||||
verifyLocalReconciliationSecretConfigDecisionCommandFile,
|
verifyLocalReconciliationSecretConfigDecisionCommandFile,
|
||||||
readLocalReconciliationSecretConfigDecisionTerminal,
|
readLocalReconciliationSecretConfigDecisionTerminal,
|
||||||
|
applyLocalReconciliationSecretConfig,
|
||||||
|
applyLocalReconciliationSecretConfigCommandFile,
|
||||||
|
verifyLocalReconciliationSecretConfigApply,
|
||||||
|
verifyLocalReconciliationSecretConfigApplyCommandFile,
|
||||||
|
rollbackLocalReconciliationSecretConfigApply,
|
||||||
|
rollbackLocalReconciliationSecretConfigApplyCommandFile,
|
||||||
preserveLocalReconciliationRunHistory,
|
preserveLocalReconciliationRunHistory,
|
||||||
preserveLocalReconciliationRunHistoryCommandFile,
|
preserveLocalReconciliationRunHistoryCommandFile,
|
||||||
readLocalReconciliationRunHistoryTerminal,
|
readLocalReconciliationRunHistoryTerminal,
|
||||||
@@ -381,6 +395,17 @@ export {
|
|||||||
type LocalReconciliationSecretConfigDecisionDependencies,
|
type LocalReconciliationSecretConfigDecisionDependencies,
|
||||||
type LocalReconciliationSecretConfigDecisionTerminal,
|
type LocalReconciliationSecretConfigDecisionTerminal,
|
||||||
} from './reconciliation/application/secret-and-config/decisionCoordinator';
|
} from './reconciliation/application/secret-and-config/decisionCoordinator';
|
||||||
|
export {
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyCommand,
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyRollbackCommand,
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyVerifyCommand,
|
||||||
|
type LocalReconciliationSecretConfigApplyCommand,
|
||||||
|
type LocalReconciliationSecretConfigApplyOptions,
|
||||||
|
type LocalReconciliationSecretConfigApplyResult,
|
||||||
|
type LocalReconciliationSecretConfigApplyRollbackCommand,
|
||||||
|
type LocalReconciliationSecretConfigApplyVerifyCommand,
|
||||||
|
} from './reconciliation/application/secret-and-config/application/contract';
|
||||||
|
export { type LocalReconciliationSecretConfigApplyDependencies } from './reconciliation/application/secret-and-config/application/coordinator';
|
||||||
export {
|
export {
|
||||||
type LocalReconciliationSecretConfigDecision,
|
type LocalReconciliationSecretConfigDecision,
|
||||||
type LocalReconciliationSecretConfigDecisionDisposition,
|
type LocalReconciliationSecretConfigDecisionDisposition,
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ import {
|
|||||||
prepareLocalReconciliationSecretConfigDecisionCommandFile,
|
prepareLocalReconciliationSecretConfigDecisionCommandFile,
|
||||||
commitLocalReconciliationSecretConfigDecisionCommandFile,
|
commitLocalReconciliationSecretConfigDecisionCommandFile,
|
||||||
verifyLocalReconciliationSecretConfigDecisionCommandFile,
|
verifyLocalReconciliationSecretConfigDecisionCommandFile,
|
||||||
|
applyLocalReconciliationSecretConfigCommandFile,
|
||||||
|
verifyLocalReconciliationSecretConfigApplyCommandFile,
|
||||||
|
rollbackLocalReconciliationSecretConfigApplyCommandFile,
|
||||||
preserveLocalReconciliationRunHistoryCommandFile,
|
preserveLocalReconciliationRunHistoryCommandFile,
|
||||||
verifyLocalReconciliationRunHistoryCommandFile,
|
verifyLocalReconciliationRunHistoryCommandFile,
|
||||||
completeLocalReconciliationCommandFile,
|
completeLocalReconciliationCommandFile,
|
||||||
@@ -57,7 +60,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-secret-config-plan|reconciliation-secret-config-verify|reconciliation-secret-config-decision-prepare|reconciliation-secret-config-decision-commit|reconciliation-secret-config-decision-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';
|
'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-secret-config-decision-prepare|reconciliation-secret-config-decision-commit|reconciliation-secret-config-decision-verify|reconciliation-secret-config-apply|reconciliation-secret-config-apply-verify|reconciliation-secret-config-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';
|
||||||
|
|
||||||
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')) {
|
||||||
@@ -112,6 +115,9 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
argv[0] !== 'reconciliation-secret-config-decision-prepare' &&
|
argv[0] !== 'reconciliation-secret-config-decision-prepare' &&
|
||||||
argv[0] !== 'reconciliation-secret-config-decision-commit' &&
|
argv[0] !== 'reconciliation-secret-config-decision-commit' &&
|
||||||
argv[0] !== 'reconciliation-secret-config-decision-verify' &&
|
argv[0] !== 'reconciliation-secret-config-decision-verify' &&
|
||||||
|
argv[0] !== 'reconciliation-secret-config-apply' &&
|
||||||
|
argv[0] !== 'reconciliation-secret-config-apply-verify' &&
|
||||||
|
argv[0] !== 'reconciliation-secret-config-apply-rollback' &&
|
||||||
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' &&
|
||||||
@@ -238,6 +244,12 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
? commitLocalReconciliationSecretConfigDecisionCommandFile(argv[2]!)
|
? commitLocalReconciliationSecretConfigDecisionCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-secret-config-decision-verify'
|
: argv[0] === 'reconciliation-secret-config-decision-verify'
|
||||||
? verifyLocalReconciliationSecretConfigDecisionCommandFile(argv[2]!)
|
? verifyLocalReconciliationSecretConfigDecisionCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-secret-config-apply'
|
||||||
|
? applyLocalReconciliationSecretConfigCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-secret-config-apply-verify'
|
||||||
|
? verifyLocalReconciliationSecretConfigApplyCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-secret-config-apply-rollback'
|
||||||
|
? rollbackLocalReconciliationSecretConfigApplyCommandFile(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'
|
||||||
|
|||||||
+410
@@ -0,0 +1,410 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { currentIdentity } from '../../../../foundation/contract';
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../../foundation/error';
|
||||||
|
|
||||||
|
const DIGEST = /^[0-9a-f]{64}$/;
|
||||||
|
const UUID_V4 =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
const UUID_V7 =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
const SAFE_PATH = /^\/[A-Za-z0-9._/@-]+$/;
|
||||||
|
const SAFE_ID = /^[A-Za-z0-9._:@-]{1,256}$/;
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigApplyOptions {
|
||||||
|
readonly deploymentRoot: string;
|
||||||
|
readonly applicationRoot: string;
|
||||||
|
readonly secretConfigRoot: string;
|
||||||
|
readonly secretConfigDecisionRoot: string;
|
||||||
|
readonly secretConfigApplyRoot: string;
|
||||||
|
readonly targetDatabasePath: string;
|
||||||
|
readonly secretKeyringPath: string;
|
||||||
|
readonly ownerPepperKeyringDirectory: string;
|
||||||
|
readonly credentialFilePath: string;
|
||||||
|
readonly allowRootService: boolean;
|
||||||
|
readonly busyTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigApplyCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.secret-config.apply';
|
||||||
|
readonly options: Readonly<LocalReconciliationSecretConfigApplyOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
decisionId: string;
|
||||||
|
secretConfigId: string;
|
||||||
|
expectedDecisionDigest: string;
|
||||||
|
expectedHeadDigest: string;
|
||||||
|
mutationId: string;
|
||||||
|
requestId: string;
|
||||||
|
appliedAtMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigApplyVerifyCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.secret-config.apply.verify';
|
||||||
|
readonly options: Readonly<LocalReconciliationSecretConfigApplyOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
decisionId: string;
|
||||||
|
secretConfigId: string;
|
||||||
|
expectedApplyDigest: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigApplyRollbackCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.secret-config.apply.rollback';
|
||||||
|
readonly options: Readonly<LocalReconciliationSecretConfigApplyOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
decisionId: string;
|
||||||
|
secretConfigId: string;
|
||||||
|
expectedApplyDigest: string;
|
||||||
|
expectedHeadDigest: string;
|
||||||
|
rolledBackAtMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigApplyResult {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation:
|
||||||
|
| LocalReconciliationSecretConfigApplyCommand['operation']
|
||||||
|
| LocalReconciliationSecretConfigApplyVerifyCommand['operation']
|
||||||
|
| LocalReconciliationSecretConfigApplyRollbackCommand['operation'];
|
||||||
|
readonly status: 'applied' | 'existing' | 'verified' | 'rolled_back';
|
||||||
|
readonly state:
|
||||||
|
| 'reconciliation_secret_config_applied'
|
||||||
|
| 'reconciliation_secret_config_rolled_back';
|
||||||
|
readonly decisionId: string;
|
||||||
|
readonly secretConfigId: string;
|
||||||
|
readonly applyDigest: string;
|
||||||
|
readonly publicationDigest: string;
|
||||||
|
readonly activeBindingCount: number;
|
||||||
|
readonly disabledPreservationCount: number;
|
||||||
|
readonly updatedTaskCount: number;
|
||||||
|
readonly updatedTriggerCount: number;
|
||||||
|
readonly backupSha256: string;
|
||||||
|
readonly instanceHeadDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation secret config apply ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
fail(`${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])
|
||||||
|
) {
|
||||||
|
fail(`${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.test(value) ||
|
||||||
|
Buffer.byteLength(value, 'utf8') > 4096
|
||||||
|
) {
|
||||||
|
fail(`${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 normalizeOptions(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyOptions> {
|
||||||
|
const selected = record(value, 'options');
|
||||||
|
const hasBusyTimeout = Object.hasOwn(selected, 'busyTimeoutMs');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
[
|
||||||
|
'allowRootService',
|
||||||
|
'applicationRoot',
|
||||||
|
'credentialFilePath',
|
||||||
|
'deploymentRoot',
|
||||||
|
'ownerPepperKeyringDirectory',
|
||||||
|
'secretConfigApplyRoot',
|
||||||
|
'secretConfigDecisionRoot',
|
||||||
|
'secretConfigRoot',
|
||||||
|
'secretKeyringPath',
|
||||||
|
'targetDatabasePath',
|
||||||
|
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||||
|
],
|
||||||
|
'options',
|
||||||
|
);
|
||||||
|
const identity = currentIdentity();
|
||||||
|
if (
|
||||||
|
typeof selected.allowRootService !== 'boolean' ||
|
||||||
|
(identity.uid === 0) !== selected.allowRootService
|
||||||
|
) {
|
||||||
|
fail('command identity is invalid');
|
||||||
|
}
|
||||||
|
const normalized = {
|
||||||
|
deploymentRoot: safePath(selected.deploymentRoot, 'deploymentRoot'),
|
||||||
|
applicationRoot: safePath(selected.applicationRoot, 'applicationRoot'),
|
||||||
|
secretConfigRoot: safePath(selected.secretConfigRoot, 'secretConfigRoot'),
|
||||||
|
secretConfigDecisionRoot: safePath(
|
||||||
|
selected.secretConfigDecisionRoot,
|
||||||
|
'secretConfigDecisionRoot',
|
||||||
|
),
|
||||||
|
secretConfigApplyRoot: safePath(
|
||||||
|
selected.secretConfigApplyRoot,
|
||||||
|
'secretConfigApplyRoot',
|
||||||
|
),
|
||||||
|
targetDatabasePath: safePath(
|
||||||
|
selected.targetDatabasePath,
|
||||||
|
'targetDatabasePath',
|
||||||
|
),
|
||||||
|
secretKeyringPath: safePath(
|
||||||
|
selected.secretKeyringPath,
|
||||||
|
'secretKeyringPath',
|
||||||
|
),
|
||||||
|
ownerPepperKeyringDirectory: safePath(
|
||||||
|
selected.ownerPepperKeyringDirectory,
|
||||||
|
'ownerPepperKeyringDirectory',
|
||||||
|
),
|
||||||
|
credentialFilePath: safePath(
|
||||||
|
selected.credentialFilePath,
|
||||||
|
'credentialFilePath',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const roots = [
|
||||||
|
normalized.deploymentRoot,
|
||||||
|
normalized.applicationRoot,
|
||||||
|
normalized.secretConfigRoot,
|
||||||
|
normalized.secretConfigDecisionRoot,
|
||||||
|
normalized.secretConfigApplyRoot,
|
||||||
|
];
|
||||||
|
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]!)
|
||||||
|
) {
|
||||||
|
fail('authority roots overlap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const candidate of [
|
||||||
|
normalized.secretKeyringPath,
|
||||||
|
normalized.ownerPepperKeyringDirectory,
|
||||||
|
normalized.credentialFilePath,
|
||||||
|
]) {
|
||||||
|
const relative = path.relative(normalized.deploymentRoot, candidate);
|
||||||
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
fail('authentication or Secret material must be below deploymentRoot');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
roots.some(
|
||||||
|
(root) =>
|
||||||
|
overlaps(root, normalized.targetDatabasePath) ||
|
||||||
|
overlaps(normalized.targetDatabasePath, root),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail('targetDatabasePath overlaps an authority root');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
selected.busyTimeoutMs !== undefined &&
|
||||||
|
(!Number.isSafeInteger(selected.busyTimeoutMs) ||
|
||||||
|
(selected.busyTimeoutMs as number) < 1 ||
|
||||||
|
(selected.busyTimeoutMs as number) > 60_000)
|
||||||
|
) {
|
||||||
|
fail('busyTimeoutMs is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...normalized,
|
||||||
|
allowRootService: selected.allowRootService as boolean,
|
||||||
|
...(selected.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: selected.busyTimeoutMs as number }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function command(value: unknown, operation: string) {
|
||||||
|
const selected = record(value, 'command');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
['operation', 'options', 'request', 'schemaVersion'],
|
||||||
|
'command',
|
||||||
|
);
|
||||||
|
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||||
|
fail('command version or operation is invalid');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
options: normalizeOptions(selected.options),
|
||||||
|
request: record(selected.request, 'request'),
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function id(value: unknown, pattern: RegExp, label: string): string {
|
||||||
|
if (typeof value !== 'string' || !pattern.test(value)) {
|
||||||
|
fail(`${label} is invalid`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value: unknown, label: string): string {
|
||||||
|
return id(value, DIGEST, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationSecretConfigApplyCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.secret-config.apply',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
[
|
||||||
|
'appliedAtMs',
|
||||||
|
'decisionId',
|
||||||
|
'expectedDecisionDigest',
|
||||||
|
'expectedHeadDigest',
|
||||||
|
'mutationId',
|
||||||
|
'requestId',
|
||||||
|
'secretConfigId',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(selected.request.appliedAtMs) ||
|
||||||
|
(selected.request.appliedAtMs as number) < 0 ||
|
||||||
|
typeof selected.request.requestId !== 'string' ||
|
||||||
|
!SAFE_ID.test(selected.request.requestId)
|
||||||
|
) {
|
||||||
|
fail('apply request timestamp or requestId is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.apply',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
decisionId: id(selected.request.decisionId, UUID_V7, 'decisionId'),
|
||||||
|
secretConfigId: id(
|
||||||
|
selected.request.secretConfigId,
|
||||||
|
UUID_V4,
|
||||||
|
'secretConfigId',
|
||||||
|
),
|
||||||
|
expectedDecisionDigest: digest(
|
||||||
|
selected.request.expectedDecisionDigest,
|
||||||
|
'expectedDecisionDigest',
|
||||||
|
),
|
||||||
|
expectedHeadDigest: digest(
|
||||||
|
selected.request.expectedHeadDigest,
|
||||||
|
'expectedHeadDigest',
|
||||||
|
),
|
||||||
|
mutationId: id(selected.request.mutationId, UUID_V4, 'mutationId'),
|
||||||
|
requestId: selected.request.requestId,
|
||||||
|
appliedAtMs: selected.request.appliedAtMs as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationSecretConfigApplyVerifyCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyVerifyCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.secret-config.apply.verify',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
['decisionId', 'expectedApplyDigest', 'secretConfigId'],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.apply.verify',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
decisionId: id(selected.request.decisionId, UUID_V7, 'decisionId'),
|
||||||
|
secretConfigId: id(
|
||||||
|
selected.request.secretConfigId,
|
||||||
|
UUID_V4,
|
||||||
|
'secretConfigId',
|
||||||
|
),
|
||||||
|
expectedApplyDigest: digest(
|
||||||
|
selected.request.expectedApplyDigest,
|
||||||
|
'expectedApplyDigest',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationSecretConfigApplyRollbackCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyRollbackCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.secret-config.apply.rollback',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
[
|
||||||
|
'decisionId',
|
||||||
|
'expectedApplyDigest',
|
||||||
|
'expectedHeadDigest',
|
||||||
|
'rolledBackAtMs',
|
||||||
|
'secretConfigId',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(selected.request.rolledBackAtMs) ||
|
||||||
|
(selected.request.rolledBackAtMs as number) < 0
|
||||||
|
) {
|
||||||
|
fail('rolledBackAtMs is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.apply.rollback',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
decisionId: id(selected.request.decisionId, UUID_V7, 'decisionId'),
|
||||||
|
secretConfigId: id(
|
||||||
|
selected.request.secretConfigId,
|
||||||
|
UUID_V4,
|
||||||
|
'secretConfigId',
|
||||||
|
),
|
||||||
|
expectedApplyDigest: digest(
|
||||||
|
selected.request.expectedApplyDigest,
|
||||||
|
'expectedApplyDigest',
|
||||||
|
),
|
||||||
|
expectedHeadDigest: digest(
|
||||||
|
selected.request.expectedHeadDigest,
|
||||||
|
'expectedHeadDigest',
|
||||||
|
),
|
||||||
|
rolledBackAtMs: selected.request.rolledBackAtMs as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
+977
@@ -0,0 +1,977 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyPreparedReconciliationSecretConfigApplication,
|
||||||
|
prepareReconciliationSecretConfigApplication,
|
||||||
|
type PreparedReconciliationSecretConfigMaterial,
|
||||||
|
} from '@qinglong/local-admin/reconciliation-secret-and-config-application';
|
||||||
|
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||||
|
import {
|
||||||
|
establishAuthenticatedLocalCommand,
|
||||||
|
type AuthenticatedLocalCommand,
|
||||||
|
} from '@qinglong/local-owner-console/authenticated-command';
|
||||||
|
import { LocalSecretKeyringFileProvider } from '@qinglong/local-secret';
|
||||||
|
import { openLocalSqliteAuthenticationReadDatabase } from '@qinglong/local-sqlite/authentication-read';
|
||||||
|
import {
|
||||||
|
createLocalSqliteRolloutBackup,
|
||||||
|
inspectLocalSqliteSnapshot,
|
||||||
|
restoreLocalSqliteSnapshot,
|
||||||
|
} from '@qinglong/local-sqlite/rollout-safety';
|
||||||
|
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||||
|
|
||||||
|
import { currentIdentity } from '../../../../foundation/contract';
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../../foundation/error';
|
||||||
|
import {
|
||||||
|
publishExactFile,
|
||||||
|
validatePrivateDirectory,
|
||||||
|
} from '../../../../foundation/files';
|
||||||
|
import {
|
||||||
|
advanceLocalCutoverInstanceHead,
|
||||||
|
readLocalCutoverInstanceHead,
|
||||||
|
type LocalCutoverInstanceHead,
|
||||||
|
} from '../../../../cutover/instanceLineage';
|
||||||
|
import { readLocalReconciliationCaptureIntent } from '../../../preparation';
|
||||||
|
import {
|
||||||
|
inspectLocalReconciliationSealedBundle,
|
||||||
|
withLocalReconciliationSealedDatabaseAsync,
|
||||||
|
} from '../../../sealed-bundle/reader';
|
||||||
|
import { proveLocalReconciliationStoppedState } from '../../../stoppedProof';
|
||||||
|
import {
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyCommand,
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyRollbackCommand,
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyVerifyCommand,
|
||||||
|
type LocalReconciliationSecretConfigApplyCommand,
|
||||||
|
type LocalReconciliationSecretConfigApplyOptions,
|
||||||
|
type LocalReconciliationSecretConfigApplyResult,
|
||||||
|
} from './contract';
|
||||||
|
import {
|
||||||
|
buildLocalReconciliationSecretConfigApplyIntent,
|
||||||
|
buildLocalReconciliationSecretConfigApplyReceipt,
|
||||||
|
buildLocalReconciliationSecretConfigRollbackReceipt,
|
||||||
|
localReconciliationSecretConfigApplyEvidenceContents,
|
||||||
|
type LocalReconciliationSecretConfigApplyIntent,
|
||||||
|
type LocalReconciliationSecretConfigApplyReceipt,
|
||||||
|
} from './evidence';
|
||||||
|
import {
|
||||||
|
discardUnpreparedLocalReconciliationSecretConfigMaterials,
|
||||||
|
ensureLocalReconciliationSecretConfigApplyLayout,
|
||||||
|
localReconciliationSecretConfigApplyPaths,
|
||||||
|
prepareLocalReconciliationSecretConfigRollbackSource,
|
||||||
|
publishLocalReconciliationSecretConfigMaterials,
|
||||||
|
readLocalReconciliationSecretConfigApplyIntent,
|
||||||
|
readLocalReconciliationSecretConfigApplyReceipt,
|
||||||
|
readLocalReconciliationSecretConfigMaterials,
|
||||||
|
readLocalReconciliationSecretConfigRollbackReceipt,
|
||||||
|
sealLocalReconciliationSecretConfigAppliedStorage,
|
||||||
|
sealLocalReconciliationSecretConfigRolledBackStorage,
|
||||||
|
validateLocalReconciliationSecretConfigAppliedStorage,
|
||||||
|
validateLocalReconciliationSecretConfigApplyCatalog,
|
||||||
|
validateLocalReconciliationSecretConfigRolledBackStorage,
|
||||||
|
} from './storage';
|
||||||
|
import { readLocalReconciliationSecretConfigDecisionTerminal } from '../decisionCoordinator';
|
||||||
|
import { createLocalReconciliationSecretConfigDecisionRequirementFactory } from '../planReader';
|
||||||
|
|
||||||
|
const MAX_AUTHENTICATION_AGE_MS = 5 * 60 * 1_000;
|
||||||
|
const APPLY_HEAD_STATES = Object.freeze([
|
||||||
|
'reconciliation_secret_config_apply_prepared',
|
||||||
|
'reconciliation_secret_config_applied',
|
||||||
|
'reconciliation_secret_config_rolled_back',
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
type AuthenticationDatabase = Awaited<
|
||||||
|
ReturnType<typeof openLocalSqliteAuthenticationReadDatabase>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigApplyDependencies {
|
||||||
|
readonly openAuthenticationDatabase?: typeof openLocalSqliteAuthenticationReadDatabase;
|
||||||
|
readonly authenticate?: typeof establishAuthenticatedLocalCommand;
|
||||||
|
readonly prepareApplication?: typeof prepareReconciliationSecretConfigApplication;
|
||||||
|
readonly applyApplication?: typeof applyPreparedReconciliationSecretConfigApplication;
|
||||||
|
readonly createBackup?: typeof createLocalSqliteRolloutBackup;
|
||||||
|
readonly inspectSnapshot?: typeof inspectLocalSqliteSnapshot;
|
||||||
|
readonly restoreSnapshot?: typeof restoreLocalSqliteSnapshot;
|
||||||
|
readonly afterMaterialPublished?: () => void;
|
||||||
|
readonly afterBackupPublished?: () => void;
|
||||||
|
readonly afterPreparedHead?: () => void;
|
||||||
|
readonly afterDatabaseCommit?: () => void;
|
||||||
|
readonly afterReceiptPublished?: () => void;
|
||||||
|
readonly afterAppliedHead?: () => void;
|
||||||
|
readonly afterAppliedSeal?: () => void;
|
||||||
|
readonly afterRestore?: () => void;
|
||||||
|
readonly afterRollbackReceipt?: () => void;
|
||||||
|
readonly afterRollbackHead?: () => void;
|
||||||
|
readonly afterRollbackSeal?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string, cause?: unknown): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation secret config apply ${message}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionOptions(
|
||||||
|
options: Readonly<LocalReconciliationSecretConfigApplyOptions>,
|
||||||
|
) {
|
||||||
|
return Object.freeze({
|
||||||
|
deploymentRoot: options.deploymentRoot,
|
||||||
|
applicationRoot: options.applicationRoot,
|
||||||
|
secretConfigRoot: options.secretConfigRoot,
|
||||||
|
secretConfigDecisionRoot: options.secretConfigDecisionRoot,
|
||||||
|
allowRootService: options.allowRootService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance(
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
uid: number,
|
||||||
|
state:
|
||||||
|
| 'reconciliation_secret_config_apply_prepared'
|
||||||
|
| 'reconciliation_secret_config_applied'
|
||||||
|
| 'reconciliation_secret_config_rolled_back',
|
||||||
|
digest: string,
|
||||||
|
atMs: number,
|
||||||
|
): Readonly<LocalCutoverInstanceHead> {
|
||||||
|
return advanceLocalCutoverInstanceHead(
|
||||||
|
{
|
||||||
|
options: { deploymentRoot: intent.command.options.deploymentRoot },
|
||||||
|
request: {
|
||||||
|
cutoverId: intent.cutoverId,
|
||||||
|
profile: intent.profile,
|
||||||
|
instanceId: intent.instanceId,
|
||||||
|
expectedActivationDigest: intent.activationDigest,
|
||||||
|
requestedAtMs: atMs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
uid,
|
||||||
|
state,
|
||||||
|
intent.generation,
|
||||||
|
digest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(
|
||||||
|
operation: LocalReconciliationSecretConfigApplyResult['operation'],
|
||||||
|
status: LocalReconciliationSecretConfigApplyResult['status'],
|
||||||
|
receipt: Readonly<LocalReconciliationSecretConfigApplyReceipt>,
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
head: Readonly<LocalCutoverInstanceHead>,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyResult> {
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation,
|
||||||
|
status,
|
||||||
|
state: head.state as
|
||||||
|
| 'reconciliation_secret_config_applied'
|
||||||
|
| 'reconciliation_secret_config_rolled_back',
|
||||||
|
decisionId: receipt.decisionId,
|
||||||
|
secretConfigId: receipt.secretConfigId,
|
||||||
|
applyDigest: receipt.applyDigest,
|
||||||
|
publicationDigest: receipt.publicationDigest,
|
||||||
|
activeBindingCount: receipt.activeBindingCount,
|
||||||
|
disabledPreservationCount: receipt.disabledPreservationCount,
|
||||||
|
updatedTaskCount: receipt.updatedTaskCount,
|
||||||
|
updatedTriggerCount: receipt.updatedTriggerCount,
|
||||||
|
backupSha256: intent.backup.sha256,
|
||||||
|
instanceHeadDigest: head.headDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertReviewer(
|
||||||
|
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||||
|
reviewer: Readonly<SecurityPrincipal>,
|
||||||
|
atMs: number,
|
||||||
|
): void {
|
||||||
|
const principal = authenticated.principal;
|
||||||
|
if (
|
||||||
|
reviewer.subject.type !== 'user' ||
|
||||||
|
principal.subject.type !== 'user' ||
|
||||||
|
reviewer.subject.id !== principal.subject.id ||
|
||||||
|
principal.assurance !== 'local_console' ||
|
||||||
|
principal.authenticatedAtMs > atMs ||
|
||||||
|
atMs - principal.authenticatedAtMs > MAX_AUTHENTICATION_AGE_MS ||
|
||||||
|
principal.expiresAtMs <= atMs
|
||||||
|
) {
|
||||||
|
fail('current reviewer authentication is not strong or identical');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(
|
||||||
|
options: Readonly<LocalReconciliationSecretConfigApplyOptions>,
|
||||||
|
atMs: number,
|
||||||
|
profile: 'edge' | 'standalone',
|
||||||
|
reviewer: Readonly<SecurityPrincipal>,
|
||||||
|
dependencies: LocalReconciliationSecretConfigApplyDependencies,
|
||||||
|
): Promise<
|
||||||
|
Readonly<{
|
||||||
|
authenticated: Readonly<AuthenticatedLocalCommand>;
|
||||||
|
database: AuthenticationDatabase;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const database = await (
|
||||||
|
dependencies.openAuthenticationDatabase ??
|
||||||
|
openLocalSqliteAuthenticationReadDatabase
|
||||||
|
)({
|
||||||
|
databasePath: options.targetDatabasePath,
|
||||||
|
profile,
|
||||||
|
...(options.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const authenticated = await (
|
||||||
|
dependencies.authenticate ?? establishAuthenticatedLocalCommand
|
||||||
|
)(database, {
|
||||||
|
deploymentRoot: options.deploymentRoot,
|
||||||
|
databasePath: options.targetDatabasePath,
|
||||||
|
ownerPepperKeyringDirectory: options.ownerPepperKeyringDirectory,
|
||||||
|
credentialFilePath: options.credentialFilePath,
|
||||||
|
authenticationNamespace: 'local_reconciliation_secret_config_apply',
|
||||||
|
now: () => atMs,
|
||||||
|
});
|
||||||
|
assertReviewer(authenticated, reviewer, atMs);
|
||||||
|
return Object.freeze({ authenticated, database });
|
||||||
|
} catch (error) {
|
||||||
|
await database.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactBundle(
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
captureRoot: string,
|
||||||
|
captureId: string,
|
||||||
|
expectedBundleDigest: string,
|
||||||
|
expectedFingerprintDigest: string,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
const current = inspectLocalReconciliationSealedBundle(
|
||||||
|
captureRoot,
|
||||||
|
captureId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
current.receipt.bundleDigest !== expectedBundleDigest ||
|
||||||
|
current.fingerprintDigest !== expectedFingerprintDigest ||
|
||||||
|
intent.command.request.expectedDecisionDigest.length !== 64
|
||||||
|
) {
|
||||||
|
fail('sealed source authority drifted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyIntentCommand(
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
command: Readonly<LocalReconciliationSecretConfigApplyCommand>,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
intent.command.request.secretConfigId !== command.request.secretConfigId ||
|
||||||
|
intent.command.request.decisionId !== command.request.decisionId ||
|
||||||
|
intent.command.request.expectedDecisionDigest !==
|
||||||
|
command.request.expectedDecisionDigest ||
|
||||||
|
intent.command.request.mutationId !== command.request.mutationId ||
|
||||||
|
intent.command.request.requestId !== command.request.requestId ||
|
||||||
|
intent.command.request.appliedAtMs !== command.request.appliedAtMs ||
|
||||||
|
intent.command.request.expectedHeadDigest !==
|
||||||
|
command.request.expectedHeadDigest ||
|
||||||
|
JSON.stringify(intent.command.options) !== JSON.stringify(command.options)
|
||||||
|
) {
|
||||||
|
fail('apply command is not an exact replay');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPreparedAuthority(
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
uid: number,
|
||||||
|
allowedState:
|
||||||
|
| 'reconciliation_secret_config_apply_prepared'
|
||||||
|
| 'reconciliation_secret_config_applied',
|
||||||
|
expectedDigest: string,
|
||||||
|
): void {
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
intent.command.options.deploymentRoot,
|
||||||
|
intent.instanceId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
head.state !== allowedState ||
|
||||||
|
head.sourceRecordDigest !== expectedDigest
|
||||||
|
) {
|
||||||
|
fail('apply lost current head authority');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyLocalReconciliationSecretConfig(
|
||||||
|
value: unknown,
|
||||||
|
dependencies: LocalReconciliationSecretConfigApplyDependencies = {},
|
||||||
|
): Promise<Readonly<LocalReconciliationSecretConfigApplyResult>> {
|
||||||
|
const command = normalizeLocalReconciliationSecretConfigApplyCommand(value);
|
||||||
|
const uid = currentIdentity().uid;
|
||||||
|
for (const [directory, label] of [
|
||||||
|
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||||
|
[command.options.applicationRoot, 'applicationRoot'],
|
||||||
|
[command.options.secretConfigRoot, 'secretConfigRoot'],
|
||||||
|
[command.options.secretConfigDecisionRoot, 'secretConfigDecisionRoot'],
|
||||||
|
[command.options.secretConfigApplyRoot, 'secretConfigApplyRoot'],
|
||||||
|
] as const) {
|
||||||
|
validatePrivateDirectory(directory, uid, label);
|
||||||
|
}
|
||||||
|
const selected = localReconciliationSecretConfigApplyPaths(
|
||||||
|
command.options.secretConfigApplyRoot,
|
||||||
|
command.request.secretConfigId,
|
||||||
|
);
|
||||||
|
ensureLocalReconciliationSecretConfigApplyLayout(selected, uid);
|
||||||
|
validateLocalReconciliationSecretConfigApplyCatalog(selected);
|
||||||
|
|
||||||
|
const terminal = await readLocalReconciliationSecretConfigDecisionTerminal(
|
||||||
|
decisionOptions(command.options),
|
||||||
|
command.request.secretConfigId,
|
||||||
|
uid,
|
||||||
|
APPLY_HEAD_STATES,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
terminal.receipt.decisionId !== command.request.decisionId ||
|
||||||
|
terminal.receipt.decisionDigest !==
|
||||||
|
command.request.expectedDecisionDigest ||
|
||||||
|
terminal.receipt.outcome !== 'ready' ||
|
||||||
|
terminal.receipt.skippedCount !== 0 ||
|
||||||
|
terminal.receipt.candidateCount < 1
|
||||||
|
) {
|
||||||
|
fail('apply command is detached from a ready signed decision');
|
||||||
|
}
|
||||||
|
const planTerminal = terminal.context.planTerminal;
|
||||||
|
const capture = readLocalReconciliationCaptureIntent(
|
||||||
|
planTerminal.intent.command.options.captureRoot,
|
||||||
|
planTerminal.intent.command.request.captureId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
capture.command.request.targetDatabasePath !==
|
||||||
|
command.options.targetDatabasePath
|
||||||
|
) {
|
||||||
|
fail('target database is detached from stopped capture');
|
||||||
|
}
|
||||||
|
|
||||||
|
let intent: Readonly<LocalReconciliationSecretConfigApplyIntent>;
|
||||||
|
const recoveringPreparedIntent = fs.existsSync(selected.intent);
|
||||||
|
let head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
terminal.intent.instanceId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (recoveringPreparedIntent) {
|
||||||
|
intent = readLocalReconciliationSecretConfigApplyIntent(selected, uid);
|
||||||
|
verifyIntentCommand(intent, command);
|
||||||
|
readLocalReconciliationSecretConfigMaterials(
|
||||||
|
selected,
|
||||||
|
intent.profile,
|
||||||
|
uid,
|
||||||
|
intent.material,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
head.state !== 'reconciliation_secret_config_reviewed' ||
|
||||||
|
head.headDigest !== command.request.expectedHeadDigest ||
|
||||||
|
head.sourceRecordDigest !== terminal.receipt.decisionDigest
|
||||||
|
) {
|
||||||
|
fail('apply lost reviewed head compare-and-swap');
|
||||||
|
}
|
||||||
|
discardUnpreparedLocalReconciliationSecretConfigMaterials(selected);
|
||||||
|
const before = proveLocalReconciliationStoppedState(capture.command, uid);
|
||||||
|
const materials: Readonly<PreparedReconciliationSecretConfigMaterial>[] =
|
||||||
|
[];
|
||||||
|
const openRequirements =
|
||||||
|
createLocalReconciliationSecretConfigDecisionRequirementFactory(
|
||||||
|
terminal.context.secretConfig.planPath,
|
||||||
|
terminal.context.secretConfig.receipt,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const prepared = await withLocalReconciliationSealedDatabaseAsync(
|
||||||
|
planTerminal.bundle,
|
||||||
|
'legacy',
|
||||||
|
uid,
|
||||||
|
{},
|
||||||
|
async (sourceClient) =>
|
||||||
|
(
|
||||||
|
dependencies.prepareApplication ??
|
||||||
|
prepareReconciliationSecretConfigApplication
|
||||||
|
)({
|
||||||
|
sourceClient,
|
||||||
|
profile: terminal.intent.profile,
|
||||||
|
projectId: terminal.intent.projectId,
|
||||||
|
mutationId: command.request.mutationId,
|
||||||
|
appliedAtMs: command.request.appliedAtMs,
|
||||||
|
expectedLegacyInventoryDigest:
|
||||||
|
terminal.context.secretConfig.receipt.legacyInventoryDigest,
|
||||||
|
decisions: terminal.authorization.decisions,
|
||||||
|
openRequirements,
|
||||||
|
keyProvider: new LocalSecretKeyringFileProvider(
|
||||||
|
command.options.secretKeyringPath,
|
||||||
|
),
|
||||||
|
visitMaterial(entry) {
|
||||||
|
materials.push(entry);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!prepared) fail('sealed Legacy source requires manual handling');
|
||||||
|
const materialEvidence = publishLocalReconciliationSecretConfigMaterials(
|
||||||
|
selected,
|
||||||
|
materials,
|
||||||
|
terminal.intent.profile,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
materialEvidence.secretCount !== prepared.secretCount ||
|
||||||
|
materialEvidence.activeBindingCount !== prepared.activeBindingCount ||
|
||||||
|
materialEvidence.disabledPreservationCount !==
|
||||||
|
prepared.disabledPreservationCount ||
|
||||||
|
materialEvidence.materialSetDigest !== prepared.materialSetDigest ||
|
||||||
|
prepared.activeBindingCount !== terminal.receipt.applyBindingCount ||
|
||||||
|
prepared.disabledPreservationCount !==
|
||||||
|
terminal.receipt.preserveDisabledCount
|
||||||
|
) {
|
||||||
|
fail('prepared material evidence drifted');
|
||||||
|
}
|
||||||
|
dependencies.afterMaterialPublished?.();
|
||||||
|
const backup = await (
|
||||||
|
dependencies.createBackup ?? createLocalSqliteRolloutBackup
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
backupPath: selected.backup,
|
||||||
|
profile: terminal.intent.profile,
|
||||||
|
...(command.options.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||||
|
});
|
||||||
|
const after = proveLocalReconciliationStoppedState(capture.command, uid);
|
||||||
|
if (after.proofDigest !== before.proofDigest) {
|
||||||
|
fail('stopped target drifted across preparation');
|
||||||
|
}
|
||||||
|
dependencies.afterBackupPublished?.();
|
||||||
|
intent = buildLocalReconciliationSecretConfigApplyIntent({
|
||||||
|
command,
|
||||||
|
instanceId: terminal.intent.instanceId,
|
||||||
|
cutoverId: terminal.intent.cutoverId,
|
||||||
|
activationDigest: terminal.intent.activationDigest,
|
||||||
|
profile: terminal.intent.profile,
|
||||||
|
projectId: terminal.intent.projectId,
|
||||||
|
generation: terminal.intent.generation,
|
||||||
|
stoppedProofDigest: before.proofDigest,
|
||||||
|
legacyInventoryDigest: prepared.legacyInventoryDigest,
|
||||||
|
candidateSetDigest: terminal.intent.candidateSetDigest,
|
||||||
|
automationAdoptionSetDigest:
|
||||||
|
terminal.context.secretConfig.receipt.automationAdoptionSetDigest,
|
||||||
|
material: materialEvidence,
|
||||||
|
backup,
|
||||||
|
});
|
||||||
|
publishExactFile(
|
||||||
|
selected.intent,
|
||||||
|
localReconciliationSecretConfigApplyEvidenceContents(intent),
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'secret config apply intent',
|
||||||
|
);
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_secret_config_apply_prepared',
|
||||||
|
intent.preparationDigest,
|
||||||
|
command.request.appliedAtMs,
|
||||||
|
);
|
||||||
|
dependencies.afterPreparedHead?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(selected.receipt)) {
|
||||||
|
const receipt = readLocalReconciliationSecretConfigApplyReceipt(
|
||||||
|
selected,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const current = await (
|
||||||
|
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
if (current.sha256 !== receipt.targetAfter.sha256) {
|
||||||
|
fail('terminal apply target drifted');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
head.state === 'reconciliation_secret_config_apply_prepared' &&
|
||||||
|
head.sourceRecordDigest === intent.preparationDigest
|
||||||
|
) {
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_secret_config_applied',
|
||||||
|
receipt.applyDigest,
|
||||||
|
receipt.appliedAtMs,
|
||||||
|
);
|
||||||
|
dependencies.afterAppliedHead?.();
|
||||||
|
} else if (
|
||||||
|
head.state !== 'reconciliation_secret_config_applied' ||
|
||||||
|
head.sourceRecordDigest !== receipt.applyDigest
|
||||||
|
) {
|
||||||
|
fail('terminal apply receipt drifted');
|
||||||
|
}
|
||||||
|
sealLocalReconciliationSecretConfigAppliedStorage(selected, intent, uid);
|
||||||
|
dependencies.afterAppliedSeal?.();
|
||||||
|
return result(command.operation, 'existing', receipt, intent, head);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
head.state !== 'reconciliation_secret_config_apply_prepared' ||
|
||||||
|
head.sourceRecordDigest !== intent.preparationDigest
|
||||||
|
) {
|
||||||
|
fail('apply lost prepared head compare-and-swap');
|
||||||
|
}
|
||||||
|
if (!recoveringPreparedIntent) {
|
||||||
|
const stopped = proveLocalReconciliationStoppedState(capture.command, uid);
|
||||||
|
if (stopped.proofDigest !== intent.stoppedProofDigest) {
|
||||||
|
fail('stopped proof drifted before write');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticatedScope = await authenticate(
|
||||||
|
command.options,
|
||||||
|
command.request.appliedAtMs,
|
||||||
|
intent.profile,
|
||||||
|
terminal.reviewer,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
let publication;
|
||||||
|
try {
|
||||||
|
publication = await (
|
||||||
|
dependencies.applyApplication ??
|
||||||
|
applyPreparedReconciliationSecretConfigApplication
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
projectId: intent.projectId,
|
||||||
|
mutationId: command.request.mutationId,
|
||||||
|
requestId: command.request.requestId,
|
||||||
|
secretConfigPlanDigest: terminal.receipt.secretConfigPlanDigest,
|
||||||
|
decisionDigest: terminal.receipt.decisionDigest,
|
||||||
|
candidateSetDigest: intent.candidateSetDigest,
|
||||||
|
automationAdoptionSetDigest: intent.automationAdoptionSetDigest,
|
||||||
|
materials: readLocalReconciliationSecretConfigMaterials(
|
||||||
|
selected,
|
||||||
|
intent.profile,
|
||||||
|
uid,
|
||||||
|
intent.material,
|
||||||
|
),
|
||||||
|
principal: authenticatedScope.authenticated.principal,
|
||||||
|
appliedAtMs: command.request.appliedAtMs,
|
||||||
|
...(command.options.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||||
|
confirmAuthenticationAuthority: () =>
|
||||||
|
authenticatedScope.authenticated.confirm(),
|
||||||
|
confirmPreparedAuthority() {
|
||||||
|
exactBundle(
|
||||||
|
intent,
|
||||||
|
planTerminal.bundle.captureRoot,
|
||||||
|
planTerminal.bundle.receipt.captureId,
|
||||||
|
planTerminal.bundle.receipt.bundleDigest,
|
||||||
|
planTerminal.bundle.fingerprintDigest,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
assertPreparedAuthority(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_secret_config_apply_prepared',
|
||||||
|
intent.preparationDigest,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await authenticatedScope.database.close();
|
||||||
|
}
|
||||||
|
dependencies.afterDatabaseCommit?.();
|
||||||
|
const targetAfter = await (
|
||||||
|
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
const publisher = publication.receipt;
|
||||||
|
const receipt = buildLocalReconciliationSecretConfigApplyReceipt({
|
||||||
|
decisionId: command.request.decisionId,
|
||||||
|
secretConfigId: command.request.secretConfigId,
|
||||||
|
mutationId: command.request.mutationId,
|
||||||
|
preparationDigest: intent.preparationDigest,
|
||||||
|
preparedHeadDigest: head.headDigest,
|
||||||
|
publicationDigest: publisher.publicationDigest,
|
||||||
|
publisherReceiptDigest: publisher.receiptDigest,
|
||||||
|
activeBindingCount: publisher.activeBindingCount,
|
||||||
|
disabledPreservationCount: publisher.disabledPreservationCount,
|
||||||
|
updatedTaskCount: publisher.taskCount,
|
||||||
|
updatedTriggerCount: publisher.triggerCount,
|
||||||
|
targetAfter,
|
||||||
|
appliedAtMs: command.request.appliedAtMs,
|
||||||
|
});
|
||||||
|
publishExactFile(
|
||||||
|
selected.receipt,
|
||||||
|
localReconciliationSecretConfigApplyEvidenceContents(receipt),
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'secret config apply receipt',
|
||||||
|
);
|
||||||
|
dependencies.afterReceiptPublished?.();
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_secret_config_applied',
|
||||||
|
receipt.applyDigest,
|
||||||
|
command.request.appliedAtMs,
|
||||||
|
);
|
||||||
|
dependencies.afterAppliedHead?.();
|
||||||
|
sealLocalReconciliationSecretConfigAppliedStorage(selected, intent, uid);
|
||||||
|
dependencies.afterAppliedSeal?.();
|
||||||
|
return result(
|
||||||
|
command.operation,
|
||||||
|
publication.status === 'existing' ? 'existing' : 'applied',
|
||||||
|
receipt,
|
||||||
|
intent,
|
||||||
|
head,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLocalReconciliationSecretConfigApply(
|
||||||
|
value: unknown,
|
||||||
|
): Promise<Readonly<LocalReconciliationSecretConfigApplyResult>> {
|
||||||
|
const command =
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyVerifyCommand(value);
|
||||||
|
const uid = currentIdentity().uid;
|
||||||
|
const selected = localReconciliationSecretConfigApplyPaths(
|
||||||
|
command.options.secretConfigApplyRoot,
|
||||||
|
command.request.secretConfigId,
|
||||||
|
);
|
||||||
|
validatePrivateDirectory(
|
||||||
|
command.options.secretConfigApplyRoot,
|
||||||
|
uid,
|
||||||
|
'secretConfigApplyRoot',
|
||||||
|
);
|
||||||
|
validateLocalReconciliationSecretConfigApplyCatalog(selected);
|
||||||
|
const intent = readLocalReconciliationSecretConfigApplyIntent(selected, uid);
|
||||||
|
const receipt = readLocalReconciliationSecretConfigApplyReceipt(
|
||||||
|
selected,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
intent.command.request.decisionId !== command.request.decisionId ||
|
||||||
|
receipt.decisionId !== command.request.decisionId ||
|
||||||
|
receipt.secretConfigId !== command.request.secretConfigId ||
|
||||||
|
receipt.applyDigest !== command.request.expectedApplyDigest
|
||||||
|
) {
|
||||||
|
fail('verify command is detached from apply receipt');
|
||||||
|
}
|
||||||
|
await readLocalReconciliationSecretConfigDecisionTerminal(
|
||||||
|
decisionOptions(command.options),
|
||||||
|
command.request.secretConfigId,
|
||||||
|
uid,
|
||||||
|
APPLY_HEAD_STATES,
|
||||||
|
);
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
intent.instanceId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (head.state === 'reconciliation_secret_config_applied') {
|
||||||
|
validateLocalReconciliationSecretConfigAppliedStorage(
|
||||||
|
selected,
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (head.sourceRecordDigest !== receipt.applyDigest) {
|
||||||
|
fail('applied head drifted');
|
||||||
|
}
|
||||||
|
const current = await inspectLocalSqliteSnapshot({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
if (current.sha256 !== receipt.targetAfter.sha256) {
|
||||||
|
fail('applied target drifted');
|
||||||
|
}
|
||||||
|
return result(command.operation, 'verified', receipt, intent, head);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
head.state === 'reconciliation_secret_config_rolled_back' &&
|
||||||
|
fs.existsSync(selected.rollbackReceipt)
|
||||||
|
) {
|
||||||
|
validateLocalReconciliationSecretConfigRolledBackStorage(
|
||||||
|
selected,
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const rollback = readLocalReconciliationSecretConfigRollbackReceipt(
|
||||||
|
selected,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
rollback.applyDigest !== receipt.applyDigest ||
|
||||||
|
head.sourceRecordDigest !== rollback.rollbackDigest
|
||||||
|
) {
|
||||||
|
fail('rollback head drifted');
|
||||||
|
}
|
||||||
|
const current = await inspectLocalSqliteSnapshot({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
current.sha256 !== intent.backup.sha256 ||
|
||||||
|
current.sha256 !== rollback.restored.sha256
|
||||||
|
) {
|
||||||
|
fail('rolled-back target drifted');
|
||||||
|
}
|
||||||
|
return result(command.operation, 'verified', receipt, intent, head);
|
||||||
|
}
|
||||||
|
return fail('apply head is not terminal');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollbackLocalReconciliationSecretConfigApply(
|
||||||
|
value: unknown,
|
||||||
|
dependencies: LocalReconciliationSecretConfigApplyDependencies = {},
|
||||||
|
): Promise<Readonly<LocalReconciliationSecretConfigApplyResult>> {
|
||||||
|
const command =
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyRollbackCommand(value);
|
||||||
|
const uid = currentIdentity().uid;
|
||||||
|
const selected = localReconciliationSecretConfigApplyPaths(
|
||||||
|
command.options.secretConfigApplyRoot,
|
||||||
|
command.request.secretConfigId,
|
||||||
|
);
|
||||||
|
validatePrivateDirectory(
|
||||||
|
command.options.secretConfigApplyRoot,
|
||||||
|
uid,
|
||||||
|
'secretConfigApplyRoot',
|
||||||
|
);
|
||||||
|
validateLocalReconciliationSecretConfigApplyCatalog(selected);
|
||||||
|
const intent = readLocalReconciliationSecretConfigApplyIntent(selected, uid);
|
||||||
|
const receipt = readLocalReconciliationSecretConfigApplyReceipt(
|
||||||
|
selected,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
receipt.decisionId !== command.request.decisionId ||
|
||||||
|
receipt.secretConfigId !== command.request.secretConfigId ||
|
||||||
|
receipt.applyDigest !== command.request.expectedApplyDigest
|
||||||
|
) {
|
||||||
|
fail('rollback command is detached from apply receipt');
|
||||||
|
}
|
||||||
|
const terminal = await readLocalReconciliationSecretConfigDecisionTerminal(
|
||||||
|
decisionOptions(command.options),
|
||||||
|
command.request.secretConfigId,
|
||||||
|
uid,
|
||||||
|
APPLY_HEAD_STATES,
|
||||||
|
);
|
||||||
|
let head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
intent.instanceId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (fs.existsSync(selected.rollbackReceipt)) {
|
||||||
|
const rollback = readLocalReconciliationSecretConfigRollbackReceipt(
|
||||||
|
selected,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const current = await (
|
||||||
|
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
if (current.sha256 !== rollback.restored.sha256) {
|
||||||
|
fail('rollback replay target drifted');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
head.state === 'reconciliation_secret_config_applied' &&
|
||||||
|
head.sourceRecordDigest === receipt.applyDigest
|
||||||
|
) {
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_secret_config_rolled_back',
|
||||||
|
rollback.rollbackDigest,
|
||||||
|
rollback.rolledBackAtMs,
|
||||||
|
);
|
||||||
|
dependencies.afterRollbackHead?.();
|
||||||
|
} else if (
|
||||||
|
head.state !== 'reconciliation_secret_config_rolled_back' ||
|
||||||
|
head.sourceRecordDigest !== rollback.rollbackDigest
|
||||||
|
) {
|
||||||
|
fail('rollback replay drifted');
|
||||||
|
}
|
||||||
|
sealLocalReconciliationSecretConfigRolledBackStorage(selected, intent, uid);
|
||||||
|
dependencies.afterRollbackSeal?.();
|
||||||
|
return result(command.operation, 'existing', receipt, intent, head);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
head.state !== 'reconciliation_secret_config_applied' ||
|
||||||
|
head.headDigest !== command.request.expectedHeadDigest ||
|
||||||
|
head.sourceRecordDigest !== receipt.applyDigest
|
||||||
|
) {
|
||||||
|
fail('rollback lost applied head compare-and-swap');
|
||||||
|
}
|
||||||
|
const current = await (
|
||||||
|
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
if (current.sha256 === intent.backup.sha256) {
|
||||||
|
const rollback = buildLocalReconciliationSecretConfigRollbackReceipt({
|
||||||
|
decisionId: receipt.decisionId,
|
||||||
|
secretConfigId: receipt.secretConfigId,
|
||||||
|
applyDigest: receipt.applyDigest,
|
||||||
|
restored: current,
|
||||||
|
rolledBackAtMs: command.request.rolledBackAtMs,
|
||||||
|
});
|
||||||
|
publishExactFile(
|
||||||
|
selected.rollbackReceipt,
|
||||||
|
localReconciliationSecretConfigApplyEvidenceContents(rollback),
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'secret config rollback receipt',
|
||||||
|
);
|
||||||
|
dependencies.afterRollbackReceipt?.();
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_secret_config_rolled_back',
|
||||||
|
rollback.rollbackDigest,
|
||||||
|
command.request.rolledBackAtMs,
|
||||||
|
);
|
||||||
|
dependencies.afterRollbackHead?.();
|
||||||
|
sealLocalReconciliationSecretConfigRolledBackStorage(selected, intent, uid);
|
||||||
|
dependencies.afterRollbackSeal?.();
|
||||||
|
return result(command.operation, 'existing', receipt, intent, head);
|
||||||
|
}
|
||||||
|
if (current.sha256 !== receipt.targetAfter.sha256) {
|
||||||
|
fail('rollback current target drifted');
|
||||||
|
}
|
||||||
|
sealLocalReconciliationSecretConfigAppliedStorage(selected, intent, uid);
|
||||||
|
|
||||||
|
const authenticatedScope = await authenticate(
|
||||||
|
command.options,
|
||||||
|
command.request.rolledBackAtMs,
|
||||||
|
intent.profile,
|
||||||
|
terminal.reviewer,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const replay = await (
|
||||||
|
dependencies.applyApplication ??
|
||||||
|
applyPreparedReconciliationSecretConfigApplication
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
projectId: intent.projectId,
|
||||||
|
mutationId: intent.command.request.mutationId,
|
||||||
|
requestId: intent.command.request.requestId,
|
||||||
|
secretConfigPlanDigest: terminal.receipt.secretConfigPlanDigest,
|
||||||
|
decisionDigest: terminal.receipt.decisionDigest,
|
||||||
|
candidateSetDigest: intent.candidateSetDigest,
|
||||||
|
automationAdoptionSetDigest: intent.automationAdoptionSetDigest,
|
||||||
|
materials: readLocalReconciliationSecretConfigMaterials(
|
||||||
|
selected,
|
||||||
|
intent.profile,
|
||||||
|
uid,
|
||||||
|
intent.material,
|
||||||
|
),
|
||||||
|
principal: authenticatedScope.authenticated.principal,
|
||||||
|
appliedAtMs: intent.command.request.appliedAtMs,
|
||||||
|
authorizationAtMs: command.request.rolledBackAtMs,
|
||||||
|
...(command.options.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||||
|
confirmAuthenticationAuthority: () =>
|
||||||
|
authenticatedScope.authenticated.confirm(),
|
||||||
|
confirmPreparedAuthority() {
|
||||||
|
assertPreparedAuthority(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_secret_config_applied',
|
||||||
|
receipt.applyDigest,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
replay.status !== 'existing' ||
|
||||||
|
replay.receipt.publicationDigest !== receipt.publicationDigest ||
|
||||||
|
replay.receipt.receiptDigest !== receipt.publisherReceiptDigest
|
||||||
|
) {
|
||||||
|
fail('rollback could not verify current publication');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await authenticatedScope.database.close();
|
||||||
|
}
|
||||||
|
prepareLocalReconciliationSecretConfigRollbackSource(selected, intent, uid);
|
||||||
|
const restored = await (
|
||||||
|
dependencies.restoreSnapshot ?? restoreLocalSqliteSnapshot
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
sourceSnapshotPath: selected.rollbackSource,
|
||||||
|
restoreStagePath: selected.restoreStage,
|
||||||
|
replacedDatabasePath: selected.replaced,
|
||||||
|
expectedCurrentSha256: receipt.targetAfter.sha256,
|
||||||
|
expectedSourceSha256: intent.backup.sha256,
|
||||||
|
preserveDatabaseIdentity: true,
|
||||||
|
profile: intent.profile,
|
||||||
|
...(command.options.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||||
|
});
|
||||||
|
dependencies.afterRestore?.();
|
||||||
|
const rollback = buildLocalReconciliationSecretConfigRollbackReceipt({
|
||||||
|
decisionId: receipt.decisionId,
|
||||||
|
secretConfigId: receipt.secretConfigId,
|
||||||
|
applyDigest: receipt.applyDigest,
|
||||||
|
restored: Object.freeze({
|
||||||
|
contractVersion: restored.contractVersion,
|
||||||
|
sha256: restored.sha256,
|
||||||
|
bytes: restored.bytes,
|
||||||
|
pageCount: restored.pageCount,
|
||||||
|
pageSize: restored.pageSize,
|
||||||
|
}),
|
||||||
|
rolledBackAtMs: command.request.rolledBackAtMs,
|
||||||
|
});
|
||||||
|
publishExactFile(
|
||||||
|
selected.rollbackReceipt,
|
||||||
|
localReconciliationSecretConfigApplyEvidenceContents(rollback),
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'secret config rollback receipt',
|
||||||
|
);
|
||||||
|
dependencies.afterRollbackReceipt?.();
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_secret_config_rolled_back',
|
||||||
|
rollback.rollbackDigest,
|
||||||
|
command.request.rolledBackAtMs,
|
||||||
|
);
|
||||||
|
dependencies.afterRollbackHead?.();
|
||||||
|
sealLocalReconciliationSecretConfigRolledBackStorage(selected, intent, uid);
|
||||||
|
dependencies.afterRollbackSeal?.();
|
||||||
|
return result(command.operation, 'rolled_back', receipt, intent, head);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyLocalReconciliationSecretConfigCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<Readonly<LocalReconciliationSecretConfigApplyResult>> {
|
||||||
|
return applyLocalReconciliationSecretConfig(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLocalReconciliationSecretConfigApplyCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<Readonly<LocalReconciliationSecretConfigApplyResult>> {
|
||||||
|
return verifyLocalReconciliationSecretConfigApply(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollbackLocalReconciliationSecretConfigApplyCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<Readonly<LocalReconciliationSecretConfigApplyResult>> {
|
||||||
|
return rollbackLocalReconciliationSecretConfigApply(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
);
|
||||||
|
}
|
||||||
+436
@@ -0,0 +1,436 @@
|
|||||||
|
import type {
|
||||||
|
LocalSqliteRolloutBackupEvidence,
|
||||||
|
LocalSqliteSnapshotEvidence,
|
||||||
|
} from '@qinglong/local-sqlite/rollout-safety';
|
||||||
|
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../../foundation/error';
|
||||||
|
import { cutoverDigest } from '../../../../cutover/targetEvidence';
|
||||||
|
import {
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyCommand,
|
||||||
|
type LocalReconciliationSecretConfigApplyCommand,
|
||||||
|
} from './contract';
|
||||||
|
|
||||||
|
const INTENT_SCHEMA =
|
||||||
|
'qinglong3-local-reconciliation-secret-config-apply-intent';
|
||||||
|
const RECEIPT_SCHEMA =
|
||||||
|
'qinglong3-local-reconciliation-secret-config-apply-receipt';
|
||||||
|
const ROLLBACK_SCHEMA =
|
||||||
|
'qinglong3-local-reconciliation-secret-config-rollback-receipt';
|
||||||
|
const DIGEST = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigMaterialEvidence {
|
||||||
|
readonly fileBytes: number;
|
||||||
|
readonly fileDigest: string;
|
||||||
|
readonly secretCount: number;
|
||||||
|
readonly activeBindingCount: number;
|
||||||
|
readonly disabledPreservationCount: number;
|
||||||
|
readonly materialSetDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigApplyIntent {
|
||||||
|
readonly schema: typeof INTENT_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly command: Readonly<LocalReconciliationSecretConfigApplyCommand>;
|
||||||
|
readonly instanceId: string;
|
||||||
|
readonly cutoverId: string;
|
||||||
|
readonly activationDigest: string;
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly generation: number;
|
||||||
|
readonly stoppedProofDigest: string;
|
||||||
|
readonly legacyInventoryDigest: string;
|
||||||
|
readonly candidateSetDigest: string;
|
||||||
|
readonly automationAdoptionSetDigest: string;
|
||||||
|
readonly material: Readonly<LocalReconciliationSecretConfigMaterialEvidence>;
|
||||||
|
readonly backup: Readonly<LocalSqliteRolloutBackupEvidence>;
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigApplyReceipt {
|
||||||
|
readonly schema: typeof RECEIPT_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly state: 'reconciliation_secret_config_applied';
|
||||||
|
readonly decisionId: string;
|
||||||
|
readonly secretConfigId: string;
|
||||||
|
readonly mutationId: string;
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
readonly preparedHeadDigest: string;
|
||||||
|
readonly publicationDigest: string;
|
||||||
|
readonly publisherReceiptDigest: string;
|
||||||
|
readonly activeBindingCount: number;
|
||||||
|
readonly disabledPreservationCount: number;
|
||||||
|
readonly updatedTaskCount: number;
|
||||||
|
readonly updatedTriggerCount: number;
|
||||||
|
readonly targetAfter: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
|
readonly appliedAtMs: number;
|
||||||
|
readonly applyDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigRollbackReceipt {
|
||||||
|
readonly schema: typeof ROLLBACK_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly state: 'reconciliation_secret_config_rolled_back';
|
||||||
|
readonly decisionId: string;
|
||||||
|
readonly secretConfigId: string;
|
||||||
|
readonly applyDigest: string;
|
||||||
|
readonly restored: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
|
readonly rolledBackAtMs: number;
|
||||||
|
readonly rollbackDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation secret config apply evidence ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(
|
||||||
|
value: unknown,
|
||||||
|
keys: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
fail(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
const selected = value as Record<string, unknown>;
|
||||||
|
const actual = Object.keys(selected).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
) {
|
||||||
|
fail(`${label} shape is invalid`);
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validDigest(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && DIGEST.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonnegative(value: unknown): value is number {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positive(value: unknown): value is number {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): Readonly<LocalSqliteSnapshotEvidence> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
['bytes', 'contractVersion', 'pageCount', 'pageSize', 'sha256'],
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!validDigest(selected.sha256) ||
|
||||||
|
!positive(selected.bytes) ||
|
||||||
|
!positive(selected.pageCount) ||
|
||||||
|
!positive(selected.pageSize) ||
|
||||||
|
!Number.isSafeInteger(selected.contractVersion) ||
|
||||||
|
(selected.contractVersion as number) < 1
|
||||||
|
) {
|
||||||
|
fail(`${label} is invalid`);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
contractVersion: selected.contractVersion,
|
||||||
|
sha256: selected.sha256,
|
||||||
|
bytes: selected.bytes,
|
||||||
|
pageCount: selected.pageCount,
|
||||||
|
pageSize: selected.pageSize,
|
||||||
|
}) as unknown as Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function backup(value: unknown): Readonly<LocalSqliteRolloutBackupEvidence> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'bytes',
|
||||||
|
'contractVersion',
|
||||||
|
'pageCount',
|
||||||
|
'pageSize',
|
||||||
|
'sha256',
|
||||||
|
'status',
|
||||||
|
'writeContractVersion',
|
||||||
|
],
|
||||||
|
'backup',
|
||||||
|
);
|
||||||
|
const base = snapshot(
|
||||||
|
{
|
||||||
|
bytes: selected.bytes,
|
||||||
|
contractVersion: selected.contractVersion,
|
||||||
|
pageCount: selected.pageCount,
|
||||||
|
pageSize: selected.pageSize,
|
||||||
|
sha256: selected.sha256,
|
||||||
|
},
|
||||||
|
'backup snapshot',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(selected.status !== 'prepared' && selected.status !== 'existing') ||
|
||||||
|
!Number.isSafeInteger(selected.writeContractVersion) ||
|
||||||
|
(selected.writeContractVersion as number) < 1
|
||||||
|
) {
|
||||||
|
fail('backup is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
status: selected.status,
|
||||||
|
writeContractVersion: selected.writeContractVersion,
|
||||||
|
...base,
|
||||||
|
}) as Readonly<LocalSqliteRolloutBackupEvidence>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function material(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigMaterialEvidence> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'activeBindingCount',
|
||||||
|
'disabledPreservationCount',
|
||||||
|
'fileBytes',
|
||||||
|
'fileDigest',
|
||||||
|
'materialSetDigest',
|
||||||
|
'secretCount',
|
||||||
|
],
|
||||||
|
'material',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!positive(selected.fileBytes) ||
|
||||||
|
!validDigest(selected.fileDigest) ||
|
||||||
|
!validDigest(selected.materialSetDigest) ||
|
||||||
|
!positive(selected.secretCount) ||
|
||||||
|
!nonnegative(selected.activeBindingCount) ||
|
||||||
|
!nonnegative(selected.disabledPreservationCount) ||
|
||||||
|
(selected.activeBindingCount as number) +
|
||||||
|
(selected.disabledPreservationCount as number) !==
|
||||||
|
selected.secretCount
|
||||||
|
) {
|
||||||
|
fail('material is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze(
|
||||||
|
selected,
|
||||||
|
) as unknown as Readonly<LocalReconciliationSecretConfigMaterialEvidence>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationSecretConfigApplyIntent(
|
||||||
|
input: Omit<
|
||||||
|
LocalReconciliationSecretConfigApplyIntent,
|
||||||
|
'schema' | 'schemaVersion' | 'preparationDigest'
|
||||||
|
>,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyIntent> {
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: INTENT_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
preparationDigest: cutoverDigest(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationSecretConfigApplyIntent(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyIntent> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'activationDigest',
|
||||||
|
'automationAdoptionSetDigest',
|
||||||
|
'backup',
|
||||||
|
'candidateSetDigest',
|
||||||
|
'command',
|
||||||
|
'cutoverId',
|
||||||
|
'generation',
|
||||||
|
'instanceId',
|
||||||
|
'legacyInventoryDigest',
|
||||||
|
'material',
|
||||||
|
'preparationDigest',
|
||||||
|
'profile',
|
||||||
|
'projectId',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'stoppedProofDigest',
|
||||||
|
],
|
||||||
|
'intent',
|
||||||
|
);
|
||||||
|
const { preparationDigest, ...raw } = selected;
|
||||||
|
const normalized = Object.freeze({
|
||||||
|
...raw,
|
||||||
|
command: normalizeLocalReconciliationSecretConfigApplyCommand(
|
||||||
|
selected.command,
|
||||||
|
),
|
||||||
|
material: material(selected.material),
|
||||||
|
backup: backup(selected.backup),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
selected.schema !== INTENT_SCHEMA ||
|
||||||
|
selected.schemaVersion !== 1 ||
|
||||||
|
!validDigest(preparationDigest) ||
|
||||||
|
cutoverDigest(raw) !== preparationDigest ||
|
||||||
|
cutoverDigest(normalized) !== preparationDigest ||
|
||||||
|
![
|
||||||
|
selected.activationDigest,
|
||||||
|
selected.stoppedProofDigest,
|
||||||
|
selected.legacyInventoryDigest,
|
||||||
|
selected.candidateSetDigest,
|
||||||
|
selected.automationAdoptionSetDigest,
|
||||||
|
].every(validDigest) ||
|
||||||
|
(selected.profile !== 'edge' && selected.profile !== 'standalone') ||
|
||||||
|
!positive(selected.generation) ||
|
||||||
|
![selected.instanceId, selected.cutoverId, selected.projectId].every(
|
||||||
|
(entry) => typeof entry === 'string' && entry.length > 0,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail('intent binding is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...normalized,
|
||||||
|
preparationDigest,
|
||||||
|
}) as unknown as Readonly<LocalReconciliationSecretConfigApplyIntent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationSecretConfigApplyReceipt(
|
||||||
|
input: Omit<
|
||||||
|
LocalReconciliationSecretConfigApplyReceipt,
|
||||||
|
'schema' | 'schemaVersion' | 'state' | 'applyDigest'
|
||||||
|
>,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyReceipt> {
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: RECEIPT_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
state: 'reconciliation_secret_config_applied' as const,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
return Object.freeze({ ...payload, applyDigest: cutoverDigest(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationSecretConfigApplyReceipt(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyReceipt> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'activeBindingCount',
|
||||||
|
'appliedAtMs',
|
||||||
|
'applyDigest',
|
||||||
|
'decisionId',
|
||||||
|
'disabledPreservationCount',
|
||||||
|
'mutationId',
|
||||||
|
'preparationDigest',
|
||||||
|
'preparedHeadDigest',
|
||||||
|
'publicationDigest',
|
||||||
|
'publisherReceiptDigest',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'secretConfigId',
|
||||||
|
'state',
|
||||||
|
'targetAfter',
|
||||||
|
'updatedTaskCount',
|
||||||
|
'updatedTriggerCount',
|
||||||
|
],
|
||||||
|
'receipt',
|
||||||
|
);
|
||||||
|
const { applyDigest, ...payload } = selected;
|
||||||
|
const targetAfter = snapshot(selected.targetAfter, 'targetAfter');
|
||||||
|
if (
|
||||||
|
selected.schema !== RECEIPT_SCHEMA ||
|
||||||
|
selected.schemaVersion !== 1 ||
|
||||||
|
selected.state !== 'reconciliation_secret_config_applied' ||
|
||||||
|
!validDigest(applyDigest) ||
|
||||||
|
cutoverDigest(payload) !== applyDigest ||
|
||||||
|
![
|
||||||
|
selected.preparationDigest,
|
||||||
|
selected.preparedHeadDigest,
|
||||||
|
selected.publicationDigest,
|
||||||
|
selected.publisherReceiptDigest,
|
||||||
|
].every(validDigest) ||
|
||||||
|
![
|
||||||
|
selected.activeBindingCount,
|
||||||
|
selected.disabledPreservationCount,
|
||||||
|
selected.updatedTaskCount,
|
||||||
|
selected.updatedTriggerCount,
|
||||||
|
selected.appliedAtMs,
|
||||||
|
].every(nonnegative) ||
|
||||||
|
typeof selected.decisionId !== 'string' ||
|
||||||
|
typeof selected.secretConfigId !== 'string' ||
|
||||||
|
typeof selected.mutationId !== 'string'
|
||||||
|
) {
|
||||||
|
fail('receipt binding is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
targetAfter,
|
||||||
|
applyDigest,
|
||||||
|
}) as unknown as Readonly<LocalReconciliationSecretConfigApplyReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationSecretConfigRollbackReceipt(
|
||||||
|
input: Omit<
|
||||||
|
LocalReconciliationSecretConfigRollbackReceipt,
|
||||||
|
'schema' | 'schemaVersion' | 'state' | 'rollbackDigest'
|
||||||
|
>,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigRollbackReceipt> {
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: ROLLBACK_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
state: 'reconciliation_secret_config_rolled_back' as const,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
rollbackDigest: cutoverDigest(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationSecretConfigRollbackReceipt(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigRollbackReceipt> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'applyDigest',
|
||||||
|
'decisionId',
|
||||||
|
'restored',
|
||||||
|
'rollbackDigest',
|
||||||
|
'rolledBackAtMs',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'secretConfigId',
|
||||||
|
'state',
|
||||||
|
],
|
||||||
|
'rollback receipt',
|
||||||
|
);
|
||||||
|
const { rollbackDigest, ...payload } = selected;
|
||||||
|
const restored = snapshot(selected.restored, 'restored');
|
||||||
|
if (
|
||||||
|
selected.schema !== ROLLBACK_SCHEMA ||
|
||||||
|
selected.schemaVersion !== 1 ||
|
||||||
|
selected.state !== 'reconciliation_secret_config_rolled_back' ||
|
||||||
|
!validDigest(selected.applyDigest) ||
|
||||||
|
!validDigest(rollbackDigest) ||
|
||||||
|
cutoverDigest(payload) !== rollbackDigest ||
|
||||||
|
!nonnegative(selected.rolledBackAtMs) ||
|
||||||
|
typeof selected.decisionId !== 'string' ||
|
||||||
|
typeof selected.secretConfigId !== 'string'
|
||||||
|
) {
|
||||||
|
fail('rollback receipt binding is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
restored,
|
||||||
|
rollbackDigest,
|
||||||
|
}) as unknown as Readonly<LocalReconciliationSecretConfigRollbackReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localReconciliationSecretConfigApplyEvidenceContents(
|
||||||
|
value:
|
||||||
|
| Readonly<LocalReconciliationSecretConfigApplyIntent>
|
||||||
|
| Readonly<LocalReconciliationSecretConfigApplyReceipt>
|
||||||
|
| Readonly<LocalReconciliationSecretConfigRollbackReceipt>,
|
||||||
|
): string {
|
||||||
|
return `${JSON.stringify(value, null, 2)}\n`;
|
||||||
|
}
|
||||||
+676
@@ -0,0 +1,676 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizePreparedReconciliationSecretConfigMaterial,
|
||||||
|
type PreparedReconciliationSecretConfigMaterial,
|
||||||
|
} from '@qinglong/local-admin/reconciliation-secret-and-config-application';
|
||||||
|
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../../foundation/error';
|
||||||
|
import {
|
||||||
|
preflightPublishedFile,
|
||||||
|
publishExactFile,
|
||||||
|
} from '../../../../foundation/files';
|
||||||
|
import type {
|
||||||
|
LocalReconciliationSecretConfigApplyIntent,
|
||||||
|
LocalReconciliationSecretConfigApplyReceipt,
|
||||||
|
LocalReconciliationSecretConfigMaterialEvidence,
|
||||||
|
LocalReconciliationSecretConfigRollbackReceipt,
|
||||||
|
} from './evidence';
|
||||||
|
import {
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyIntent,
|
||||||
|
normalizeLocalReconciliationSecretConfigApplyReceipt,
|
||||||
|
normalizeLocalReconciliationSecretConfigRollbackReceipt,
|
||||||
|
} from './evidence';
|
||||||
|
|
||||||
|
const MAX_JSON_BYTES = 64 * 1024;
|
||||||
|
const MAX_LINE_BYTES = 64 * 1024;
|
||||||
|
const MAX_EDGE_MATERIAL_BYTES = 4 * 1024 * 1024;
|
||||||
|
const MAX_STANDALONE_MATERIAL_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigApplyPaths {
|
||||||
|
readonly root: string;
|
||||||
|
readonly backupRoot: string;
|
||||||
|
readonly rollbackRoot: string;
|
||||||
|
readonly intent: string;
|
||||||
|
readonly material: string;
|
||||||
|
readonly backup: string;
|
||||||
|
readonly receipt: string;
|
||||||
|
readonly rollbackReceipt: string;
|
||||||
|
readonly rollbackSource: string;
|
||||||
|
readonly restoreStage: string;
|
||||||
|
readonly replaced: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string, cause?: unknown): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation secret config apply storage ${message}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localReconciliationSecretConfigApplyPaths(
|
||||||
|
root: string,
|
||||||
|
secretConfigId: string,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyPaths> {
|
||||||
|
const selected = path.join(root, secretConfigId);
|
||||||
|
const backupRoot = path.join(selected, 'backup');
|
||||||
|
const rollbackRoot = path.join(selected, 'rollback-work');
|
||||||
|
return Object.freeze({
|
||||||
|
root: selected,
|
||||||
|
backupRoot,
|
||||||
|
rollbackRoot,
|
||||||
|
intent: path.join(selected, 'intent.json'),
|
||||||
|
material: path.join(selected, 'materials.ndjson'),
|
||||||
|
backup: path.join(backupRoot, 'before.sqlite'),
|
||||||
|
receipt: path.join(selected, 'receipt.json'),
|
||||||
|
rollbackReceipt: path.join(rollbackRoot, 'receipt.json'),
|
||||||
|
rollbackSource: path.join(rollbackRoot, 'restore-source.sqlite'),
|
||||||
|
restoreStage: path.join(rollbackRoot, 'restore-stage.sqlite'),
|
||||||
|
replaced: path.join(rollbackRoot, 'replaced.sqlite'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryMode(
|
||||||
|
directory: string,
|
||||||
|
uid: number,
|
||||||
|
modes: readonly number[],
|
||||||
|
label: string,
|
||||||
|
): number {
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.lstatSync(directory);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(`${label} is unavailable`, error);
|
||||||
|
}
|
||||||
|
const mode = stat.mode & 0o777;
|
||||||
|
if (
|
||||||
|
!stat.isDirectory() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.uid !== uid ||
|
||||||
|
!modes.includes(mode) ||
|
||||||
|
fs.realpathSync(directory) !== directory
|
||||||
|
) {
|
||||||
|
fail(`${label} identity is invalid`);
|
||||||
|
}
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDirectory(directory: string, uid: number, label: string): void {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(directory, { mode: 0o700 });
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||||
|
fail(`${label} cannot be created`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
directoryMode(directory, uid, [0o700], label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureLocalReconciliationSecretConfigApplyLayout(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
if (!fs.existsSync(selected.root))
|
||||||
|
ensureDirectory(selected.root, uid, 'root');
|
||||||
|
const rootMode = directoryMode(selected.root, uid, [0o700, 0o500], 'root');
|
||||||
|
if (rootMode === 0o700) {
|
||||||
|
if (!fs.existsSync(selected.backupRoot)) {
|
||||||
|
ensureDirectory(selected.backupRoot, uid, 'backup root');
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(selected.rollbackRoot)) {
|
||||||
|
ensureDirectory(selected.rollbackRoot, uid, 'rollback work root');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
directoryMode(selected.backupRoot, uid, [0o700, 0o500], 'backup root');
|
||||||
|
directoryMode(
|
||||||
|
selected.rollbackRoot,
|
||||||
|
uid,
|
||||||
|
[0o700, 0o500],
|
||||||
|
'rollback work root',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLocalReconciliationSecretConfigApplyCatalog(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
): void {
|
||||||
|
const rootAllowed = new Set([
|
||||||
|
'backup',
|
||||||
|
'rollback-work',
|
||||||
|
'intent.json',
|
||||||
|
'materials.ndjson',
|
||||||
|
'receipt.json',
|
||||||
|
'.intent.json.ql3-deploy-stage',
|
||||||
|
'.materials.ndjson.ql3-deploy-stage',
|
||||||
|
'.receipt.json.ql3-deploy-stage',
|
||||||
|
]);
|
||||||
|
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
||||||
|
if (!rootAllowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||||
|
fail('root contains unknown material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const backupAllowed = new Set([
|
||||||
|
'before.sqlite',
|
||||||
|
'.before.sqlite.ql3-backup-stage',
|
||||||
|
]);
|
||||||
|
for (const entry of fs.readdirSync(selected.backupRoot, {
|
||||||
|
withFileTypes: true,
|
||||||
|
})) {
|
||||||
|
if (!backupAllowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||||
|
fail('backup root contains unknown material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rollbackAllowed = new Set([
|
||||||
|
'receipt.json',
|
||||||
|
'.receipt.json.ql3-deploy-stage',
|
||||||
|
'restore-source.sqlite',
|
||||||
|
'restore-stage.sqlite',
|
||||||
|
'replaced.sqlite',
|
||||||
|
]);
|
||||||
|
for (const entry of fs.readdirSync(selected.rollbackRoot, {
|
||||||
|
withFileTypes: true,
|
||||||
|
})) {
|
||||||
|
if (!rollbackAllowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||||
|
fail('rollback work root contains unknown material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableBytes(
|
||||||
|
filePath: string,
|
||||||
|
uid: number,
|
||||||
|
modes: readonly number[],
|
||||||
|
maximumBytes: number,
|
||||||
|
label: string,
|
||||||
|
): Buffer {
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
try {
|
||||||
|
const before = fs.lstatSync(filePath, { bigint: true });
|
||||||
|
if (
|
||||||
|
!before.isFile() ||
|
||||||
|
before.isSymbolicLink() ||
|
||||||
|
Number(before.uid) !== uid ||
|
||||||
|
!modes.includes(Number(before.mode) & 0o777) ||
|
||||||
|
before.nlink !== 1n ||
|
||||||
|
before.size < 2n ||
|
||||||
|
before.size > BigInt(maximumBytes) ||
|
||||||
|
fs.realpathSync(filePath) !== filePath
|
||||||
|
) {
|
||||||
|
return fail(`${label} 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 ||
|
||||||
|
opened.mtimeNs !== before.mtimeNs ||
|
||||||
|
opened.ctimeNs !== before.ctimeNs
|
||||||
|
) {
|
||||||
|
return fail(`${label} changed while opening`);
|
||||||
|
}
|
||||||
|
const bytes = Buffer.alloc(Number(opened.size));
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < bytes.length) {
|
||||||
|
const read = fs.readSync(
|
||||||
|
descriptor,
|
||||||
|
bytes,
|
||||||
|
offset,
|
||||||
|
bytes.length - offset,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
if (read < 1) {
|
||||||
|
bytes.fill(0);
|
||||||
|
return fail(`${label} read stalled`);
|
||||||
|
}
|
||||||
|
offset += read;
|
||||||
|
}
|
||||||
|
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||||
|
if (
|
||||||
|
after.dev !== opened.dev ||
|
||||||
|
after.ino !== opened.ino ||
|
||||||
|
after.size !== opened.size ||
|
||||||
|
after.mtimeNs !== opened.mtimeNs ||
|
||||||
|
after.ctimeNs !== opened.ctimeNs
|
||||||
|
) {
|
||||||
|
bytes.fill(0);
|
||||||
|
return fail(`${label} drifted while reading`);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
return fail(`${label} cannot be read`, error);
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableJson(
|
||||||
|
filePath: string,
|
||||||
|
uid: number,
|
||||||
|
modes: readonly number[],
|
||||||
|
label: string,
|
||||||
|
): unknown {
|
||||||
|
const bytes = stableBytes(filePath, uid, modes, MAX_JSON_BYTES, label);
|
||||||
|
try {
|
||||||
|
return JSON.parse(
|
||||||
|
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
|
||||||
|
) as unknown;
|
||||||
|
} catch (error) {
|
||||||
|
return fail(`${label} is not UTF-8 JSON`, error);
|
||||||
|
} finally {
|
||||||
|
bytes.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishLocalReconciliationSecretConfigMaterials(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
materials: readonly Readonly<PreparedReconciliationSecretConfigMaterial>[],
|
||||||
|
profile: 'edge' | 'standalone',
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigMaterialEvidence> {
|
||||||
|
if (materials.length < 1) fail('material set must not be empty');
|
||||||
|
const lines = materials.map((entry, index) => {
|
||||||
|
const normalized =
|
||||||
|
normalizePreparedReconciliationSecretConfigMaterial(entry);
|
||||||
|
if (normalized.ordinal !== index + 1) fail('material ordinal drifted');
|
||||||
|
const line = `${JSON.stringify(normalized)}\n`;
|
||||||
|
if (Buffer.byteLength(line, 'utf8') > MAX_LINE_BYTES) {
|
||||||
|
fail('material line exceeds its byte bound');
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
const contents = lines.join('');
|
||||||
|
const fileBytes = Buffer.byteLength(contents, 'utf8');
|
||||||
|
const maximum =
|
||||||
|
profile === 'edge'
|
||||||
|
? MAX_EDGE_MATERIAL_BYTES
|
||||||
|
: MAX_STANDALONE_MATERIAL_BYTES;
|
||||||
|
if (fileBytes > maximum) fail('material file exceeds its profile budget');
|
||||||
|
const materialHash = createHash('sha256').update(
|
||||||
|
'qinglong3.reconciliation-secret-config-material-set.v1\0',
|
||||||
|
);
|
||||||
|
for (const entry of materials) {
|
||||||
|
materialHash.update('\0').update(JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
const evidence = Object.freeze({
|
||||||
|
fileBytes,
|
||||||
|
fileDigest: createHash('sha256').update(contents).digest('hex'),
|
||||||
|
secretCount: materials.length,
|
||||||
|
activeBindingCount: materials.filter(
|
||||||
|
(entry) => entry.disposition === 'active_binding',
|
||||||
|
).length,
|
||||||
|
disabledPreservationCount: materials.filter(
|
||||||
|
(entry) => entry.disposition === 'disabled_preservation',
|
||||||
|
).length,
|
||||||
|
materialSetDigest: materialHash.digest('hex'),
|
||||||
|
});
|
||||||
|
preflightPublishedFile(
|
||||||
|
selected.material,
|
||||||
|
contents,
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'secret config prepared materials',
|
||||||
|
);
|
||||||
|
publishExactFile(
|
||||||
|
selected.material,
|
||||||
|
contents,
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'secret config prepared materials',
|
||||||
|
);
|
||||||
|
return evidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discardUnpreparedLocalReconciliationSecretConfigMaterials(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
): void {
|
||||||
|
if (fs.existsSync(selected.intent)) {
|
||||||
|
fail('prepared materials are already authoritative');
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(selected.material)) return;
|
||||||
|
fs.unlinkSync(selected.material);
|
||||||
|
syncDirectory(selected.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLocalReconciliationSecretConfigMaterials(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
profile: 'edge' | 'standalone',
|
||||||
|
uid: number,
|
||||||
|
expected?: Readonly<LocalReconciliationSecretConfigMaterialEvidence>,
|
||||||
|
): readonly Readonly<PreparedReconciliationSecretConfigMaterial>[] {
|
||||||
|
const bytes = stableBytes(
|
||||||
|
selected.material,
|
||||||
|
uid,
|
||||||
|
[0o600, 0o400],
|
||||||
|
profile === 'edge'
|
||||||
|
? MAX_EDGE_MATERIAL_BYTES
|
||||||
|
: MAX_STANDALONE_MATERIAL_BYTES,
|
||||||
|
'prepared materials',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||||
|
if (!text.endsWith('\n')) fail('material file is not newline framed');
|
||||||
|
const rawLines = text.slice(0, -1).split('\n');
|
||||||
|
const materials = rawLines.map((line, index) => {
|
||||||
|
if (Buffer.byteLength(line, 'utf8') > MAX_LINE_BYTES) {
|
||||||
|
return fail('material line exceeds its byte bound');
|
||||||
|
}
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(line) as unknown;
|
||||||
|
} catch (error) {
|
||||||
|
return fail('material line is not JSON', error);
|
||||||
|
}
|
||||||
|
const normalized =
|
||||||
|
normalizePreparedReconciliationSecretConfigMaterial(value);
|
||||||
|
if (normalized.ordinal !== index + 1) fail('material ordinal drifted');
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
const materialHash = createHash('sha256').update(
|
||||||
|
'qinglong3.reconciliation-secret-config-material-set.v1\0',
|
||||||
|
);
|
||||||
|
for (const entry of materials) {
|
||||||
|
materialHash.update('\0').update(JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
const evidence = {
|
||||||
|
fileBytes: bytes.length,
|
||||||
|
fileDigest: createHash('sha256').update(bytes).digest('hex'),
|
||||||
|
secretCount: materials.length,
|
||||||
|
activeBindingCount: materials.filter(
|
||||||
|
(entry) => entry.disposition === 'active_binding',
|
||||||
|
).length,
|
||||||
|
disabledPreservationCount: materials.filter(
|
||||||
|
(entry) => entry.disposition === 'disabled_preservation',
|
||||||
|
).length,
|
||||||
|
materialSetDigest: materialHash.digest('hex'),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
expected &&
|
||||||
|
Object.keys(evidence).some(
|
||||||
|
(key) =>
|
||||||
|
evidence[key as keyof typeof evidence] !==
|
||||||
|
expected[key as keyof typeof evidence],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail('material evidence drifted');
|
||||||
|
}
|
||||||
|
return Object.freeze(materials);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
return fail('material file cannot be decoded', error);
|
||||||
|
} finally {
|
||||||
|
bytes.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLocalReconciliationSecretConfigApplyIntent(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyIntent> {
|
||||||
|
return normalizeLocalReconciliationSecretConfigApplyIntent(
|
||||||
|
stableJson(selected.intent, uid, [0o600, 0o400], 'intent'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLocalReconciliationSecretConfigApplyReceipt(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigApplyReceipt> {
|
||||||
|
return normalizeLocalReconciliationSecretConfigApplyReceipt(
|
||||||
|
stableJson(selected.receipt, uid, [0o600, 0o400], 'receipt'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLocalReconciliationSecretConfigRollbackReceipt(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
uid: number,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigRollbackReceipt> {
|
||||||
|
return normalizeLocalReconciliationSecretConfigRollbackReceipt(
|
||||||
|
stableJson(
|
||||||
|
selected.rollbackReceipt,
|
||||||
|
uid,
|
||||||
|
[0o600, 0o400],
|
||||||
|
'rollback receipt',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFile(filePath: string): void {
|
||||||
|
const descriptor = fs.openSync(
|
||||||
|
filePath,
|
||||||
|
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDirectory(directory: string): void {
|
||||||
|
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
||||||
|
try {
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sealFile(filePath: string, uid: number, label: string): void {
|
||||||
|
const stat = fs.lstatSync(filePath);
|
||||||
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.uid !== uid ||
|
||||||
|
stat.nlink !== 1 ||
|
||||||
|
![0o600, 0o400].includes(stat.mode & 0o777)
|
||||||
|
) {
|
||||||
|
fail(`${label} cannot be sealed`);
|
||||||
|
}
|
||||||
|
if ((stat.mode & 0o777) !== 0o400) fs.chmodSync(filePath, 0o400);
|
||||||
|
syncFile(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sealDirectory(directory: string, uid: number, label: string): void {
|
||||||
|
const mode = directoryMode(directory, uid, [0o700, 0o500], label);
|
||||||
|
if (mode !== 0o500) fs.chmodSync(directory, 0o500);
|
||||||
|
syncDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableFileSha256(
|
||||||
|
filePath: string,
|
||||||
|
uid: number,
|
||||||
|
modes: readonly number[],
|
||||||
|
expectedBytes: number,
|
||||||
|
expectedSha256: string,
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const bytes = stableBytes(filePath, uid, modes, expectedBytes, label);
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
bytes.length !== expectedBytes ||
|
||||||
|
createHash('sha256').update(bytes).digest('hex') !== expectedSha256
|
||||||
|
) {
|
||||||
|
fail(`${label} digest drifted`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bytes.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBackup(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
uid: number,
|
||||||
|
modes: readonly number[],
|
||||||
|
): void {
|
||||||
|
stableFileSha256(
|
||||||
|
selected.backup,
|
||||||
|
uid,
|
||||||
|
modes,
|
||||||
|
intent.backup.bytes,
|
||||||
|
intent.backup.sha256,
|
||||||
|
'backup',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealLocalReconciliationSecretConfigAppliedStorage(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
validateLocalReconciliationSecretConfigApplyCatalog(selected);
|
||||||
|
validateBackup(selected, intent, uid, [0o600, 0o400]);
|
||||||
|
readLocalReconciliationSecretConfigMaterials(
|
||||||
|
selected,
|
||||||
|
intent.profile,
|
||||||
|
uid,
|
||||||
|
intent.material,
|
||||||
|
);
|
||||||
|
if (fs.readdirSync(selected.rollbackRoot).length !== 0) {
|
||||||
|
fail('rollback work root must be empty');
|
||||||
|
}
|
||||||
|
for (const [file, label] of [
|
||||||
|
[selected.intent, 'intent'],
|
||||||
|
[selected.material, 'materials'],
|
||||||
|
[selected.receipt, 'receipt'],
|
||||||
|
[selected.backup, 'backup'],
|
||||||
|
] as const) {
|
||||||
|
sealFile(file, uid, label);
|
||||||
|
}
|
||||||
|
sealDirectory(selected.backupRoot, uid, 'backup root');
|
||||||
|
sealDirectory(selected.root, uid, 'root');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLocalReconciliationSecretConfigAppliedStorage(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
directoryMode(selected.root, uid, [0o500], 'root');
|
||||||
|
directoryMode(selected.backupRoot, uid, [0o500], 'backup root');
|
||||||
|
directoryMode(selected.rollbackRoot, uid, [0o700], 'rollback work root');
|
||||||
|
validateLocalReconciliationSecretConfigApplyCatalog(selected);
|
||||||
|
if (fs.readdirSync(selected.rollbackRoot).length !== 0) {
|
||||||
|
fail('rollback work root must be empty');
|
||||||
|
}
|
||||||
|
readLocalReconciliationSecretConfigApplyIntent(selected, uid);
|
||||||
|
readLocalReconciliationSecretConfigApplyReceipt(selected, uid);
|
||||||
|
readLocalReconciliationSecretConfigMaterials(
|
||||||
|
selected,
|
||||||
|
intent.profile,
|
||||||
|
uid,
|
||||||
|
intent.material,
|
||||||
|
);
|
||||||
|
validateBackup(selected, intent, uid, [0o400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareLocalReconciliationSecretConfigRollbackSource(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
directoryMode(selected.root, uid, [0o500], 'root');
|
||||||
|
directoryMode(selected.backupRoot, uid, [0o500], 'backup root');
|
||||||
|
directoryMode(selected.rollbackRoot, uid, [0o700], 'rollback work root');
|
||||||
|
validateBackup(selected, intent, uid, [0o400]);
|
||||||
|
if (!fs.existsSync(selected.rollbackSource)) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(
|
||||||
|
selected.backup,
|
||||||
|
selected.rollbackSource,
|
||||||
|
fs.constants.COPYFILE_EXCL,
|
||||||
|
);
|
||||||
|
fs.chmodSync(selected.rollbackSource, 0o600);
|
||||||
|
syncFile(selected.rollbackSource);
|
||||||
|
syncDirectory(selected.rollbackRoot);
|
||||||
|
} catch (error) {
|
||||||
|
if (fs.existsSync(selected.rollbackSource))
|
||||||
|
fs.unlinkSync(selected.rollbackSource);
|
||||||
|
fail('rollback source cannot be prepared', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stableFileSha256(
|
||||||
|
selected.rollbackSource,
|
||||||
|
uid,
|
||||||
|
[0o600],
|
||||||
|
intent.backup.bytes,
|
||||||
|
intent.backup.sha256,
|
||||||
|
'rollback source',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlinkIfPresent(filePath: string): void {
|
||||||
|
if (!fs.existsSync(filePath)) return;
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
syncDirectory(path.dirname(filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealLocalReconciliationSecretConfigRolledBackStorage(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
validateLocalReconciliationSecretConfigApplyCatalog(selected);
|
||||||
|
if (fs.existsSync(selected.backup)) {
|
||||||
|
validateBackup(selected, intent, uid, [0o400]);
|
||||||
|
}
|
||||||
|
for (const temporary of [
|
||||||
|
selected.restoreStage,
|
||||||
|
selected.replaced,
|
||||||
|
selected.rollbackSource,
|
||||||
|
]) {
|
||||||
|
unlinkIfPresent(temporary);
|
||||||
|
}
|
||||||
|
if ((fs.statSync(selected.backupRoot).mode & 0o777) !== 0o700) {
|
||||||
|
fs.chmodSync(selected.backupRoot, 0o700);
|
||||||
|
syncDirectory(selected.root);
|
||||||
|
}
|
||||||
|
unlinkIfPresent(selected.backup);
|
||||||
|
for (const [file, label] of [
|
||||||
|
[selected.intent, 'intent'],
|
||||||
|
[selected.material, 'materials'],
|
||||||
|
[selected.receipt, 'receipt'],
|
||||||
|
[selected.rollbackReceipt, 'rollback receipt'],
|
||||||
|
] as const) {
|
||||||
|
sealFile(file, uid, label);
|
||||||
|
}
|
||||||
|
sealDirectory(selected.backupRoot, uid, 'backup root');
|
||||||
|
sealDirectory(selected.rollbackRoot, uid, 'rollback work root');
|
||||||
|
sealDirectory(selected.root, uid, 'root');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLocalReconciliationSecretConfigRolledBackStorage(
|
||||||
|
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||||
|
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
directoryMode(selected.root, uid, [0o500], 'root');
|
||||||
|
directoryMode(selected.backupRoot, uid, [0o500], 'backup root');
|
||||||
|
directoryMode(selected.rollbackRoot, uid, [0o500], 'rollback work root');
|
||||||
|
validateLocalReconciliationSecretConfigApplyCatalog(selected);
|
||||||
|
if (fs.readdirSync(selected.backupRoot).length !== 0) {
|
||||||
|
fail('sealed backup root is not empty');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fs
|
||||||
|
.readdirSync(selected.rollbackRoot)
|
||||||
|
.some((entry) => entry !== 'receipt.json')
|
||||||
|
) {
|
||||||
|
fail('sealed rollback work root contains temporary material');
|
||||||
|
}
|
||||||
|
readLocalReconciliationSecretConfigApplyIntent(selected, uid);
|
||||||
|
readLocalReconciliationSecretConfigApplyReceipt(selected, uid);
|
||||||
|
readLocalReconciliationSecretConfigRollbackReceipt(selected, uid);
|
||||||
|
readLocalReconciliationSecretConfigMaterials(
|
||||||
|
selected,
|
||||||
|
intent.profile,
|
||||||
|
uid,
|
||||||
|
intent.material,
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
-5
@@ -1132,6 +1132,7 @@ export async function readLocalReconciliationSecretConfigDecisionTerminal(
|
|||||||
>,
|
>,
|
||||||
secretConfigId: string,
|
secretConfigId: string,
|
||||||
uid: number,
|
uid: number,
|
||||||
|
acceptedSuccessorStates: readonly LocalCutoverInstanceHead['state'][] = [],
|
||||||
): Promise<Readonly<LocalReconciliationSecretConfigDecisionTerminal>> {
|
): Promise<Readonly<LocalReconciliationSecretConfigDecisionTerminal>> {
|
||||||
for (const [directory, label] of [
|
for (const [directory, label] of [
|
||||||
[options.deploymentRoot, 'deploymentRoot'],
|
[options.deploymentRoot, 'deploymentRoot'],
|
||||||
@@ -1171,11 +1172,15 @@ export async function readLocalReconciliationSecretConfigDecisionTerminal(
|
|||||||
intent.instanceId,
|
intent.instanceId,
|
||||||
uid,
|
uid,
|
||||||
);
|
);
|
||||||
if (
|
const reviewed =
|
||||||
head.state !== 'reconciliation_secret_config_reviewed' ||
|
head.state === 'reconciliation_secret_config_reviewed' &&
|
||||||
head.previousHeadDigest !== receipt.preparedHeadDigest ||
|
head.previousHeadDigest === receipt.preparedHeadDigest &&
|
||||||
head.sourceRecordDigest !== receipt.decisionDigest
|
head.sourceRecordDigest === receipt.decisionDigest;
|
||||||
) {
|
const successor =
|
||||||
|
acceptedSuccessorStates.includes(head.state) &&
|
||||||
|
head.generation === intent.generation &&
|
||||||
|
head.updatedAtMs >= receipt.issuedAtMs;
|
||||||
|
if (!reviewed && !successor) {
|
||||||
configurationError('terminal decision is detached from instance head');
|
configurationError('terminal decision is detached from instance head');
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
+2
@@ -28,6 +28,7 @@ export interface LocalReconciliationSecretConfigDecisionRequirement {
|
|||||||
readonly candidateType: 'active_binding' | 'disabled_preservation';
|
readonly candidateType: 'active_binding' | 'disabled_preservation';
|
||||||
readonly candidateDigest: string;
|
readonly candidateDigest: string;
|
||||||
readonly sourceSetDigest: string;
|
readonly sourceSetDigest: string;
|
||||||
|
readonly proposedSecretName: string;
|
||||||
readonly requirement: LocalReconciliationSecretConfigCandidateRequirement;
|
readonly requirement: LocalReconciliationSecretConfigCandidateRequirement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +196,7 @@ function candidate(
|
|||||||
| 'disabled_preservation',
|
| 'disabled_preservation',
|
||||||
candidateDigest: record.candidateDigest,
|
candidateDigest: record.candidateDigest,
|
||||||
sourceSetDigest: record.sourceSetDigest,
|
sourceSetDigest: record.sourceSetDigest,
|
||||||
|
proposedSecretName: record.proposedSecretName as string,
|
||||||
requirement:
|
requirement:
|
||||||
record.requirement as LocalReconciliationSecretConfigCandidateRequirement,
|
record.requirement as LocalReconciliationSecretConfigCandidateRequirement,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
commitLocalReconciliationApplication,
|
commitLocalReconciliationApplication,
|
||||||
commitLocalReconciliationAutomationDecision,
|
commitLocalReconciliationAutomationDecision,
|
||||||
commitLocalReconciliationSecretConfigDecision,
|
commitLocalReconciliationSecretConfigDecision,
|
||||||
|
applyLocalReconciliationSecretConfig,
|
||||||
completeLocalReconciliation,
|
completeLocalReconciliation,
|
||||||
applyLocalReconciliationAutomation,
|
applyLocalReconciliationAutomation,
|
||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
@@ -24,6 +25,7 @@ const {
|
|||||||
readLocalReconciliationAutomationDecisionTerminal,
|
readLocalReconciliationAutomationDecisionTerminal,
|
||||||
readLocalReconciliationSecretConfigDecisionTerminal,
|
readLocalReconciliationSecretConfigDecisionTerminal,
|
||||||
rollbackLocalReconciliationAutomationApply,
|
rollbackLocalReconciliationAutomationApply,
|
||||||
|
rollbackLocalReconciliationSecretConfigApply,
|
||||||
planLocalReconciliationAutomation,
|
planLocalReconciliationAutomation,
|
||||||
planLocalReconciliationSecretConfig,
|
planLocalReconciliationSecretConfig,
|
||||||
prepareLocalReconciliationPlan,
|
prepareLocalReconciliationPlan,
|
||||||
@@ -32,6 +34,7 @@ const {
|
|||||||
verifyLocalReconciliationApplication,
|
verifyLocalReconciliationApplication,
|
||||||
verifyLocalReconciliationAutomationDecision,
|
verifyLocalReconciliationAutomationDecision,
|
||||||
verifyLocalReconciliationSecretConfigDecision,
|
verifyLocalReconciliationSecretConfigDecision,
|
||||||
|
verifyLocalReconciliationSecretConfigApply,
|
||||||
verifyLocalReconciliationAutomationApply,
|
verifyLocalReconciliationAutomationApply,
|
||||||
verifyLocalReconciliationAutomationPlan,
|
verifyLocalReconciliationAutomationPlan,
|
||||||
verifyLocalReconciliationSecretConfigPlan,
|
verifyLocalReconciliationSecretConfigPlan,
|
||||||
@@ -41,6 +44,7 @@ const {
|
|||||||
verifyLocalReconciliationRunHistory,
|
verifyLocalReconciliationRunHistory,
|
||||||
writeLocalReconciliationReviewDiagnostics,
|
writeLocalReconciliationReviewDiagnostics,
|
||||||
} = require('../dist/deployment/localDeployment.js');
|
} = require('../dist/deployment/localDeployment.js');
|
||||||
|
const { provisionLocalSecretKeyring } = require('@qinglong/local-secret');
|
||||||
const {
|
const {
|
||||||
normalizeLocalReconciliationCaptureManifest,
|
normalizeLocalReconciliationCaptureManifest,
|
||||||
} = require('../dist/deployment/reconciliation/bundle.js');
|
} = require('../dist/deployment/reconciliation/bundle.js');
|
||||||
@@ -846,6 +850,20 @@ function secretConfigDatabaseInitializer({
|
|||||||
);
|
);
|
||||||
assert.equal(migration.status, 0, migration.stderr);
|
assert.equal(migration.status, 0, migration.stderr);
|
||||||
fs.chmodSync(targetDatabasePath, 0o600);
|
fs.chmodSync(targetDatabasePath, 0o600);
|
||||||
|
const target = new DatabaseSync(targetDatabasePath);
|
||||||
|
target.exec(`
|
||||||
|
INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||||
|
"project_id", "subject_type", "subject_id", "version", "state",
|
||||||
|
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||||
|
"created_at_ms"
|
||||||
|
) VALUES (
|
||||||
|
'default', 'user', 'review-owner', 1, 'active', 'owner',
|
||||||
|
'secret-config-apply-owner-binding', 'user', 'review-owner', 1
|
||||||
|
);
|
||||||
|
PRAGMA wal_checkpoint(TRUNCATE);
|
||||||
|
PRAGMA journal_mode=DELETE;
|
||||||
|
`);
|
||||||
|
target.close();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3827,6 +3845,366 @@ test('Secret/Config decision reauthenticates the same reviewer, seals exact cand
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Secret/Config apply publishes encrypted material atomically and recovers every apply and rollback boundary', async (t) => {
|
||||||
|
const state = await plannedSecretConfigDecisionFixture(t, {
|
||||||
|
suffix: 'apply-terminal',
|
||||||
|
planId: '00000000-0000-4000-8000-000000000437',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000438',
|
||||||
|
applicationId: '00000000-0000-4000-8000-000000000439',
|
||||||
|
secretConfigId: '00000000-0000-4000-8000-00000000043a',
|
||||||
|
});
|
||||||
|
const decisionId = '019b0000-0000-7000-8000-000000000437';
|
||||||
|
const prepareCommand = secretConfigDecisionPrepareCommand(state, decisionId);
|
||||||
|
const prepared = await prepareLocalReconciliationSecretConfigDecision(
|
||||||
|
prepareCommand,
|
||||||
|
);
|
||||||
|
const review = secretConfigDecisionFile(
|
||||||
|
state,
|
||||||
|
{ result: prepared },
|
||||||
|
[
|
||||||
|
{
|
||||||
|
disposition: 'preserve_disabled',
|
||||||
|
reason: 'reviewed_disabled_preservation',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'apply-terminal',
|
||||||
|
);
|
||||||
|
const decision = secretConfigDecisionCommitFixture(
|
||||||
|
state,
|
||||||
|
{ result: prepared, commandOptions: prepareCommand.options },
|
||||||
|
review.filePath,
|
||||||
|
);
|
||||||
|
const committed = await commitLocalReconciliationSecretConfigDecision(
|
||||||
|
decision.command,
|
||||||
|
decision.dependencies,
|
||||||
|
);
|
||||||
|
assert.equal(committed.outcome, 'ready');
|
||||||
|
|
||||||
|
const secretKeyringPath = path.join(
|
||||||
|
state.deploymentRoot,
|
||||||
|
'local-secret-keyring.json',
|
||||||
|
);
|
||||||
|
await provisionLocalSecretKeyring(secretKeyringPath);
|
||||||
|
const secretConfigApplyRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
'secret-config-apply-terminal',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(secretConfigApplyRoot, { mode: 0o700 });
|
||||||
|
const appliedAtMs = decision.command.request.committedAtMs + 1;
|
||||||
|
const applyOptions = {
|
||||||
|
...prepareCommand.options,
|
||||||
|
secretConfigApplyRoot,
|
||||||
|
targetDatabasePath: state.targetDatabasePath,
|
||||||
|
secretKeyringPath,
|
||||||
|
ownerPepperKeyringDirectory:
|
||||||
|
state.command.options.ownerPepperKeyringDirectory,
|
||||||
|
credentialFilePath: state.command.options.credentialFilePath,
|
||||||
|
};
|
||||||
|
const applyCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.apply',
|
||||||
|
options: applyOptions,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
secretConfigId: state.secretConfigId,
|
||||||
|
expectedDecisionDigest: committed.decisionDigest,
|
||||||
|
expectedHeadDigest: committed.instanceHeadDigest,
|
||||||
|
mutationId: '00000000-0000-4000-8000-00000000043b',
|
||||||
|
requestId: 'secret-config-apply-terminal',
|
||||||
|
appliedAtMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let authentications = 0;
|
||||||
|
let confirmations = 0;
|
||||||
|
let databaseCloses = 0;
|
||||||
|
const applyDependencies = {
|
||||||
|
async openAuthenticationDatabase() {
|
||||||
|
return {
|
||||||
|
async close() {
|
||||||
|
databaseCloses += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async authenticate(_database, options) {
|
||||||
|
authentications += 1;
|
||||||
|
assert.equal(
|
||||||
|
options.authenticationNamespace,
|
||||||
|
'local_reconciliation_secret_config_apply',
|
||||||
|
);
|
||||||
|
const authenticatedAtMs = options.now();
|
||||||
|
return {
|
||||||
|
principal: {
|
||||||
|
subject: { type: 'user', id: 'review-owner' },
|
||||||
|
authenticationId: 'local_reconciliation_secret_config_apply:test',
|
||||||
|
authenticatedAtMs,
|
||||||
|
expiresAtMs: authenticatedAtMs + 60 * 60 * 1_000,
|
||||||
|
assurance: 'local_console',
|
||||||
|
},
|
||||||
|
databaseFence: {
|
||||||
|
credentialId: 'review-owner',
|
||||||
|
credentialVersion: 1,
|
||||||
|
pepperKeyId: 'review-owner-v1',
|
||||||
|
pepperVersion: 1,
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
confirmations += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const targetIdentity = fs.statSync(state.targetDatabasePath);
|
||||||
|
await assert.rejects(
|
||||||
|
applyLocalReconciliationSecretConfig(
|
||||||
|
{
|
||||||
|
...applyCommand,
|
||||||
|
options: {
|
||||||
|
...applyOptions,
|
||||||
|
secretKeyringPath: path.join(
|
||||||
|
path.dirname(state.deploymentRoot),
|
||||||
|
'outside-secret-keyring.json',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
applyDependencies,
|
||||||
|
),
|
||||||
|
/authentication or Secret material must be below deploymentRoot/,
|
||||||
|
);
|
||||||
|
for (const boundary of ['afterMaterialPublished']) {
|
||||||
|
await assert.rejects(
|
||||||
|
applyLocalReconciliationSecretConfig(applyCommand, {
|
||||||
|
...applyDependencies,
|
||||||
|
[boundary]() {
|
||||||
|
throw new Error(`secret config apply ${boundary} response loss`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`secret config apply ${boundary} response loss`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await assert.rejects(
|
||||||
|
applyLocalReconciliationSecretConfig(applyCommand, {
|
||||||
|
...applyDependencies,
|
||||||
|
async createBackup() {
|
||||||
|
const error = new Error('router storage is full');
|
||||||
|
error.code = 'ENOSPC';
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/router storage is full/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(
|
||||||
|
path.join(secretConfigApplyRoot, state.secretConfigId, 'intent.json'),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
readLocalCutoverInstanceHead(
|
||||||
|
state.deploymentRoot,
|
||||||
|
state.captureCommand.request.instanceId,
|
||||||
|
state.uid,
|
||||||
|
).state,
|
||||||
|
'reconciliation_secret_config_reviewed',
|
||||||
|
);
|
||||||
|
for (const boundary of ['afterBackupPublished', 'afterPreparedHead']) {
|
||||||
|
await assert.rejects(
|
||||||
|
applyLocalReconciliationSecretConfig(applyCommand, {
|
||||||
|
...applyDependencies,
|
||||||
|
[boundary]() {
|
||||||
|
throw new Error(`secret config apply ${boundary} response loss`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`secret config apply ${boundary} response loss`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await assert.rejects(
|
||||||
|
applyLocalReconciliationSecretConfig(applyCommand, {
|
||||||
|
...applyDependencies,
|
||||||
|
async authenticate(database, options) {
|
||||||
|
const authenticated = await applyDependencies.authenticate(
|
||||||
|
database,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...authenticated,
|
||||||
|
principal: {
|
||||||
|
...authenticated.principal,
|
||||||
|
subject: { type: 'user', id: 'another-owner' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/current reviewer authentication is not strong or identical/,
|
||||||
|
);
|
||||||
|
for (const boundary of [
|
||||||
|
'afterDatabaseCommit',
|
||||||
|
'afterReceiptPublished',
|
||||||
|
'afterAppliedHead',
|
||||||
|
'afterAppliedSeal',
|
||||||
|
]) {
|
||||||
|
await assert.rejects(
|
||||||
|
applyLocalReconciliationSecretConfig(applyCommand, {
|
||||||
|
...applyDependencies,
|
||||||
|
[boundary]() {
|
||||||
|
throw new Error(`secret config apply ${boundary} response loss`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`secret config apply ${boundary} response loss`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const applied = await applyLocalReconciliationSecretConfig(
|
||||||
|
applyCommand,
|
||||||
|
applyDependencies,
|
||||||
|
);
|
||||||
|
assert.equal(applied.status, 'existing');
|
||||||
|
assert.equal(applied.state, 'reconciliation_secret_config_applied');
|
||||||
|
assert.equal(applied.activeBindingCount, 0);
|
||||||
|
assert.equal(applied.disabledPreservationCount, 1);
|
||||||
|
assert.equal(fs.statSync(state.targetDatabasePath).ino, targetIdentity.ino);
|
||||||
|
const target = new DatabaseSync(state.targetDatabasePath, {
|
||||||
|
readOnly: true,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
target
|
||||||
|
.prepare(
|
||||||
|
'SELECT count(*) AS count FROM "QingLong3SecretConfigApplications"',
|
||||||
|
)
|
||||||
|
.get().count,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
target
|
||||||
|
.prepare('SELECT count(*) AS count FROM "QingLong3LocalSecretEnvelopes"')
|
||||||
|
.get().count,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
target.close();
|
||||||
|
const evidenceRoot = path.join(secretConfigApplyRoot, state.secretConfigId);
|
||||||
|
const materialsPath = path.join(evidenceRoot, 'materials.ndjson');
|
||||||
|
const materialText = fs.readFileSync(materialsPath, 'utf8');
|
||||||
|
assert.equal(materialText.includes('private-secret-value'), false);
|
||||||
|
assert.equal(materialText.includes('DISABLED_TOKEN'), false);
|
||||||
|
assert.equal(fs.statSync(evidenceRoot).mode & 0o777, 0o500);
|
||||||
|
assert.equal(fs.statSync(materialsPath).mode & 0o777, 0o400);
|
||||||
|
assert.deepEqual(fs.readdirSync(evidenceRoot).sort(), [
|
||||||
|
'backup',
|
||||||
|
'intent.json',
|
||||||
|
'materials.ndjson',
|
||||||
|
'receipt.json',
|
||||||
|
'rollback-work',
|
||||||
|
]);
|
||||||
|
const verified = await verifyLocalReconciliationSecretConfigApply({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.apply.verify',
|
||||||
|
options: applyOptions,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
secretConfigId: state.secretConfigId,
|
||||||
|
expectedApplyDigest: applied.applyDigest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(verified.status, 'verified');
|
||||||
|
const verifyPath = path.join(
|
||||||
|
state.deploymentRoot,
|
||||||
|
'secret-config-apply-verify.json',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
verifyPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.apply.verify',
|
||||||
|
options: applyOptions,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
secretConfigId: state.secretConfigId,
|
||||||
|
expectedApplyDigest: applied.applyDigest,
|
||||||
|
},
|
||||||
|
})}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
const verifyCli = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||||
|
'reconciliation-secret-config-apply-verify',
|
||||||
|
'--command-file',
|
||||||
|
verifyPath,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(verifyCli.status, 0, verifyCli.stderr);
|
||||||
|
assert.equal(JSON.parse(verifyCli.stdout).status, 'verified');
|
||||||
|
assert.equal(verifyCli.stdout.includes('private-secret-value'), false);
|
||||||
|
|
||||||
|
const rollbackCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.apply.rollback',
|
||||||
|
options: applyOptions,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
secretConfigId: state.secretConfigId,
|
||||||
|
expectedApplyDigest: applied.applyDigest,
|
||||||
|
expectedHeadDigest: applied.instanceHeadDigest,
|
||||||
|
rolledBackAtMs: appliedAtMs + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const boundary of [
|
||||||
|
'afterRestore',
|
||||||
|
'afterRollbackReceipt',
|
||||||
|
'afterRollbackHead',
|
||||||
|
'afterRollbackSeal',
|
||||||
|
]) {
|
||||||
|
await assert.rejects(
|
||||||
|
rollbackLocalReconciliationSecretConfigApply(rollbackCommand, {
|
||||||
|
...applyDependencies,
|
||||||
|
[boundary]() {
|
||||||
|
throw new Error(`secret config rollback ${boundary} response loss`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`secret config rollback ${boundary} response loss`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rolledBack = await rollbackLocalReconciliationSecretConfigApply(
|
||||||
|
rollbackCommand,
|
||||||
|
applyDependencies,
|
||||||
|
);
|
||||||
|
assert.equal(rolledBack.status, 'existing');
|
||||||
|
assert.equal(rolledBack.state, 'reconciliation_secret_config_rolled_back');
|
||||||
|
assert.equal(fs.statSync(state.targetDatabasePath).ino, targetIdentity.ino);
|
||||||
|
assert.deepEqual(fs.readdirSync(path.join(evidenceRoot, 'backup')), []);
|
||||||
|
assert.deepEqual(fs.readdirSync(path.join(evidenceRoot, 'rollback-work')), [
|
||||||
|
'receipt.json',
|
||||||
|
]);
|
||||||
|
const restored = new DatabaseSync(state.targetDatabasePath, {
|
||||||
|
readOnly: true,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
restored
|
||||||
|
.prepare(
|
||||||
|
'SELECT count(*) AS count FROM "QingLong3SecretConfigApplications"',
|
||||||
|
)
|
||||||
|
.get().count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
restored.close();
|
||||||
|
const rollbackVerified = await verifyLocalReconciliationSecretConfigApply({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.apply.verify',
|
||||||
|
options: applyOptions,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
secretConfigId: state.secretConfigId,
|
||||||
|
expectedApplyDigest: applied.applyDigest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
rollbackVerified.state,
|
||||||
|
'reconciliation_secret_config_rolled_back',
|
||||||
|
);
|
||||||
|
assert.ok(authentications >= 3);
|
||||||
|
assert.ok(confirmations >= 4);
|
||||||
|
assert.equal(databaseCloses, authentications);
|
||||||
|
});
|
||||||
|
|
||||||
test('Secret/Config decision rejects manual plans, invalid candidate choices and reviewer drift', async (t) => {
|
test('Secret/Config decision rejects manual plans, invalid candidate choices and reviewer drift', async (t) => {
|
||||||
const manual = await plannedSecretConfigDecisionFixture(t, {
|
const manual = await plannedSecretConfigDecisionFixture(t, {
|
||||||
suffix: 'decision-manual-plan',
|
suffix: 'decision-manual-plan',
|
||||||
|
|||||||
@@ -2045,6 +2045,27 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
'@qinglong/local-sqlite/authentication-read',
|
'@qinglong/local-sqlite/authentication-read',
|
||||||
].includes(specifier)
|
].includes(specifier)
|
||||||
) &&
|
) &&
|
||||||
|
!(
|
||||||
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts' &&
|
||||||
|
[
|
||||||
|
'@qinglong/local-admin/reconciliation-secret-and-config-application',
|
||||||
|
'@qinglong/local-owner-console/authenticated-command',
|
||||||
|
'@qinglong/local-sqlite/authentication-read',
|
||||||
|
'@qinglong/local-sqlite/rollout-safety',
|
||||||
|
].includes(specifier)
|
||||||
|
) &&
|
||||||
|
!(
|
||||||
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/deployment/reconciliation/application/secret-and-config/application/evidence.ts' &&
|
||||||
|
specifier === '@qinglong/local-sqlite/rollout-safety'
|
||||||
|
) &&
|
||||||
|
!(
|
||||||
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/deployment/reconciliation/application/secret-and-config/application/storage.ts' &&
|
||||||
|
specifier ===
|
||||||
|
'@qinglong/local-admin/reconciliation-secret-and-config-application'
|
||||||
|
) &&
|
||||||
!(
|
!(
|
||||||
path.relative(packageDirectory, filePath) ===
|
path.relative(packageDirectory, filePath) ===
|
||||||
'src/deployment/reconciliation/application/automation/applyEvidence.ts' &&
|
'src/deployment/reconciliation/application/automation/applyEvidence.ts' &&
|
||||||
@@ -2214,7 +2235,10 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
(path.relative(packageDirectory, filePath) ===
|
(path.relative(packageDirectory, filePath) ===
|
||||||
'src/plugin-package/pluginPackageSecretBinding.ts' &&
|
'src/plugin-package/pluginPackageSecretBinding.ts' &&
|
||||||
specifier ===
|
specifier ===
|
||||||
'@qinglong/local-sqlite/plugin-package-secret-binding-administration')
|
'@qinglong/local-sqlite/plugin-package-secret-binding-administration') ||
|
||||||
|
(path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/legacy-adoption/secret-and-config/reconciliationSecretConfigApplication.ts' &&
|
||||||
|
specifier === '@qinglong/local-sqlite/secret-config-application')
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
findings.push({
|
findings.push({
|
||||||
@@ -2252,6 +2276,14 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
'@qinglong/runtime-core/local-secret',
|
'@qinglong/runtime-core/local-secret',
|
||||||
'@qinglong/runtime-core/security',
|
'@qinglong/runtime-core/security',
|
||||||
].includes(specifier)) ||
|
].includes(specifier)) ||
|
||||||
|
(path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/legacy-adoption/secret-and-config/reconciliationSecretConfigApplication.ts' &&
|
||||||
|
[
|
||||||
|
'@qinglong/runtime-core/local-secret',
|
||||||
|
'@qinglong/runtime-core/project-policy',
|
||||||
|
'@qinglong/runtime-core/security',
|
||||||
|
'@qinglong/runtime-core/security-audit',
|
||||||
|
].includes(specifier)) ||
|
||||||
(path.relative(packageDirectory, filePath) ===
|
(path.relative(packageDirectory, filePath) ===
|
||||||
'src/plugin-package/pluginPackageStaging.ts' &&
|
'src/plugin-package/pluginPackageStaging.ts' &&
|
||||||
[
|
[
|
||||||
@@ -2982,6 +3014,16 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||||
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts' &&
|
||||||
|
['@qinglong/local-secret', '@qinglong/runtime-core/security'].includes(
|
||||||
|
specifier,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||||
path.relative(packageDirectory, filePath) ===
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
|||||||
@@ -105,6 +105,87 @@ function fixture(t, packagePath, source, sourcePath = 'index.ts') {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('confines Secret/Config application authority to reviewed composition files', (t) => {
|
||||||
|
const root = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ql3-secret-config-application-boundary-'),
|
||||||
|
);
|
||||||
|
const sources = {
|
||||||
|
'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts':
|
||||||
|
[
|
||||||
|
"import { prepare } from '@qinglong/local-admin/reconciliation-secret-and-config-application';",
|
||||||
|
"import { authenticate } from '@qinglong/local-owner-console/authenticated-command';",
|
||||||
|
"import { keyring } from '@qinglong/local-secret';",
|
||||||
|
"import { read } from '@qinglong/local-sqlite/authentication-read';",
|
||||||
|
"import { backup } from '@qinglong/local-sqlite/rollout-safety';",
|
||||||
|
"import type { Principal } from '@qinglong/runtime-core/security';",
|
||||||
|
].join('\n'),
|
||||||
|
'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/evidence.ts':
|
||||||
|
"import type { Backup } from '@qinglong/local-sqlite/rollout-safety';",
|
||||||
|
'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/storage.ts':
|
||||||
|
"import type { Material } from '@qinglong/local-admin/reconciliation-secret-and-config-application';",
|
||||||
|
'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/widened.ts':
|
||||||
|
[
|
||||||
|
"import { prepare } from '@qinglong/local-admin/reconciliation-secret-and-config-application';",
|
||||||
|
"import { backup } from '@qinglong/local-sqlite/rollout-safety';",
|
||||||
|
"import type { Principal } from '@qinglong/runtime-core/security';",
|
||||||
|
].join('\n'),
|
||||||
|
'packages/ql3-local-admin/src/legacy-adoption/secret-and-config/reconciliationSecretConfigApplication.ts':
|
||||||
|
[
|
||||||
|
"import { apply } from '@qinglong/local-sqlite/secret-config-application';",
|
||||||
|
"import { envelope } from '@qinglong/runtime-core/local-secret';",
|
||||||
|
"import { policy } from '@qinglong/runtime-core/project-policy';",
|
||||||
|
"import { principal } from '@qinglong/runtime-core/security';",
|
||||||
|
"import { audit } from '@qinglong/runtime-core/security-audit';",
|
||||||
|
].join('\n'),
|
||||||
|
'packages/ql3-local-admin/src/legacy-adoption/secret-and-config/widened.ts':
|
||||||
|
[
|
||||||
|
"import { apply } from '@qinglong/local-sqlite/secret-config-application';",
|
||||||
|
"import { policy } from '@qinglong/runtime-core/project-policy';",
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
for (const [relativePath, source] of Object.entries(sources)) {
|
||||||
|
const filePath = path.join(root, relativePath);
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, source);
|
||||||
|
}
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const findings = [];
|
||||||
|
auditSourceImports(root, 'packages/ql3-local-owner-cli', findings);
|
||||||
|
auditSourceImports(root, 'packages/ql3-local-admin', findings);
|
||||||
|
assert.deepEqual(
|
||||||
|
findings.map(({ code, file, specifier }) => ({ code, file, specifier })),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT',
|
||||||
|
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/widened.ts',
|
||||||
|
specifier:
|
||||||
|
'@qinglong/local-admin/reconciliation-secret-and-config-application',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT',
|
||||||
|
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/widened.ts',
|
||||||
|
specifier: '@qinglong/local-sqlite/rollout-safety',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'FORBIDDEN_PACKAGE_SOURCE_IMPORT',
|
||||||
|
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/widened.ts',
|
||||||
|
specifier: '@qinglong/runtime-core/security',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'FORBIDDEN_LOCAL_ADMIN_SQLITE_ENTRYPOINT',
|
||||||
|
file: 'packages/ql3-local-admin/src/legacy-adoption/secret-and-config/widened.ts',
|
||||||
|
specifier: '@qinglong/local-sqlite/secret-config-application',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'FORBIDDEN_LOCAL_ADMIN_RUNTIME_CORE_ENTRYPOINT',
|
||||||
|
file: 'packages/ql3-local-admin/src/legacy-adoption/secret-and-config/widened.ts',
|
||||||
|
specifier: '@qinglong/runtime-core/project-policy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('accepts package-local and declared forward source imports', (t) => {
|
test('accepts package-local and declared forward source imports', (t) => {
|
||||||
const root = fixture(
|
const root = fixture(
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localAdmin.rootSourceFileRoles,
|
rootSourceFileRoles: localAdmin.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 48,
|
sourceFiles: 49,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 9,
|
rootSourceLines: 9,
|
||||||
nestedSourceFiles: 47,
|
nestedSourceFiles: 48,
|
||||||
rootSourceFileRoles: { 'runtime.ts': 'public_export' },
|
rootSourceFileRoles: { 'runtime.ts': 'public_export' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 184,
|
sourceFiles: 188,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 183,
|
nestedSourceFiles: 187,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user