From 8ad8ed96bd9afbac6f57513eb790bf4b84c84f9d Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 24 Aug 2026 10:38:04 +0800 Subject: [PATCH] feat(ql3): complete local secret config apply lifecycle --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 17 +- ...-config-reconciliation-and-task-binding.md | 18 +- docs/adr/README.md | 2 +- packages/ql3-local-admin/package.json | 5 + .../reconciliationSecretConfigApplication.ts | 612 +++++++++++ .../src/deployment/cutover/instanceLineage.ts | 18 + .../src/deployment/localDeployment.ts | 25 + .../src/deployment/localDeploymentCli.ts | 14 +- .../secret-and-config/application/contract.ts | 410 ++++++++ .../application/coordinator.ts | 977 ++++++++++++++++++ .../secret-and-config/application/evidence.ts | 436 ++++++++ .../secret-and-config/application/storage.ts | 676 ++++++++++++ .../secret-and-config/decisionCoordinator.ts | 15 +- .../secret-and-config/planReader.ts | 2 + .../reconciliationCapturePrepare.test.cjs | 378 +++++++ scripts/ql3-cluster-dependency-audit.cjs | 44 +- test/back/ql3ClusterDependencyAudit.test.cjs | 81 ++ test/back/ql3PackageBoundaryAudit.test.cjs | 8 +- 18 files changed, 3714 insertions(+), 24 deletions(-) create mode 100644 packages/ql3-local-admin/src/legacy-adoption/secret-and-config/reconciliationSecretConfigApplication.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/contract.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/evidence.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/storage.ts diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index e82e7f7f..2f85a245 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -42,18 +42,25 @@ 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) 扫描。 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 - 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、本地镜像与 + 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;基础 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 依赖。 - 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 拓扑, 因而不重跑且不重新占有 PostgreSQL HA 证明;相邻已通过的 remote CI/HA 只作为基线。 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/ readiness、观察窗和 rollback retention 之后另行强认证。Cluster 必须使用 PostgreSQL SERIALIZABLE ledger、外部 KMS/Secret provider 与 HA evidence, 不复用 Local SQLite/POSIX authority,也不得把明文写入 PostgreSQL、ConfigMap、Pod env 或 Job command。 diff --git a/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md b/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md index 60568249..1ee57c54 100644 --- a/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md +++ b/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md @@ -1,6 +1,6 @@ # 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 - 决策: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 @@ -72,9 +72,9 @@ durable publisher 固定写入 `//{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。 -### 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; 2. 为每个 active effective Env 写入加密 Local Secret envelope、content-free `secret.create` audit 与 adoption item; @@ -85,7 +85,11 @@ durable publisher 固定写入 `//{plan.ndjson 任一 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 @@ -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 已完成。 diff --git a/docs/adr/README.md b/docs/adr/README.md index fa31d17d..3f73b949 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -494,7 +494,7 @@ | [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-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) | ## 规则 diff --git a/packages/ql3-local-admin/package.json b/packages/ql3-local-admin/package.json index 8c0d3174..85a3bb6b 100644 --- a/packages/ql3-local-admin/package.json +++ b/packages/ql3-local-admin/package.json @@ -50,6 +50,11 @@ "require": "./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": { "types": "./dist/legacy-adoption/reconciliationAutomationDecision.d.ts", "require": "./dist/legacy-adoption/reconciliationAutomationDecision.js", diff --git a/packages/ql3-local-admin/src/legacy-adoption/secret-and-config/reconciliationSecretConfigApplication.ts b/packages/ql3-local-admin/src/legacy-adoption/secret-and-config/reconciliationSecretConfigApplication.ts new file mode 100644 index 00000000..941d4745 --- /dev/null +++ b/packages/ql3-local-admin/src/legacy-adoption/secret-and-config/reconciliationSecretConfigApplication.ts @@ -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; +} + +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[]; + readonly openRequirements: () => Iterable; + readonly keyProvider: LocalSecretKeyProvider; + readonly visitMaterial: ( + material: Readonly, + ) => 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[]; + readonly principal: Readonly; + readonly appliedAtMs: number; + readonly authorizationAtMs?: number; + readonly busyTimeoutMs?: number; + readonly confirmAuthenticationAuthority: () => void | Promise; + readonly confirmPreparedAuthority: () => void | Promise; +} + +export interface ApplyPreparedReconciliationSecretConfigApplicationResult { + readonly status: 'inserted' | 'existing'; + readonly receipt: Readonly; +} + +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(value: Iterable, label: string): Iterator { + 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 { + 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 { + 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, + requirement: Readonly, + candidate: Readonly, +): 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return fail('prepared material must be an object'); + } + const selected = value as Record; + 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; + 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, +): Promise> { + 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, + atMs: number, +): Readonly { + let principal: Readonly; + 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, + principal: Readonly, + decision: Readonly, +): Readonly { + 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, +): Promise> { + 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; + 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[] = + 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(); + } +} diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts index 4a3bbb6a..552021c9 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts @@ -40,6 +40,9 @@ export type LocalCutoverInstanceHeadState = | 'reconciliation_secret_config_planned' | 'reconciliation_secret_config_decision_prepared' | 'reconciliation_secret_config_reviewed' + | 'reconciliation_secret_config_apply_prepared' + | 'reconciliation_secret_config_applied' + | 'reconciliation_secret_config_rolled_back' | 'reconciliation_completed' | 'rollback_prepared' | 'legacy_restart_requested' @@ -189,6 +192,9 @@ function parseHead(value: unknown): Readonly { head.state !== 'reconciliation_secret_config_planned' && head.state !== 'reconciliation_secret_config_decision_prepared' && 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 !== 'rollback_prepared' && head.state !== 'legacy_restart_requested' && @@ -373,6 +379,9 @@ export function advanceLocalCutoverInstanceHead( | 'reconciliation_secret_config_planned' | 'reconciliation_secret_config_decision_prepared' | 'reconciliation_secret_config_reviewed' + | 'reconciliation_secret_config_apply_prepared' + | 'reconciliation_secret_config_applied' + | 'reconciliation_secret_config_rolled_back' | 'reconciliation_completed' | 'rollback_prepared' | 'legacy_restart_requested' @@ -429,6 +438,9 @@ export function advanceLocalCutoverInstanceHead( current.state === 'reconciliation_secret_config_planned' || current.state === 'reconciliation_secret_config_decision_prepared' || 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 === 'legacy_restart_requested' || current.state === 'legacy_running' || @@ -481,6 +493,12 @@ export function advanceLocalCutoverInstanceHead( current.state === 'reconciliation_secret_config_planned') || (state === 'reconciliation_secret_config_reviewed' && 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' && (current.state === 'reconciliation_application_planned' || current.state === 'reconciliation_automation_applied')) || diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts index 60d9a355..811d11bf 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts @@ -170,6 +170,14 @@ import { verifyLocalReconciliationSecretConfigDecision, verifyLocalReconciliationSecretConfigDecisionCommandFile, } from './reconciliation/application/secret-and-config/decisionCoordinator'; +import { + applyLocalReconciliationSecretConfig, + applyLocalReconciliationSecretConfigCommandFile, + rollbackLocalReconciliationSecretConfigApply, + rollbackLocalReconciliationSecretConfigApplyCommandFile, + verifyLocalReconciliationSecretConfigApply, + verifyLocalReconciliationSecretConfigApplyCommandFile, +} from './reconciliation/application/secret-and-config/application/coordinator'; import { preserveLocalReconciliationRunHistory, preserveLocalReconciliationRunHistoryCommandFile, @@ -234,6 +242,12 @@ export { verifyLocalReconciliationSecretConfigDecision, verifyLocalReconciliationSecretConfigDecisionCommandFile, readLocalReconciliationSecretConfigDecisionTerminal, + applyLocalReconciliationSecretConfig, + applyLocalReconciliationSecretConfigCommandFile, + verifyLocalReconciliationSecretConfigApply, + verifyLocalReconciliationSecretConfigApplyCommandFile, + rollbackLocalReconciliationSecretConfigApply, + rollbackLocalReconciliationSecretConfigApplyCommandFile, preserveLocalReconciliationRunHistory, preserveLocalReconciliationRunHistoryCommandFile, readLocalReconciliationRunHistoryTerminal, @@ -381,6 +395,17 @@ export { type LocalReconciliationSecretConfigDecisionDependencies, type LocalReconciliationSecretConfigDecisionTerminal, } 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 { type LocalReconciliationSecretConfigDecision, type LocalReconciliationSecretConfigDecisionDisposition, diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts index 5bea6824..21942bac 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts @@ -38,6 +38,9 @@ import { prepareLocalReconciliationSecretConfigDecisionCommandFile, commitLocalReconciliationSecretConfigDecisionCommandFile, verifyLocalReconciliationSecretConfigDecisionCommandFile, + applyLocalReconciliationSecretConfigCommandFile, + verifyLocalReconciliationSecretConfigApplyCommandFile, + rollbackLocalReconciliationSecretConfigApplyCommandFile, preserveLocalReconciliationRunHistoryCommandFile, verifyLocalReconciliationRunHistoryCommandFile, completeLocalReconciliationCommandFile, @@ -57,7 +60,7 @@ import { } from './localDeployment'; const USAGE = - 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; + 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; async function main(argv: readonly string[]): Promise { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { @@ -112,6 +115,9 @@ async function main(argv: readonly string[]): Promise { argv[0] !== 'reconciliation-secret-config-decision-prepare' && argv[0] !== 'reconciliation-secret-config-decision-commit' && 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-verify' && argv[0] !== 'reconciliation-complete' && @@ -238,6 +244,12 @@ async function main(argv: readonly string[]): Promise { ? commitLocalReconciliationSecretConfigDecisionCommandFile(argv[2]!) : argv[0] === 'reconciliation-secret-config-decision-verify' ? 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' ? preserveLocalReconciliationRunHistoryCommandFile(argv[2]!) : argv[0] === 'reconciliation-run-history-verify' diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/contract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/contract.ts new file mode 100644 index 00000000..883abebe --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/contract.ts @@ -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; + 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; + 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; + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + 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 { + 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 { + 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 { + 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 { + 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, + }), + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts new file mode 100644 index 00000000..85a9d7bf --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts @@ -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 +>; + +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, +) { + return Object.freeze({ + deploymentRoot: options.deploymentRoot, + applicationRoot: options.applicationRoot, + secretConfigRoot: options.secretConfigRoot, + secretConfigDecisionRoot: options.secretConfigDecisionRoot, + allowRootService: options.allowRootService, + }); +} + +function advance( + intent: Readonly, + uid: number, + state: + | 'reconciliation_secret_config_apply_prepared' + | 'reconciliation_secret_config_applied' + | 'reconciliation_secret_config_rolled_back', + digest: string, + atMs: number, +): Readonly { + 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, + intent: Readonly, + head: Readonly, +): Readonly { + 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, + reviewer: Readonly, + 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, + atMs: number, + profile: 'edge' | 'standalone', + reviewer: Readonly, + dependencies: LocalReconciliationSecretConfigApplyDependencies, +): Promise< + Readonly<{ + authenticated: Readonly; + 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, + 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, + command: Readonly, +): 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, + 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> { + 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; + 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[] = + []; + 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> { + 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> { + 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> { + return applyLocalReconciliationSecretConfig( + readPrivateLocalCommandFile(filePath), + ); +} + +export async function verifyLocalReconciliationSecretConfigApplyCommandFile( + filePath: string, +): Promise> { + return verifyLocalReconciliationSecretConfigApply( + readPrivateLocalCommandFile(filePath), + ); +} + +export async function rollbackLocalReconciliationSecretConfigApplyCommandFile( + filePath: string, +): Promise> { + return rollbackLocalReconciliationSecretConfigApply( + readPrivateLocalCommandFile(filePath), + ); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/evidence.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/evidence.ts new file mode 100644 index 00000000..02b57a6a --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/evidence.ts @@ -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; + 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; + readonly backup: Readonly; + 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; + 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; + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${label} must be an object`); + } + const selected = value as Record; + 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 { + 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; +} + +function backup(value: unknown): Readonly { + 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; +} + +function material( + value: unknown, +): Readonly { + 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; +} + +export function buildLocalReconciliationSecretConfigApplyIntent( + input: Omit< + LocalReconciliationSecretConfigApplyIntent, + 'schema' | 'schemaVersion' | 'preparationDigest' + >, +): Readonly { + 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 { + 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; +} + +export function buildLocalReconciliationSecretConfigApplyReceipt( + input: Omit< + LocalReconciliationSecretConfigApplyReceipt, + 'schema' | 'schemaVersion' | 'state' | 'applyDigest' + >, +): Readonly { + 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 { + 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; +} + +export function buildLocalReconciliationSecretConfigRollbackReceipt( + input: Omit< + LocalReconciliationSecretConfigRollbackReceipt, + 'schema' | 'schemaVersion' | 'state' | 'rollbackDigest' + >, +): Readonly { + 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 { + 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; +} + +export function localReconciliationSecretConfigApplyEvidenceContents( + value: + | Readonly + | Readonly + | Readonly, +): string { + return `${JSON.stringify(value, null, 2)}\n`; +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/storage.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/storage.ts new file mode 100644 index 00000000..aa295f07 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/storage.ts @@ -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 { + 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, + 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, +): 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, + materials: readonly Readonly[], + profile: 'edge' | 'standalone', + uid: number, +): Readonly { + 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, +): 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, + profile: 'edge' | 'standalone', + uid: number, + expected?: Readonly, +): readonly Readonly[] { + 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, + uid: number, +): Readonly { + return normalizeLocalReconciliationSecretConfigApplyIntent( + stableJson(selected.intent, uid, [0o600, 0o400], 'intent'), + ); +} + +export function readLocalReconciliationSecretConfigApplyReceipt( + selected: Readonly, + uid: number, +): Readonly { + return normalizeLocalReconciliationSecretConfigApplyReceipt( + stableJson(selected.receipt, uid, [0o600, 0o400], 'receipt'), + ); +} + +export function readLocalReconciliationSecretConfigRollbackReceipt( + selected: Readonly, + uid: number, +): Readonly { + 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, + intent: Readonly, + uid: number, + modes: readonly number[], +): void { + stableFileSha256( + selected.backup, + uid, + modes, + intent.backup.bytes, + intent.backup.sha256, + 'backup', + ); +} + +export function sealLocalReconciliationSecretConfigAppliedStorage( + selected: Readonly, + intent: Readonly, + 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, + intent: Readonly, + 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, + intent: Readonly, + 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, + intent: Readonly, + 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, + intent: Readonly, + 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, + ); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionCoordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionCoordinator.ts index 51eb59b7..b35fa217 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionCoordinator.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/decisionCoordinator.ts @@ -1132,6 +1132,7 @@ export async function readLocalReconciliationSecretConfigDecisionTerminal( >, secretConfigId: string, uid: number, + acceptedSuccessorStates: readonly LocalCutoverInstanceHead['state'][] = [], ): Promise> { for (const [directory, label] of [ [options.deploymentRoot, 'deploymentRoot'], @@ -1171,11 +1172,15 @@ export async function readLocalReconciliationSecretConfigDecisionTerminal( intent.instanceId, uid, ); - if ( - head.state !== 'reconciliation_secret_config_reviewed' || - head.previousHeadDigest !== receipt.preparedHeadDigest || - head.sourceRecordDigest !== receipt.decisionDigest - ) { + const reviewed = + head.state === 'reconciliation_secret_config_reviewed' && + head.previousHeadDigest === receipt.preparedHeadDigest && + 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'); } return Object.freeze({ diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts index 2b2104b9..92999cc2 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts @@ -28,6 +28,7 @@ export interface LocalReconciliationSecretConfigDecisionRequirement { readonly candidateType: 'active_binding' | 'disabled_preservation'; readonly candidateDigest: string; readonly sourceSetDigest: string; + readonly proposedSecretName: string; readonly requirement: LocalReconciliationSecretConfigCandidateRequirement; } @@ -195,6 +196,7 @@ function candidate( | 'disabled_preservation', candidateDigest: record.candidateDigest, sourceSetDigest: record.sourceSetDigest, + proposedSecretName: record.proposedSecretName as string, requirement: record.requirement as LocalReconciliationSecretConfigCandidateRequirement, }); diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index 32d258ef..986656d7 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -12,6 +12,7 @@ const { commitLocalReconciliationApplication, commitLocalReconciliationAutomationDecision, commitLocalReconciliationSecretConfigDecision, + applyLocalReconciliationSecretConfig, completeLocalReconciliation, applyLocalReconciliationAutomation, commitLocalReconciliationPlan, @@ -24,6 +25,7 @@ const { readLocalReconciliationAutomationDecisionTerminal, readLocalReconciliationSecretConfigDecisionTerminal, rollbackLocalReconciliationAutomationApply, + rollbackLocalReconciliationSecretConfigApply, planLocalReconciliationAutomation, planLocalReconciliationSecretConfig, prepareLocalReconciliationPlan, @@ -32,6 +34,7 @@ const { verifyLocalReconciliationApplication, verifyLocalReconciliationAutomationDecision, verifyLocalReconciliationSecretConfigDecision, + verifyLocalReconciliationSecretConfigApply, verifyLocalReconciliationAutomationApply, verifyLocalReconciliationAutomationPlan, verifyLocalReconciliationSecretConfigPlan, @@ -41,6 +44,7 @@ const { verifyLocalReconciliationRunHistory, writeLocalReconciliationReviewDiagnostics, } = require('../dist/deployment/localDeployment.js'); +const { provisionLocalSecretKeyring } = require('@qinglong/local-secret'); const { normalizeLocalReconciliationCaptureManifest, } = require('../dist/deployment/reconciliation/bundle.js'); @@ -846,6 +850,20 @@ function secretConfigDatabaseInitializer({ ); assert.equal(migration.status, 0, migration.stderr); 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) => { const manual = await plannedSecretConfigDecisionFixture(t, { suffix: 'decision-manual-plan', diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 7ed7c87d..66838e86 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -2045,6 +2045,27 @@ function auditSourceImports(root, packagePath, findings) { '@qinglong/local-sqlite/authentication-read', ].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) === 'src/deployment/reconciliation/application/automation/applyEvidence.ts' && @@ -2214,7 +2235,10 @@ function auditSourceImports(root, packagePath, findings) { (path.relative(packageDirectory, filePath) === 'src/plugin-package/pluginPackageSecretBinding.ts' && 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({ @@ -2252,6 +2276,14 @@ function auditSourceImports(root, packagePath, findings) { '@qinglong/runtime-core/local-secret', '@qinglong/runtime-core/security', ].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) === 'src/plugin-package/pluginPackageStaging.ts' && [ @@ -2982,6 +3014,16 @@ function auditSourceImports(root, packagePath, findings) { ) { 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 ( packagePath === 'packages/ql3-local-owner-cli' && path.relative(packageDirectory, filePath) === diff --git a/test/back/ql3ClusterDependencyAudit.test.cjs b/test/back/ql3ClusterDependencyAudit.test.cjs index 40627ee1..3dafb8cd 100644 --- a/test/back/ql3ClusterDependencyAudit.test.cjs +++ b/test/back/ql3ClusterDependencyAudit.test.cjs @@ -105,6 +105,87 @@ function fixture(t, packagePath, source, sourcePath = 'index.ts') { 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) => { const root = fixture( t, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 3304959e..22ff7aad 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -97,10 +97,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localAdmin.rootSourceFileRoles, }, { - sourceFiles: 48, + sourceFiles: 49, rootSourceFiles: 1, rootSourceLines: 9, - nestedSourceFiles: 47, + nestedSourceFiles: 48, rootSourceFileRoles: { 'runtime.ts': 'public_export' }, }, ); @@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localOwnerCli.rootSourceFileRoles, }, { - sourceFiles: 184, + sourceFiles: 188, rootSourceFiles: 1, rootSourceLines: 50, - nestedSourceFiles: 183, + nestedSourceFiles: 187, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );