mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(local): plan secret config reconciliation
This commit is contained in:
@@ -16,11 +16,18 @@
|
|||||||
Secret 后续必须在同一事务中绑定到经 Automation adoption ledger 证明的全部 Legacy Task 新修订,并同步追加指向新 Task revision 的 Trigger/dispatch
|
Secret 后续必须在同一事务中绑定到经 Automation adoption ledger 证明的全部 Legacy Task 新修订,并同步追加指向新 Task revision 的 Trigger/dispatch
|
||||||
修订。disabled 行逐行加密保全但不激活;非法/保留名称、异常 status/ordering、单值或总字节超限、部分组失败均进入 manual,不能静默丢行。
|
修订。disabled 行逐行加密保全但不激活;非法/保留名称、异常 status/ordering、单值或总字节超限、部分组失败均进入 manual,不能静默丢行。
|
||||||
|
|
||||||
第一切片已在既有 `@qinglong/local-admin/src/legacy-adoption/secret-and-config/` 落地 content-free inspection 与精确私有 subpath,没有新增 package、
|
inspection 已在既有 `@qinglong/local-admin/src/legacy-adoption/secret-and-config/` 落地 content-free inspection 与精确私有 subpath,没有新增 package、
|
||||||
dependency、daemon 或 `src` 根平铺。Edge/Standalone 行数上限分别为 10,000/100,000,disabled preservation 为 128/512;共同受 256 个 active
|
dependency、daemon 或 `src` 根平铺。Edge/Standalone 行数上限分别为 10,000/100,000,disabled preservation 为 128/512;共同受 256 个 active
|
||||||
binding、单值 16 KiB 与总 effective 64 KiB 限制。实现逐行扫描,active 在途 value 有固定内存上限,disabled 以第二遍逐项交付;inventory/row
|
binding、单值 16 KiB 与总 effective 64 KiB 限制。实现逐行扫描,active 在途 value 有固定内存上限,disabled 以第二遍逐项交付;inventory/row
|
||||||
diagnostics 不含 Env name/value/row body。absent、unsupported schema、Edge over-budget、旧顺序、同名连接、disabled、保留 `QL3_`、异常状态与
|
diagnostics 不含 Env name/value/row body。absent、unsupported schema、Edge over-budget、旧顺序、同名连接、disabled、保留 `QL3_`、异常状态与
|
||||||
overflow 均已覆盖,Local Admin 完整测试 `95/95`。
|
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、
|
||||||
|
`singleSourcePackages=[]`、`shallowSourcePackages=[]`,且只允许 exact Secret/Config row planner 导入 inspection subpath。
|
||||||
|
|
||||||
D-385~D-388 的 `config.sh`/Keyv/SSH data-directory lineage 与 SQLite `Envs` 保持分离;当前无稳定生产 schema 的历史 `Configs` 表继续 sealed+manual,
|
D-385~D-388 的 `config.sh`/Keyv/SSH data-directory lineage 与 SQLite `Envs` 保持分离;当前无稳定生产 schema 的历史 `Configs` 表继续 sealed+manual,
|
||||||
不猜字段。后续切片必须完成独立 signed decision、Secret envelope + audit + Task/Trigger/dispatch + receipt ledger 的单事务发布、prepared/apply/rollback
|
不猜字段。后续切片必须完成独立 signed decision、Secret envelope + audit + Task/Trigger/dispatch + receipt ledger 的单事务发布、prepared/apply/rollback
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0491:有界 Secret/Config Reconciliation 与任务环境绑定
|
# ADR-0491:有界 Secret/Config Reconciliation 与任务环境绑定
|
||||||
|
|
||||||
- 状态:Proposed(D-397 第一切片已实现 Legacy Env inspection,原子 application 尚未完成)
|
- 状态:Proposed(D-397 已实现 Legacy Env inspection 与私有有界 row plan,原子 application 尚未完成)
|
||||||
- 日期:2026-08-23
|
- 日期:2026-08-23
|
||||||
- 决策:D-397
|
- 决策:D-397
|
||||||
- 关联:ADR-0073、ADR-0074、ADR-0092、ADR-0094、ADR-0480、ADR-0482、ADR-0483、ADR-0484、ADR-0485、ADR-0486、ADR-0487、ADR-0488、ADR-0490
|
- 关联:ADR-0073、ADR-0074、ADR-0092、ADR-0094、ADR-0480、ADR-0482、ADR-0483、ADR-0484、ADR-0485、ADR-0486、ADR-0487、ADR-0488、ADR-0490
|
||||||
@@ -52,7 +52,7 @@ id ASC
|
|||||||
|
|
||||||
### 3. Edge 与 Standalone 预算
|
### 3. Edge 与 Standalone 预算
|
||||||
|
|
||||||
第一切片固定:
|
当前 inspection 与 row-plan 切片固定:
|
||||||
|
|
||||||
| 预算 | Edge | Standalone |
|
| 预算 | Edge | Standalone |
|
||||||
| --- | ---: | ---: |
|
| --- | ---: | ---: |
|
||||||
@@ -64,6 +64,8 @@ id ASC
|
|||||||
|
|
||||||
实现逐行读取,不把整张 `Envs` 或全部停用值加载到内存;active value 的在途内存由 64 KiB 合同封顶,停用值通过第二次有界扫描逐个交付。它位于既有 `@qinglong/local-admin/src/legacy-adoption/secret-and-config/`,不新增 workspace package、production dependency、daemon、timer、watcher、listener、socket、数据库连接池或 `src` 根平铺文件。
|
实现逐行读取,不把整张 `Envs` 或全部停用值加载到内存;active value 的在途内存由 64 KiB 合同封顶,停用值通过第二次有界扫描逐个交付。它位于既有 `@qinglong/local-admin/src/legacy-adoption/secret-and-config/`,不新增 workspace package、production dependency、daemon、timer、watcher、listener、socket、数据库连接池或 `src` 根平铺文件。
|
||||||
|
|
||||||
|
Local Owner 使用私有 NDJSON row plan 记录 header、逐行 content-free disposition、逐 candidate 目标冲突投影与 footer。Edge/Standalone plan 文件分别限制为 8 MiB/32 MiB,单行不超过 64 KiB;超过预算立即失败关闭。公开 plan/receipt 不保存原 Env name/value、目标 ciphertext、key ID 或原始 row body。active 与 disabled candidate 分别使用 `legacy-db-env-*` 和 `legacy-db-env-disabled-*` 命名空间;目标已经存在时只记录 envelope 元数据的组合摘要并进入 `review_skip_conflict`,不得读取明文、覆盖或自动改名。plan 绑定 application、独立 review authorization、sealed bundle、target projection 与 prepared head,并产生可重新计算的 row-set、candidate-set、plan-file 和 receipt digest。
|
||||||
|
|
||||||
### 4. 原子 application 必须同时完成 custody 与行为绑定
|
### 4. 原子 application 必须同时完成 custody 与行为绑定
|
||||||
|
|
||||||
后续 D-397 application 必须在一个 `BEGIN IMMEDIATE` 事务内完成:
|
后续 D-397 application 必须在一个 `BEGIN IMMEDIATE` 事务内完成:
|
||||||
@@ -129,6 +131,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。Local Admin 完整测试为 95/95。
|
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;相邻文件继续被依赖隔离门拒绝。
|
||||||
|
|
||||||
转为 Accepted 前仍必须完成:私有 row plan 与 signed decision、原子 Secret/Task/Trigger/dispatch publisher、prepared/apply/rollback response-loss、completion schema 演进、完整 Local Owner/18-package/boundary/artifact gates、真实 Edge 空间预算、PostgreSQL HA 与 Cluster Secret provider live gate。
|
转为 Accepted 前仍必须完成:独立 signed decision、原子 Secret/Task/Trigger/dispatch publisher、prepared/apply/rollback response-loss、completion schema 演进、18-package/boundary/artifact gates、真实 Edge 空间预算、PostgreSQL HA 与 Cluster Secret provider live gate。
|
||||||
|
|||||||
+1
-1
@@ -494,7 +494,7 @@
|
|||||||
| [ADR-0488](./ADR-0488-cross-domain-reconciliation-completion-fence.md) | 跨领域 Reconciliation 完成围栏与目标重启授权 | Accepted |
|
| [ADR-0488](./ADR-0488-cross-domain-reconciliation-completion-fence.md) | 跨领域 Reconciliation 完成围栏与目标重启授权 | Accepted |
|
||||||
| [ADR-0489](./ADR-0489-service-manager-completion-restart-lineage.md) | Service Manager 完成围栏重启谱系 | Accepted |
|
| [ADR-0489](./ADR-0489-service-manager-completion-restart-lineage.md) | Service Manager 完成围栏重启谱系 | Accepted |
|
||||||
| [ADR-0490](./ADR-0490-run-history-terminal-preservation.md) | Run History 终态保全与跨领域完成证明 | Accepted |
|
| [ADR-0490](./ADR-0490-run-history-terminal-preservation.md) | Run History 终态保全与跨领域完成证明 | Accepted |
|
||||||
| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 第一切片) |
|
| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 inspection + row plan) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
+27
-14
@@ -232,6 +232,31 @@ function selectSql(schema: ReadonlySet<string>): string {
|
|||||||
ORDER BY ${pinned} DESC, ${position} DESC, ${createdAt} ASC, "id" ASC`;
|
ORDER BY ${pinned} DESC, ${position} DESC, ${createdAt} ASC, "id" ASC`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function* iterateRows(
|
||||||
|
client: DatabaseSync,
|
||||||
|
schema: ReadonlySet<string>,
|
||||||
|
): Iterable<LegacyRow> {
|
||||||
|
let iterator: Iterator<Record<string, unknown>>;
|
||||||
|
try {
|
||||||
|
iterator = client
|
||||||
|
.prepare(selectSql(schema))
|
||||||
|
.iterate()
|
||||||
|
[Symbol.iterator]() as Iterator<Record<string, unknown>>;
|
||||||
|
} catch (error) {
|
||||||
|
throw new LegacyEnvironmentInspectionError('rows are unavailable', error);
|
||||||
|
}
|
||||||
|
for (;;) {
|
||||||
|
let next: IteratorResult<Record<string, unknown>>;
|
||||||
|
try {
|
||||||
|
next = iterator.next();
|
||||||
|
} catch (error) {
|
||||||
|
throw new LegacyEnvironmentInspectionError('rows cannot be read', error);
|
||||||
|
}
|
||||||
|
if (next.done) return;
|
||||||
|
yield next.value as LegacyRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function reasons(row: LegacyRow): readonly LegacyEnvironmentRowReason[] {
|
function reasons(row: LegacyRow): readonly LegacyEnvironmentRowReason[] {
|
||||||
const selected: LegacyEnvironmentRowReason[] = [];
|
const selected: LegacyEnvironmentRowReason[] = [];
|
||||||
if (!Number.isSafeInteger(row.id) || (row.id as number) < 1) {
|
if (!Number.isSafeInteger(row.id) || (row.id as number) < 1) {
|
||||||
@@ -361,10 +386,7 @@ export function visitLegacyEnvironmentAdoption(
|
|||||||
let preservationReadyCount = 0;
|
let preservationReadyCount = 0;
|
||||||
let activeValueBytes = 0;
|
let activeValueBytes = 0;
|
||||||
|
|
||||||
try {
|
for (const raw of iterateRows(client, schema)) {
|
||||||
for (const raw of client
|
|
||||||
.prepare(selectSql(schema))
|
|
||||||
.iterate() as Iterable<LegacyRow>) {
|
|
||||||
rowOrdinal += 1;
|
rowOrdinal += 1;
|
||||||
const digestValue = sourceDigest(raw);
|
const digestValue = sourceDigest(raw);
|
||||||
const rowReasons = reasons(raw);
|
const rowReasons = reasons(raw);
|
||||||
@@ -435,13 +457,6 @@ export function visitLegacyEnvironmentAdoption(
|
|||||||
group.values.push(raw.value);
|
group.values.push(raw.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof LegacyEnvironmentInspectionError) throw error;
|
|
||||||
throw new LegacyEnvironmentInspectionError(
|
|
||||||
'rows cannot be inspected',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (rowOrdinal !== count) {
|
if (rowOrdinal !== count) {
|
||||||
throw new LegacyEnvironmentInspectionError('row count drifted');
|
throw new LegacyEnvironmentInspectionError('row count drifted');
|
||||||
}
|
}
|
||||||
@@ -488,9 +503,7 @@ export function visitLegacyEnvironmentAdoption(
|
|||||||
|
|
||||||
if (!globalBudgetExceeded && preservationReadyCount > 0) {
|
if (!globalBudgetExceeded && preservationReadyCount > 0) {
|
||||||
rowOrdinal = 0;
|
rowOrdinal = 0;
|
||||||
for (const raw of client
|
for (const raw of iterateRows(client, schema)) {
|
||||||
.prepare(selectSql(schema))
|
|
||||||
.iterate() as Iterable<LegacyRow>) {
|
|
||||||
rowOrdinal += 1;
|
rowOrdinal += 1;
|
||||||
if (raw.status !== 1 || reasons(raw).length !== 0) continue;
|
if (raw.status !== 1 || reasons(raw).length !== 0) continue;
|
||||||
const digestValue = sourceDigest(raw);
|
const digestValue = sourceDigest(raw);
|
||||||
|
|||||||
@@ -212,3 +212,34 @@ test('rejects unsupported schemas and over-budget Edge tables without scanning r
|
|||||||
overBudget.close();
|
overBudget.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preserves a visitor failure instead of disguising it as a SQLite read error', () => {
|
||||||
|
const database = memoryDatabase(`
|
||||||
|
CREATE TABLE "Envs" (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
value TEXT,
|
||||||
|
status INTEGER,
|
||||||
|
position REAL,
|
||||||
|
"isPinned" INTEGER,
|
||||||
|
"createdAt" TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO "Envs" VALUES
|
||||||
|
(1, 'TOKEN', 'private-value', 0, 1, 0, '2026-01-01');
|
||||||
|
`);
|
||||||
|
const expected = new Error('caller byte budget exceeded');
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
visitLegacyEnvironmentAdoption(database, {
|
||||||
|
profile: 'edge',
|
||||||
|
visitRow() {
|
||||||
|
throw expected;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(error) => error === expected,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+597
@@ -0,0 +1,597 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
import {
|
||||||
|
visitLegacyEnvironmentAdoption,
|
||||||
|
type LegacyEnvironmentCandidate,
|
||||||
|
type LegacyEnvironmentInventory,
|
||||||
|
type LegacyEnvironmentRowInspection,
|
||||||
|
} from '@qinglong/local-admin/reconciliation-secret-and-config-inspection';
|
||||||
|
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||||
|
import { cutoverDigest } from '../../../cutover/targetEvidence';
|
||||||
|
|
||||||
|
const HEADER_KIND = 'qinglong3-local-reconciliation-secret-config-plan-header';
|
||||||
|
const ROW_KIND = 'qinglong3-local-reconciliation-secret-config-plan-row';
|
||||||
|
const CANDIDATE_KIND =
|
||||||
|
'qinglong3-local-reconciliation-secret-config-plan-candidate';
|
||||||
|
const FOOTER_KIND = 'qinglong3-local-reconciliation-secret-config-plan-footer';
|
||||||
|
const RECEIPT_SCHEMA =
|
||||||
|
'qinglong3-local-reconciliation-secret-config-plan-receipt';
|
||||||
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
|
const UUID_V4_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
const MAX_LINE_BYTES = 64 * 1024;
|
||||||
|
const HASH_BUFFER_BYTES = 64 * 1024;
|
||||||
|
export const MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES =
|
||||||
|
8 * 1024 * 1024;
|
||||||
|
export const MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES =
|
||||||
|
32 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigPlanHeader {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly kind: typeof HEADER_KIND;
|
||||||
|
readonly secretConfigId: string;
|
||||||
|
readonly applicationId: string;
|
||||||
|
readonly applicationPlanDigest: string;
|
||||||
|
readonly reviewDigest: string;
|
||||||
|
readonly reviewAuthorizationDigest: string;
|
||||||
|
readonly reviewDecisionSetDigest: string;
|
||||||
|
readonly reviewDecisionFileDigest: string;
|
||||||
|
readonly bundleDigest: string;
|
||||||
|
readonly bundleFingerprintDigest: string;
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly tableDisposition: 'adopt_legacy' | 'retain_both';
|
||||||
|
readonly preparedHeadDigest: string;
|
||||||
|
readonly preparedAtMs: number;
|
||||||
|
readonly headerDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigPlanRow {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly kind: typeof ROW_KIND;
|
||||||
|
readonly rowOrdinal: number;
|
||||||
|
readonly sourceDigest: string;
|
||||||
|
readonly disposition: LegacyEnvironmentRowInspection['disposition'];
|
||||||
|
readonly reasons: LegacyEnvironmentRowInspection['reasons'];
|
||||||
|
readonly rowPlanDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalReconciliationSecretConfigCandidateRequirement =
|
||||||
|
| 'review_apply_binding'
|
||||||
|
| 'review_preserve_disabled'
|
||||||
|
| 'review_skip_conflict';
|
||||||
|
|
||||||
|
export type LocalReconciliationSecretConfigTarget =
|
||||||
|
| Readonly<{ state: 'absent' }>
|
||||||
|
| Readonly<{
|
||||||
|
state: 'occupied';
|
||||||
|
version: number;
|
||||||
|
contentDigest: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigPlanCandidate {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly kind: typeof CANDIDATE_KIND;
|
||||||
|
readonly candidateOrdinal: number;
|
||||||
|
readonly candidateType: LegacyEnvironmentCandidate['kind'];
|
||||||
|
readonly candidateDigest: string;
|
||||||
|
readonly sourceRowCount: number;
|
||||||
|
readonly sourceSetDigest: string;
|
||||||
|
readonly proposedSecretName: string;
|
||||||
|
readonly target: LocalReconciliationSecretConfigTarget;
|
||||||
|
readonly requirement: LocalReconciliationSecretConfigCandidateRequirement;
|
||||||
|
readonly candidatePlanDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigPlanSummary {
|
||||||
|
readonly tableState: LegacyEnvironmentInventory['tableState'];
|
||||||
|
readonly rowCount: number;
|
||||||
|
readonly activeRowCount: number;
|
||||||
|
readonly disabledRowCount: number;
|
||||||
|
readonly manualRowCount: number;
|
||||||
|
readonly activeGroupCount: number;
|
||||||
|
readonly bindingReadyCount: number;
|
||||||
|
readonly preservationReadyCount: number;
|
||||||
|
readonly manualGroupCount: number;
|
||||||
|
readonly eligibleBindingCount: number;
|
||||||
|
readonly eligiblePreservationCount: number;
|
||||||
|
readonly targetConflictCount: number;
|
||||||
|
readonly outcome: 'ready' | 'manual_required' | 'no_effect';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigPlanFooter
|
||||||
|
extends LocalReconciliationSecretConfigPlanSummary {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly kind: typeof FOOTER_KIND;
|
||||||
|
readonly secretConfigId: string;
|
||||||
|
readonly legacyInventoryDigest: string;
|
||||||
|
readonly rowSetDigest: string;
|
||||||
|
readonly candidateSetDigest: string;
|
||||||
|
readonly secretConfigPlanDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigPlanReceipt
|
||||||
|
extends LocalReconciliationSecretConfigPlanSummary {
|
||||||
|
readonly schema: typeof RECEIPT_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly state: 'reconciliation_secret_config_planned';
|
||||||
|
readonly secretConfigId: string;
|
||||||
|
readonly applicationId: string;
|
||||||
|
readonly applicationPlanDigest: string;
|
||||||
|
readonly preparedHeadDigest: string;
|
||||||
|
readonly legacyInventoryDigest: string;
|
||||||
|
readonly rowSetDigest: string;
|
||||||
|
readonly candidateSetDigest: string;
|
||||||
|
readonly secretConfigPlanDigest: string;
|
||||||
|
readonly planFileBytes: number;
|
||||||
|
readonly planFileDigest: string;
|
||||||
|
readonly preparedAtMs: number;
|
||||||
|
readonly receiptDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteLocalReconciliationSecretConfigPlanOptions {
|
||||||
|
readonly descriptor: number;
|
||||||
|
readonly maxBytes: number;
|
||||||
|
readonly header: Omit<
|
||||||
|
LocalReconciliationSecretConfigPlanHeader,
|
||||||
|
'headerDigest'
|
||||||
|
>;
|
||||||
|
readonly legacy: DatabaseSync;
|
||||||
|
readonly target: DatabaseSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string, cause?: unknown): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation secret config row plan ${message}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(
|
||||||
|
value: unknown,
|
||||||
|
keys: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
fail(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const actual = Object.keys(record).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
) {
|
||||||
|
fail(`${label} shape is invalid`);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(value: unknown): Buffer {
|
||||||
|
const bytes = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
|
||||||
|
if (bytes.byteLength < 3 || bytes.byteLength > MAX_LINE_BYTES + 1) {
|
||||||
|
bytes.fill(0);
|
||||||
|
fail('record exceeds its line bound');
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAll(descriptor: number, bytes: Buffer): void {
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < bytes.byteLength) {
|
||||||
|
const written = fs.writeSync(
|
||||||
|
descriptor,
|
||||||
|
bytes,
|
||||||
|
offset,
|
||||||
|
bytes.byteLength - offset,
|
||||||
|
);
|
||||||
|
if (written < 1) fail('write stalled');
|
||||||
|
offset += written;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesDigest(value: unknown, length: number, label: string): string {
|
||||||
|
if (!(value instanceof Uint8Array) || value.byteLength !== length) {
|
||||||
|
fail(`target ${label} is invalid`);
|
||||||
|
}
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetSecret(
|
||||||
|
target: DatabaseSync,
|
||||||
|
projectId: string,
|
||||||
|
secretName: string,
|
||||||
|
): LocalReconciliationSecretConfigTarget {
|
||||||
|
let row: Readonly<Record<string, unknown>> | undefined;
|
||||||
|
try {
|
||||||
|
row = target
|
||||||
|
.prepare(
|
||||||
|
`SELECT "version", "mutation_id" AS "mutationId",
|
||||||
|
"key_id" AS "keyId", "algorithm", "nonce", "ciphertext",
|
||||||
|
"auth_tag" AS "authTag", "created_at_ms" AS "createdAtMs"
|
||||||
|
FROM "QingLong3LocalSecretEnvelopes"
|
||||||
|
WHERE "project_id" = ? AND "secret_name" = ?
|
||||||
|
ORDER BY "version" DESC LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get(projectId, secretName) as
|
||||||
|
| Readonly<Record<string, unknown>>
|
||||||
|
| undefined;
|
||||||
|
} catch (error) {
|
||||||
|
return fail('target Secret projection is unavailable', error);
|
||||||
|
}
|
||||||
|
if (!row) return Object.freeze({ state: 'absent' as const });
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(row.version) ||
|
||||||
|
(row.version as number) < 1 ||
|
||||||
|
typeof row.mutationId !== 'string' ||
|
||||||
|
row.mutationId.length < 1 ||
|
||||||
|
row.mutationId.length > 64 ||
|
||||||
|
typeof row.keyId !== 'string' ||
|
||||||
|
row.keyId.length < 1 ||
|
||||||
|
row.keyId.length > 128 ||
|
||||||
|
row.algorithm !== 'aes-256-gcm' ||
|
||||||
|
!Number.isSafeInteger(row.createdAtMs) ||
|
||||||
|
(row.createdAtMs as number) < 0
|
||||||
|
) {
|
||||||
|
fail('target Secret projection drifted');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(row.ciphertext instanceof Uint8Array) ||
|
||||||
|
row.ciphertext.byteLength > 16 * 1024
|
||||||
|
) {
|
||||||
|
fail('target ciphertext is invalid');
|
||||||
|
}
|
||||||
|
const contentDigest = cutoverDigest({
|
||||||
|
projectId,
|
||||||
|
secretName,
|
||||||
|
version: row.version,
|
||||||
|
mutationId: row.mutationId,
|
||||||
|
keyId: row.keyId,
|
||||||
|
algorithm: row.algorithm,
|
||||||
|
nonceDigest: bytesDigest(row.nonce, 12, 'nonce'),
|
||||||
|
ciphertextDigest: bytesDigest(
|
||||||
|
row.ciphertext,
|
||||||
|
row.ciphertext.byteLength,
|
||||||
|
'ciphertext',
|
||||||
|
),
|
||||||
|
authTagDigest: bytesDigest(row.authTag, 16, 'auth tag'),
|
||||||
|
createdAtMs: row.createdAtMs,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
state: 'occupied' as const,
|
||||||
|
version: row.version as number,
|
||||||
|
contentDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function secretName(candidate: Readonly<LegacyEnvironmentCandidate>): string {
|
||||||
|
const source =
|
||||||
|
candidate.kind === 'active_binding'
|
||||||
|
? candidate.environmentName
|
||||||
|
: `${candidate.environmentName}\0${candidate.sourceDigest}`;
|
||||||
|
const suffix = createHash('sha256').update(source).digest('hex').slice(0, 32);
|
||||||
|
return candidate.kind === 'active_binding'
|
||||||
|
? `legacy-db-env-${suffix}`
|
||||||
|
: `legacy-db-env-disabled-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function planRow(
|
||||||
|
value: Readonly<LegacyEnvironmentRowInspection>,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigPlanRow> {
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
kind: ROW_KIND,
|
||||||
|
rowOrdinal: value.rowOrdinal,
|
||||||
|
sourceDigest: value.sourceDigest,
|
||||||
|
disposition: value.disposition,
|
||||||
|
reasons: value.reasons,
|
||||||
|
});
|
||||||
|
return Object.freeze({ ...payload, rowPlanDigest: cutoverDigest(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function planCandidate(
|
||||||
|
value: Readonly<LegacyEnvironmentCandidate>,
|
||||||
|
candidateOrdinal: number,
|
||||||
|
target: DatabaseSync,
|
||||||
|
projectId: string,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigPlanCandidate> {
|
||||||
|
const proposedSecretName = secretName(value);
|
||||||
|
const selectedTarget = targetSecret(target, projectId, proposedSecretName);
|
||||||
|
const sourceRowCount =
|
||||||
|
value.kind === 'active_binding' ? value.sourceRowCount : 1;
|
||||||
|
const sourceSetDigest =
|
||||||
|
value.kind === 'active_binding'
|
||||||
|
? value.sourceSetDigest
|
||||||
|
: value.sourceDigest;
|
||||||
|
const requirement: LocalReconciliationSecretConfigCandidateRequirement =
|
||||||
|
selectedTarget.state === 'occupied'
|
||||||
|
? 'review_skip_conflict'
|
||||||
|
: value.kind === 'active_binding'
|
||||||
|
? 'review_apply_binding'
|
||||||
|
: 'review_preserve_disabled';
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
kind: CANDIDATE_KIND,
|
||||||
|
candidateOrdinal,
|
||||||
|
candidateType: value.kind,
|
||||||
|
candidateDigest: value.candidateDigest,
|
||||||
|
sourceRowCount,
|
||||||
|
sourceSetDigest,
|
||||||
|
proposedSecretName,
|
||||||
|
target: selectedTarget,
|
||||||
|
requirement,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
candidatePlanDigest: cutoverDigest(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeLocalReconciliationSecretConfigPlan(
|
||||||
|
options: Readonly<WriteLocalReconciliationSecretConfigPlanOptions>,
|
||||||
|
): Readonly<{
|
||||||
|
header: Readonly<LocalReconciliationSecretConfigPlanHeader>;
|
||||||
|
footer: Readonly<LocalReconciliationSecretConfigPlanFooter>;
|
||||||
|
fileBytes: number;
|
||||||
|
fileDigest: string;
|
||||||
|
}> {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(options.maxBytes) ||
|
||||||
|
options.maxBytes < MAX_LINE_BYTES
|
||||||
|
) {
|
||||||
|
fail('byte budget is invalid');
|
||||||
|
}
|
||||||
|
const header = Object.freeze({
|
||||||
|
...options.header,
|
||||||
|
headerDigest: cutoverDigest(options.header),
|
||||||
|
});
|
||||||
|
const fileHash = createHash('sha256');
|
||||||
|
const rowHash = createHash('sha256').update(
|
||||||
|
'qinglong3.local-reconciliation-secret-config-row-set.v1\0',
|
||||||
|
);
|
||||||
|
const candidateHash = createHash('sha256').update(
|
||||||
|
'qinglong3.local-reconciliation-secret-config-candidate-set.v1\0',
|
||||||
|
);
|
||||||
|
let fileBytes = 0;
|
||||||
|
const append = (
|
||||||
|
value: unknown,
|
||||||
|
set: 'none' | 'row' | 'candidate' = 'none',
|
||||||
|
): void => {
|
||||||
|
const bytes = line(value);
|
||||||
|
try {
|
||||||
|
if (fileBytes + bytes.byteLength > options.maxBytes) {
|
||||||
|
fail('exceeds profile byte budget');
|
||||||
|
}
|
||||||
|
writeAll(options.descriptor, bytes);
|
||||||
|
fileHash.update(bytes);
|
||||||
|
if (set === 'row') rowHash.update(bytes);
|
||||||
|
if (set === 'candidate') candidateHash.update(bytes);
|
||||||
|
fileBytes += bytes.byteLength;
|
||||||
|
} finally {
|
||||||
|
bytes.fill(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
append(header);
|
||||||
|
let candidateOrdinal = 0;
|
||||||
|
let eligibleBindingCount = 0;
|
||||||
|
let eligiblePreservationCount = 0;
|
||||||
|
let targetConflictCount = 0;
|
||||||
|
const inventory = visitLegacyEnvironmentAdoption(options.legacy, {
|
||||||
|
profile: header.profile,
|
||||||
|
visitRow(value) {
|
||||||
|
append(planRow(value), 'row');
|
||||||
|
},
|
||||||
|
visitCandidate(value) {
|
||||||
|
candidateOrdinal += 1;
|
||||||
|
const candidate = planCandidate(
|
||||||
|
value,
|
||||||
|
candidateOrdinal,
|
||||||
|
options.target,
|
||||||
|
header.projectId,
|
||||||
|
);
|
||||||
|
if (candidate.requirement === 'review_apply_binding') {
|
||||||
|
eligibleBindingCount += 1;
|
||||||
|
} else if (candidate.requirement === 'review_preserve_disabled') {
|
||||||
|
eligiblePreservationCount += 1;
|
||||||
|
} else {
|
||||||
|
targetConflictCount += 1;
|
||||||
|
}
|
||||||
|
append(candidate, 'candidate');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary: LocalReconciliationSecretConfigPlanSummary = Object.freeze({
|
||||||
|
tableState: inventory.tableState,
|
||||||
|
rowCount: inventory.rowCount,
|
||||||
|
activeRowCount: inventory.activeRowCount,
|
||||||
|
disabledRowCount: inventory.disabledRowCount,
|
||||||
|
manualRowCount: inventory.manualRowCount,
|
||||||
|
activeGroupCount: inventory.activeGroupCount,
|
||||||
|
bindingReadyCount: inventory.bindingReadyCount,
|
||||||
|
preservationReadyCount: inventory.preservationReadyCount,
|
||||||
|
manualGroupCount: inventory.manualGroupCount,
|
||||||
|
eligibleBindingCount,
|
||||||
|
eligiblePreservationCount,
|
||||||
|
targetConflictCount,
|
||||||
|
outcome:
|
||||||
|
inventory.tableState === 'absent' || inventory.rowCount === 0
|
||||||
|
? ('no_effect' as const)
|
||||||
|
: !inventory.mutationReady || targetConflictCount > 0
|
||||||
|
? ('manual_required' as const)
|
||||||
|
: ('ready' as const),
|
||||||
|
});
|
||||||
|
const footerPayload = Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
kind: FOOTER_KIND,
|
||||||
|
secretConfigId: header.secretConfigId,
|
||||||
|
...summary,
|
||||||
|
legacyInventoryDigest: inventory.inventoryDigest,
|
||||||
|
rowSetDigest: rowHash.digest('hex'),
|
||||||
|
candidateSetDigest: candidateHash.digest('hex'),
|
||||||
|
});
|
||||||
|
const footer = Object.freeze({
|
||||||
|
...footerPayload,
|
||||||
|
secretConfigPlanDigest: cutoverDigest({
|
||||||
|
headerDigest: header.headerDigest,
|
||||||
|
...footerPayload,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
append(footer);
|
||||||
|
return Object.freeze({
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
fileBytes,
|
||||||
|
fileDigest: fileHash.digest('hex'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationSecretConfigPlanReceipt(
|
||||||
|
header: Readonly<LocalReconciliationSecretConfigPlanHeader>,
|
||||||
|
footer: Readonly<LocalReconciliationSecretConfigPlanFooter>,
|
||||||
|
planFileBytes: number,
|
||||||
|
planFileDigest: string,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: RECEIPT_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
state: 'reconciliation_secret_config_planned' as const,
|
||||||
|
secretConfigId: header.secretConfigId,
|
||||||
|
applicationId: header.applicationId,
|
||||||
|
applicationPlanDigest: header.applicationPlanDigest,
|
||||||
|
preparedHeadDigest: header.preparedHeadDigest,
|
||||||
|
legacyInventoryDigest: footer.legacyInventoryDigest,
|
||||||
|
rowSetDigest: footer.rowSetDigest,
|
||||||
|
candidateSetDigest: footer.candidateSetDigest,
|
||||||
|
secretConfigPlanDigest: footer.secretConfigPlanDigest,
|
||||||
|
planFileBytes,
|
||||||
|
planFileDigest,
|
||||||
|
tableState: footer.tableState,
|
||||||
|
rowCount: footer.rowCount,
|
||||||
|
activeRowCount: footer.activeRowCount,
|
||||||
|
disabledRowCount: footer.disabledRowCount,
|
||||||
|
manualRowCount: footer.manualRowCount,
|
||||||
|
activeGroupCount: footer.activeGroupCount,
|
||||||
|
bindingReadyCount: footer.bindingReadyCount,
|
||||||
|
preservationReadyCount: footer.preservationReadyCount,
|
||||||
|
manualGroupCount: footer.manualGroupCount,
|
||||||
|
eligibleBindingCount: footer.eligibleBindingCount,
|
||||||
|
eligiblePreservationCount: footer.eligiblePreservationCount,
|
||||||
|
targetConflictCount: footer.targetConflictCount,
|
||||||
|
outcome: footer.outcome,
|
||||||
|
preparedAtMs: header.preparedAtMs,
|
||||||
|
});
|
||||||
|
return Object.freeze({ ...payload, receiptDigest: cutoverDigest(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationSecretConfigPlanReceipt(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
|
||||||
|
const receipt = exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'activeGroupCount',
|
||||||
|
'activeRowCount',
|
||||||
|
'applicationId',
|
||||||
|
'applicationPlanDigest',
|
||||||
|
'bindingReadyCount',
|
||||||
|
'candidateSetDigest',
|
||||||
|
'disabledRowCount',
|
||||||
|
'eligibleBindingCount',
|
||||||
|
'eligiblePreservationCount',
|
||||||
|
'legacyInventoryDigest',
|
||||||
|
'manualGroupCount',
|
||||||
|
'manualRowCount',
|
||||||
|
'outcome',
|
||||||
|
'planFileBytes',
|
||||||
|
'planFileDigest',
|
||||||
|
'preparedAtMs',
|
||||||
|
'preparedHeadDigest',
|
||||||
|
'preservationReadyCount',
|
||||||
|
'receiptDigest',
|
||||||
|
'rowCount',
|
||||||
|
'rowSetDigest',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'secretConfigId',
|
||||||
|
'secretConfigPlanDigest',
|
||||||
|
'state',
|
||||||
|
'tableState',
|
||||||
|
'targetConflictCount',
|
||||||
|
],
|
||||||
|
'receipt',
|
||||||
|
);
|
||||||
|
const { receiptDigest, ...payload } = receipt;
|
||||||
|
if (
|
||||||
|
receipt.schema !== RECEIPT_SCHEMA ||
|
||||||
|
receipt.schemaVersion !== 1 ||
|
||||||
|
receipt.state !== 'reconciliation_secret_config_planned' ||
|
||||||
|
typeof receipt.secretConfigId !== 'string' ||
|
||||||
|
!UUID_V4_PATTERN.test(receipt.secretConfigId) ||
|
||||||
|
typeof receipt.applicationId !== 'string' ||
|
||||||
|
!UUID_V4_PATTERN.test(receipt.applicationId) ||
|
||||||
|
![
|
||||||
|
receipt.applicationPlanDigest,
|
||||||
|
receipt.preparedHeadDigest,
|
||||||
|
receipt.legacyInventoryDigest,
|
||||||
|
receipt.rowSetDigest,
|
||||||
|
receipt.candidateSetDigest,
|
||||||
|
receipt.secretConfigPlanDigest,
|
||||||
|
receipt.planFileDigest,
|
||||||
|
receiptDigest,
|
||||||
|
].every(
|
||||||
|
(candidate) =>
|
||||||
|
typeof candidate === 'string' && DIGEST_PATTERN.test(candidate),
|
||||||
|
) ||
|
||||||
|
![
|
||||||
|
receipt.rowCount,
|
||||||
|
receipt.activeRowCount,
|
||||||
|
receipt.disabledRowCount,
|
||||||
|
receipt.manualRowCount,
|
||||||
|
receipt.activeGroupCount,
|
||||||
|
receipt.bindingReadyCount,
|
||||||
|
receipt.preservationReadyCount,
|
||||||
|
receipt.manualGroupCount,
|
||||||
|
receipt.eligibleBindingCount,
|
||||||
|
receipt.eligiblePreservationCount,
|
||||||
|
receipt.targetConflictCount,
|
||||||
|
receipt.planFileBytes,
|
||||||
|
receipt.preparedAtMs,
|
||||||
|
].every((count) => Number.isSafeInteger(count) && (count as number) >= 0) ||
|
||||||
|
!['absent', 'supported', 'unsupported_schema', 'budget_exceeded'].includes(
|
||||||
|
receipt.tableState as string,
|
||||||
|
) ||
|
||||||
|
!['ready', 'manual_required', 'no_effect'].includes(
|
||||||
|
receipt.outcome as string,
|
||||||
|
) ||
|
||||||
|
cutoverDigest(payload) !== receiptDigest
|
||||||
|
) {
|
||||||
|
fail('receipt drifted');
|
||||||
|
}
|
||||||
|
return Object.freeze(
|
||||||
|
receipt,
|
||||||
|
) as unknown as Readonly<LocalReconciliationSecretConfigPlanReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashLocalReconciliationSecretConfigPlanFile(
|
||||||
|
descriptor: number,
|
||||||
|
expectedBytes: number,
|
||||||
|
): string {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES);
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < expectedBytes) {
|
||||||
|
const count = fs.readSync(
|
||||||
|
descriptor,
|
||||||
|
buffer,
|
||||||
|
0,
|
||||||
|
Math.min(buffer.byteLength, expectedBytes - offset),
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
if (count < 1) fail('plan file read stalled');
|
||||||
|
hash.update(buffer.subarray(0, count));
|
||||||
|
offset += count;
|
||||||
|
}
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
const { test } = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildLocalReconciliationSecretConfigPlanReceipt,
|
||||||
|
hashLocalReconciliationSecretConfigPlanFile,
|
||||||
|
normalizeLocalReconciliationSecretConfigPlanReceipt,
|
||||||
|
writeLocalReconciliationSecretConfigPlan,
|
||||||
|
} = require('../dist/deployment/reconciliation/application/secret-and-config/rowPlan');
|
||||||
|
|
||||||
|
const DIGEST = 'a'.repeat(64);
|
||||||
|
const HEADER = Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
kind: 'qinglong3-local-reconciliation-secret-config-plan-header',
|
||||||
|
secretConfigId: '10000000-0000-4000-8000-000000000001',
|
||||||
|
applicationId: '20000000-0000-4000-8000-000000000002',
|
||||||
|
applicationPlanDigest: DIGEST,
|
||||||
|
reviewDigest: 'b'.repeat(64),
|
||||||
|
reviewAuthorizationDigest: 'c'.repeat(64),
|
||||||
|
reviewDecisionSetDigest: 'd'.repeat(64),
|
||||||
|
reviewDecisionFileDigest: 'e'.repeat(64),
|
||||||
|
bundleDigest: 'f'.repeat(64),
|
||||||
|
bundleFingerprintDigest: '1'.repeat(64),
|
||||||
|
profile: 'edge',
|
||||||
|
projectId: 'project-1',
|
||||||
|
tableDisposition: 'adopt_legacy',
|
||||||
|
preparedHeadDigest: '2'.repeat(64),
|
||||||
|
preparedAtMs: 1_780_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
function databases() {
|
||||||
|
const legacy = new DatabaseSync(':memory:');
|
||||||
|
legacy.exec(`
|
||||||
|
CREATE TABLE "Envs" (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
value TEXT,
|
||||||
|
status INTEGER,
|
||||||
|
position REAL,
|
||||||
|
"isPinned" INTEGER,
|
||||||
|
"createdAt" TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const target = new DatabaseSync(':memory:');
|
||||||
|
target.exec(`
|
||||||
|
CREATE TABLE "QingLong3LocalSecretEnvelopes" (
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
secret_name TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
mutation_id TEXT NOT NULL,
|
||||||
|
key_id TEXT NOT NULL,
|
||||||
|
algorithm TEXT NOT NULL,
|
||||||
|
nonce BLOB NOT NULL,
|
||||||
|
ciphertext BLOB NOT NULL,
|
||||||
|
auth_tag BLOB NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (project_id, secret_name, version)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
return { legacy, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePlan(t, legacy, target, maxBytes = 8 * 1024 * 1024) {
|
||||||
|
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');
|
||||||
|
const descriptor = fs.openSync(filePath, 'w+', 0o600);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = writeLocalReconciliationSecretConfigPlan({
|
||||||
|
descriptor,
|
||||||
|
maxBytes,
|
||||||
|
header: HEADER,
|
||||||
|
legacy,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
assert.equal(
|
||||||
|
hashLocalReconciliationSecretConfigPlanFile(descriptor, result.fileBytes),
|
||||||
|
result.fileDigest,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
serialized: fs.readFileSync(filePath, 'utf8'),
|
||||||
|
records: fs
|
||||||
|
.readFileSync(filePath, 'utf8')
|
||||||
|
.trimEnd()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => JSON.parse(line)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('writes a content-free Env plan with separate active and disabled candidates', (t) => {
|
||||||
|
const { legacy, target } = databases();
|
||||||
|
t.after(() => legacy.close());
|
||||||
|
t.after(() => target.close());
|
||||||
|
legacy.exec(`
|
||||||
|
INSERT INTO "Envs" VALUES
|
||||||
|
(1, 'TOKEN', 'later-secret', 0, 10, 0, '2026-01-01'),
|
||||||
|
(2, 'TOKEN', 'pinned-secret', 0, 1, 1, '2026-01-02'),
|
||||||
|
(3, 'DISABLED_TOKEN', 'disabled-secret', 1, 0, 0, '2026-01-03');
|
||||||
|
`);
|
||||||
|
|
||||||
|
const { result, records, serialized } = writePlan(t, legacy, target);
|
||||||
|
assert.equal(result.footer.outcome, 'ready');
|
||||||
|
assert.equal(result.footer.rowCount, 3);
|
||||||
|
assert.equal(result.footer.eligibleBindingCount, 1);
|
||||||
|
assert.equal(result.footer.eligiblePreservationCount, 1);
|
||||||
|
assert.equal(result.footer.targetConflictCount, 0);
|
||||||
|
const candidates = records.filter((record) =>
|
||||||
|
record.kind.endsWith('-candidate'),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
candidates.map(({ candidateType, requirement, proposedSecretName }) => ({
|
||||||
|
candidateType,
|
||||||
|
requirement,
|
||||||
|
prefix: proposedSecretName.replace(/[0-9a-f]{32}$/, ''),
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
candidateType: 'active_binding',
|
||||||
|
requirement: 'review_apply_binding',
|
||||||
|
prefix: 'legacy-db-env-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
candidateType: 'disabled_preservation',
|
||||||
|
requirement: 'review_preserve_disabled',
|
||||||
|
prefix: 'legacy-db-env-disabled-',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
for (const privateValue of [
|
||||||
|
'TOKEN',
|
||||||
|
'DISABLED_TOKEN',
|
||||||
|
'later-secret',
|
||||||
|
'pinned-secret',
|
||||||
|
'disabled-secret',
|
||||||
|
]) {
|
||||||
|
assert.equal(serialized.includes(privateValue), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = buildLocalReconciliationSecretConfigPlanReceipt(
|
||||||
|
result.header,
|
||||||
|
result.footer,
|
||||||
|
result.fileBytes,
|
||||||
|
result.fileDigest,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeLocalReconciliationSecretConfigPlanReceipt(receipt),
|
||||||
|
receipt,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
normalizeLocalReconciliationSecretConfigPlanReceipt({
|
||||||
|
...receipt,
|
||||||
|
eligibleBindingCount: 2,
|
||||||
|
}),
|
||||||
|
/receipt drifted/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('captures a target Secret collision without reading plaintext', (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 initial = writePlan(t, legacy, target);
|
||||||
|
const candidate = initial.records.find((record) =>
|
||||||
|
record.kind.endsWith('-candidate'),
|
||||||
|
);
|
||||||
|
target
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3LocalSecretEnvelopes" VALUES
|
||||||
|
(?, ?, 1, ?, ?, 'aes-256-gcm', ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
HEADER.projectId,
|
||||||
|
candidate.proposedSecretName,
|
||||||
|
'30000000-0000-4000-8000-000000000003',
|
||||||
|
'qlsk-test',
|
||||||
|
Buffer.alloc(12, 1),
|
||||||
|
Buffer.from('ciphertext'),
|
||||||
|
Buffer.alloc(16, 2),
|
||||||
|
HEADER.preparedAtMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const conflicted = writePlan(t, legacy, target);
|
||||||
|
assert.equal(conflicted.result.footer.outcome, 'manual_required');
|
||||||
|
assert.equal(conflicted.result.footer.eligibleBindingCount, 0);
|
||||||
|
assert.equal(conflicted.result.footer.targetConflictCount, 1);
|
||||||
|
const occupied = conflicted.records.find((record) =>
|
||||||
|
record.kind.endsWith('-candidate'),
|
||||||
|
);
|
||||||
|
assert.equal(occupied.requirement, 'review_skip_conflict');
|
||||||
|
assert.equal(occupied.target.state, 'occupied');
|
||||||
|
assert.equal(occupied.target.version, 1);
|
||||||
|
assert.match(occupied.target.contentDigest, /^[0-9a-f]{64}$/);
|
||||||
|
assert.equal(conflicted.serialized.includes('private-value'), false);
|
||||||
|
assert.equal(conflicted.serialized.includes('ciphertext'), false);
|
||||||
|
assert.equal(conflicted.serialized.includes('qlsk-test'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('makes absent Envs no-effect and malformed Env manual', (t) => {
|
||||||
|
const noEnvs = new DatabaseSync(':memory:');
|
||||||
|
const { target } = databases();
|
||||||
|
t.after(() => noEnvs.close());
|
||||||
|
t.after(() => target.close());
|
||||||
|
const empty = writePlan(t, noEnvs, target);
|
||||||
|
assert.equal(empty.result.footer.outcome, 'no_effect');
|
||||||
|
assert.equal(empty.result.footer.tableState, 'absent');
|
||||||
|
|
||||||
|
const { legacy, target: secondTarget } = databases();
|
||||||
|
t.after(() => legacy.close());
|
||||||
|
t.after(() => secondTarget.close());
|
||||||
|
legacy.exec(
|
||||||
|
`INSERT INTO "Envs" VALUES
|
||||||
|
(1, 'QL3_RESERVED', 'private-value', 0, 1, 0, '2026-01-01')`,
|
||||||
|
);
|
||||||
|
const manual = writePlan(t, legacy, secondTarget);
|
||||||
|
assert.equal(manual.result.footer.outcome, 'manual_required');
|
||||||
|
assert.equal(manual.result.footer.manualRowCount, 1);
|
||||||
|
assert.equal(manual.result.footer.eligibleBindingCount, 0);
|
||||||
|
assert.equal(manual.serialized.includes('QL3_RESERVED'), false);
|
||||||
|
assert.equal(manual.serialized.includes('private-value'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails closed before exceeding the plan byte budget', (t) => {
|
||||||
|
const { legacy, target } = databases();
|
||||||
|
t.after(() => legacy.close());
|
||||||
|
t.after(() => target.close());
|
||||||
|
legacy.exec(`
|
||||||
|
WITH RECURSIVE rows(id) AS (
|
||||||
|
SELECT 1 UNION ALL SELECT id + 1 FROM rows WHERE id < 400
|
||||||
|
)
|
||||||
|
INSERT INTO "Envs"
|
||||||
|
SELECT id, 'TOKEN_' || id, 'private-value', 0, id, 0, '2026-01-01'
|
||||||
|
FROM rows
|
||||||
|
`);
|
||||||
|
assert.throws(
|
||||||
|
() => writePlan(t, legacy, target, 64 * 1024),
|
||||||
|
/exceeds profile byte budget/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -2058,6 +2058,12 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
'src/deployment/reconciliation/application/automation/rowPlan.ts' &&
|
'src/deployment/reconciliation/application/automation/rowPlan.ts' &&
|
||||||
specifier === '@qinglong/local-admin/adoption-inspection'
|
specifier === '@qinglong/local-admin/adoption-inspection'
|
||||||
) &&
|
) &&
|
||||||
|
!(
|
||||||
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/deployment/reconciliation/application/secret-and-config/rowPlan.ts' &&
|
||||||
|
specifier ===
|
||||||
|
'@qinglong/local-admin/reconciliation-secret-and-config-inspection'
|
||||||
|
) &&
|
||||||
!(
|
!(
|
||||||
[
|
[
|
||||||
'src/deployment/compose/composeApply.ts',
|
'src/deployment/compose/composeApply.ts',
|
||||||
|
|||||||
@@ -2246,6 +2246,40 @@ test('confines reconciliation automation apply authority to exact coordinators',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('confines reconciliation Secret and Config inspection to its exact row planner', (t) => {
|
||||||
|
const root = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ql3-reconciliation-secret-config-boundary-'),
|
||||||
|
);
|
||||||
|
const secretConfigDirectory = path.join(
|
||||||
|
root,
|
||||||
|
'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(secretConfigDirectory, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(secretConfigDirectory, 'rowPlan.ts'),
|
||||||
|
"import { inspect } from '@qinglong/local-admin/reconciliation-secret-and-config-inspection';",
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(secretConfigDirectory, 'neighbor.ts'),
|
||||||
|
"import { inspect } from '@qinglong/local-admin/reconciliation-secret-and-config-inspection';",
|
||||||
|
);
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const findings = [];
|
||||||
|
auditSourceImports(root, 'packages/ql3-local-owner-cli', 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/neighbor.ts',
|
||||||
|
specifier:
|
||||||
|
'@qinglong/local-admin/reconciliation-secret-and-config-inspection',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('deleted Owner ceremony package names remain dependency tombstones', (t) => {
|
test('deleted Owner ceremony package names remain dependency tombstones', (t) => {
|
||||||
const root = fixture(
|
const root = fixture(
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localAdmin.rootSourceFileRoles,
|
rootSourceFileRoles: localAdmin.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 47,
|
sourceFiles: 48,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 9,
|
rootSourceLines: 9,
|
||||||
nestedSourceFiles: 46,
|
nestedSourceFiles: 47,
|
||||||
rootSourceFileRoles: { 'runtime.ts': 'public_export' },
|
rootSourceFileRoles: { 'runtime.ts': 'public_export' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 175,
|
sourceFiles: 176,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 174,
|
nestedSourceFiles: 175,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user