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