mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(ql3): prepare private reconciliation reviews
This commit is contained in:
@@ -11,9 +11,9 @@
|
||||
|
||||
最新增量证据(2026-08-21):
|
||||
|
||||
- D-391/ADR-0484(契约已冻结,待实现):`reconciliation_planned` 仍是内容无关 summary,不能为人工裁决泄漏明细或获得 import authority。
|
||||
下一切片在既有 Local Owner 的 `deployment/reconciliation/review/` 增加
|
||||
`reconciliation.review.prepare|diagnostics|commit|verify`,以
|
||||
- D-391/ADR-0484(第一切片已实现,commit/verify 待实现):`reconciliation_planned` 仍是内容无关 summary,不能为人工裁决泄漏明细或
|
||||
获得 import authority。既有 Local Owner 已在 `deployment/reconciliation/review/` 增加
|
||||
`reconciliation.review.prepare|diagnostics`,并为后续 commit/verify 冻结
|
||||
`reconciliation_planned → reconciliation_review_prepared → reconciliation_reviewed` CAS 建立唯一 review fence。diagnostics 每次只把
|
||||
一个 database/domain/fact-kind 的最多 64 条私有记录 no-replace 写入 caller 指定的 owner-only 文件;stdout 只返回 page digest、计数和
|
||||
offset,不含路径、名称或 fact digest。commit 不信任 page,而是从 exact sealed bundle 重新流式派生 canonical facts,与 Edge ≤8 MiB、
|
||||
@@ -22,7 +22,10 @@
|
||||
exact review file/plan/bundle/head fence 签名;terminal review 只保存签名 authorization、compact counts/digest 和 content-free receipt。
|
||||
verify 不打开 SQLite。实现不新增 package/dependency/binary/daemon,不把文件平铺回 `src/` 根,也不调用 DML、Secret 解密、Docker/init/
|
||||
network。后续领域 adapter 必须消费 exact `reviewDigest` 后重新认证和授权,分别定义 backup、prepare/commit、幂等与 rollback,不能把
|
||||
review completion 当作 reconciliation completion。
|
||||
review completion 当作 reconciliation completion。当前聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`,Local Owner
|
||||
`250 total / 243 pass / 7 conditional skip / 0 fail`,tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`,
|
||||
18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact 和真实 Docker readonly `2/2` 全通过。workspace 仍为 18
|
||||
packages,Local Owner `149 source / 148 nested / 1 root binary entry`;新增 3 个源码全部位于 review 子目录,基础常驻 closure 不增长。
|
||||
- D-390/ADR-0483(已接受):既有 Local Owner 已实现密封 capture 的严格只读消费与独立
|
||||
`reconciliation.plan.prepare|commit|verify`。capture v2 使用 SQLite 可识别的固定 `target.sqlite* / legacy.sqlite* /
|
||||
recovery.sqlite` 物理名和 `0400/0500` terminal seal;main-only 走 immutable readonly,WAL+SHM 完整配对走普通 readonly,hot
|
||||
|
||||
@@ -119,6 +119,23 @@ import Local SQLite mutation、adoption publisher 或 runtime execution authorit
|
||||
运行时最多一个 SQLite handle、一个 decision iterator、64 KiB hash/read buffer、64 条 page record 和固定八领域 counters。diagnostics
|
||||
output 不进入基础 Edge/Standalone artifact;Cluster/PostgreSQL/Kubernetes 不读取 Local review root、authorization 或 reviewer identity。
|
||||
|
||||
## 当前实现进度
|
||||
|
||||
D-391 第一切片已实现 `review.prepare` 与 `review.diagnostics`。prepare 读取并重验 exact terminal plan/bundle,以 instance CAS 建立
|
||||
`reconciliation_review_prepared` 唯一 fence,覆盖 head response loss、第二 review、rollback 和 restart 拒绝。diagnostics 在每次
|
||||
SQLite open 前后重验密封资产,只为一个 database/domain/fact-kind 发布最多 64 条 owner-only page;Secret、identity、history 和 unknown
|
||||
facts 固定 blocked,未知表不读取 row,terminal result 不返回路径、名称或 fact digest。page 使用 256 KiB 固定上限、deterministic
|
||||
stage、hard-link no-replace 与 fsync,重复请求只能得到 byte-exact existing page。
|
||||
|
||||
验证结果:聚焦套件 `28 total / 26 pass / 2 conditional Docker skip / 0 fail`;完整 Local Owner
|
||||
`250 total / 243 pass / 7 conditional skip / 0 fail`;tracked backend `1540 total / 1538 pass / 2 conditional skip / 0 fail`;
|
||||
18-package clean build/逐包测试、八项架构/发布审计、十四档 artifact audit 与真实 Docker readonly `2/2` 均通过。workspace 仍为 18
|
||||
packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `149 source / 148 nested / 1 root binary entry`,新增
|
||||
3 个源码全部位于 `deployment/reconciliation/review/`,没有新增 dependency 或常驻对象,基础 Edge/Standalone closure 未增长。
|
||||
|
||||
`review.commit`、强认证 User/issuer keyring authorization、terminal seal、`reconciliation_reviewed` 推进和 `review.verify` 尚未实现,因此本
|
||||
ADR 继续保持 Proposed;当前 prepared review 不授予任何 import、rollback 或 restart authority。
|
||||
|
||||
## 被拒绝的替代方案
|
||||
|
||||
### 把对象名和冲突列表直接加入 plan
|
||||
|
||||
@@ -27,6 +27,8 @@ export type LocalCutoverInstanceHeadState =
|
||||
| 'reconciliation_captured'
|
||||
| 'reconciliation_plan_prepared'
|
||||
| 'reconciliation_planned'
|
||||
| 'reconciliation_review_prepared'
|
||||
| 'reconciliation_reviewed'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -162,6 +164,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
||||
head.state !== 'reconciliation_captured' &&
|
||||
head.state !== 'reconciliation_plan_prepared' &&
|
||||
head.state !== 'reconciliation_planned' &&
|
||||
head.state !== 'reconciliation_review_prepared' &&
|
||||
head.state !== 'reconciliation_reviewed' &&
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested' &&
|
||||
head.state !== 'legacy_running' &&
|
||||
@@ -332,6 +336,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
| 'reconciliation_captured'
|
||||
| 'reconciliation_plan_prepared'
|
||||
| 'reconciliation_planned'
|
||||
| 'reconciliation_review_prepared'
|
||||
| 'reconciliation_reviewed'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -374,6 +380,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_captured' ||
|
||||
current.state === 'reconciliation_plan_prepared' ||
|
||||
current.state === 'reconciliation_planned' ||
|
||||
current.state === 'reconciliation_review_prepared' ||
|
||||
current.state === 'reconciliation_reviewed' ||
|
||||
current.state === 'legacy_restart_requested' ||
|
||||
current.state === 'legacy_running' ||
|
||||
current.state === 'legacy_ready')
|
||||
@@ -397,6 +405,10 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_captured') ||
|
||||
(state === 'reconciliation_planned' &&
|
||||
current.state === 'reconciliation_plan_prepared') ||
|
||||
(state === 'reconciliation_review_prepared' &&
|
||||
current.state === 'reconciliation_planned') ||
|
||||
(state === 'reconciliation_reviewed' &&
|
||||
current.state === 'reconciliation_review_prepared') ||
|
||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||
(state === 'legacy_restart_requested' &&
|
||||
current.state === 'rollback_prepared') ||
|
||||
|
||||
@@ -111,6 +111,12 @@ import {
|
||||
verifyLocalReconciliationPlan,
|
||||
verifyLocalReconciliationPlanCommandFile,
|
||||
} from './reconciliation/planning/preparation';
|
||||
import {
|
||||
prepareLocalReconciliationReview,
|
||||
prepareLocalReconciliationReviewCommandFile,
|
||||
writeLocalReconciliationReviewDiagnostics,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
} from './reconciliation/review/preparation';
|
||||
|
||||
export {
|
||||
commitLocalReconciliationPlan,
|
||||
@@ -119,6 +125,10 @@ export {
|
||||
prepareLocalReconciliationPlanCommandFile,
|
||||
verifyLocalReconciliationPlan,
|
||||
verifyLocalReconciliationPlanCommandFile,
|
||||
prepareLocalReconciliationReview,
|
||||
prepareLocalReconciliationReviewCommandFile,
|
||||
writeLocalReconciliationReviewDiagnostics,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -160,6 +170,30 @@ export {
|
||||
type LocalReconciliationCaptureVerifyCommand,
|
||||
type LocalReconciliationStoppedAuthority,
|
||||
} from './reconciliation/contract';
|
||||
export {
|
||||
LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS,
|
||||
normalizeLocalReconciliationReviewDiagnosticsCommand,
|
||||
normalizeLocalReconciliationReviewPrepareCommand,
|
||||
type LocalReconciliationDiagnosticFactKind,
|
||||
type LocalReconciliationReviewDiagnosticsCommand,
|
||||
type LocalReconciliationReviewDiagnosticsResult,
|
||||
type LocalReconciliationReviewOptions,
|
||||
type LocalReconciliationReviewPrepareCommand,
|
||||
type LocalReconciliationReviewPrepareResult,
|
||||
} from './reconciliation/review/contract';
|
||||
export {
|
||||
localReconciliationReviewDirectory,
|
||||
normalizeLocalReconciliationReviewIntent,
|
||||
readLocalReconciliationReviewIntent,
|
||||
type LocalReconciliationReviewDependencies,
|
||||
type LocalReconciliationReviewIntent,
|
||||
} from './reconciliation/review/preparation';
|
||||
export {
|
||||
type LocalReconciliationDiagnosticDecisionRequirement,
|
||||
type LocalReconciliationDiagnosticFact,
|
||||
type LocalReconciliationDiagnosticPage,
|
||||
type LocalReconciliationDiagnosticReason,
|
||||
} from './reconciliation/review/diagnostics';
|
||||
export {
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||
normalizeLocalReconciliationPlanCommitCommand,
|
||||
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
prepareLocalServiceManagerLegacyRollbackCommandFile,
|
||||
prepareLocalReconciliationCaptureCommandFile,
|
||||
prepareLocalReconciliationPlanCommandFile,
|
||||
prepareLocalReconciliationReviewCommandFile,
|
||||
commitLocalReconciliationCaptureCommandFile,
|
||||
commitLocalReconciliationPlanCommandFile,
|
||||
verifyLocalReconciliationCaptureCommandFile,
|
||||
verifyLocalReconciliationPlanCommandFile,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
prepareLocalDeploymentCommandFile,
|
||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||
restoreLocalDeploymentComposeCommitCommandFile,
|
||||
@@ -33,7 +35,7 @@ import {
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
@@ -68,6 +70,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
argv[0] !== 'reconciliation-plan-prepare' &&
|
||||
argv[0] !== 'reconciliation-plan-commit' &&
|
||||
argv[0] !== 'reconciliation-plan-verify' &&
|
||||
argv[0] !== 'reconciliation-review-prepare' &&
|
||||
argv[0] !== 'reconciliation-review-diagnostics' &&
|
||||
argv[0] !== 'compose-revision' &&
|
||||
argv[0] !== 'compose-preflight' &&
|
||||
argv[0] !== 'compose-apply' &&
|
||||
@@ -150,6 +154,10 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? commitLocalReconciliationPlanCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-plan-verify'
|
||||
? verifyLocalReconciliationPlanCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-review-prepare'
|
||||
? prepareLocalReconciliationReviewCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-review-diagnostics'
|
||||
? writeLocalReconciliationReviewDiagnosticsCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-revision'
|
||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-preflight'
|
||||
|
||||
@@ -136,6 +136,13 @@ function classify(
|
||||
return kind === 'legacy' ? legacyDomain(name) : targetDomain(name);
|
||||
}
|
||||
|
||||
export function classifyLocalReconciliationFact(
|
||||
kind: LocalReconciliationSealedDatabaseKind,
|
||||
name: string,
|
||||
): LocalReconciliationPlanDomain {
|
||||
return classify(kind, name);
|
||||
}
|
||||
|
||||
function quotedIdentifier(name: string): string {
|
||||
return `"${name.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
@@ -448,6 +448,35 @@ function readTerminal(
|
||||
return Object.freeze({ plan, receipt });
|
||||
}
|
||||
|
||||
export interface LocalReconciliationPlanTerminal {
|
||||
readonly intent: Readonly<LocalReconciliationPlanIntent>;
|
||||
readonly bundle: Readonly<LocalReconciliationSealedBundle>;
|
||||
readonly plan: Readonly<LocalReconciliationPlan>;
|
||||
readonly receipt: Readonly<LocalReconciliationPlanReceipt>;
|
||||
}
|
||||
|
||||
export function readLocalReconciliationPlanTerminal(
|
||||
planRoot: string,
|
||||
planId: string,
|
||||
uid: number,
|
||||
): Readonly<LocalReconciliationPlanTerminal> {
|
||||
const paths = planPaths(planRoot, planId);
|
||||
const intent = readLocalReconciliationPlanIntent(planRoot, planId);
|
||||
if (
|
||||
intent.command.options.planRoot !== planRoot ||
|
||||
intent.command.request.planId !== planId
|
||||
) {
|
||||
configurationError('terminal reconciliation plan path binding drifted');
|
||||
}
|
||||
const bundle = inspectLocalReconciliationSealedBundle(
|
||||
intent.command.options.captureRoot,
|
||||
intent.command.request.captureId,
|
||||
uid,
|
||||
);
|
||||
const terminal = readTerminal(paths, intent, bundle, uid);
|
||||
return Object.freeze({ intent, bundle, ...terminal });
|
||||
}
|
||||
|
||||
function result(
|
||||
operation: LocalReconciliationPlanTerminalResult['operation'],
|
||||
status: LocalReconciliationPlanTerminalResult['status'],
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { currentIdentity } from '../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import {
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||
type LocalReconciliationPlanDomain,
|
||||
} from '../planning/contract';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
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_PATH_BYTES = 4_096;
|
||||
|
||||
export const LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS = Object.freeze([
|
||||
'schema_object',
|
||||
'table',
|
||||
] as const);
|
||||
|
||||
export type LocalReconciliationDiagnosticFactKind =
|
||||
(typeof LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS)[number];
|
||||
|
||||
export interface LocalReconciliationReviewOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly captureRoot: string;
|
||||
readonly planRoot: string;
|
||||
readonly reviewRoot: string;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationReviewPrepareCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.review.prepare';
|
||||
readonly options: Readonly<LocalReconciliationReviewOptions>;
|
||||
readonly request: Readonly<{
|
||||
reviewId: string;
|
||||
planId: string;
|
||||
expectedPlanDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
preparedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationReviewDiagnosticsCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.review.diagnostics';
|
||||
readonly options: Readonly<LocalReconciliationReviewOptions>;
|
||||
readonly request: Readonly<{
|
||||
reviewId: string;
|
||||
expectedPreparationDigest: string;
|
||||
database: 'legacy' | 'target';
|
||||
domain: LocalReconciliationPlanDomain;
|
||||
factKind: LocalReconciliationDiagnosticFactKind;
|
||||
offset: number;
|
||||
limit: number;
|
||||
outputPath: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationReviewPrepareResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.review.prepare';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'reconciliation_review_prepared';
|
||||
readonly reviewId: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationReviewDiagnosticsResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.review.diagnostics';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'reconciliation_review_prepared';
|
||||
readonly reviewId: string;
|
||||
readonly pageDigest: string;
|
||||
readonly recordCount: number;
|
||||
readonly complete: boolean;
|
||||
readonly nextOffset: number | null;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(message);
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeAbsolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === 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 UUID v4`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeOptions(value: unknown): Readonly<LocalReconciliationReviewOptions> {
|
||||
const options = object(value, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'captureRoot',
|
||||
'deploymentRoot',
|
||||
'planRoot',
|
||||
'reviewRoot',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const identity = currentIdentity();
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
configurationError('reconciliation review command identity is invalid');
|
||||
}
|
||||
const roots = [
|
||||
safeAbsolutePath(options.deploymentRoot, 'deploymentRoot'),
|
||||
safeAbsolutePath(options.captureRoot, 'captureRoot'),
|
||||
safeAbsolutePath(options.planRoot, 'planRoot'),
|
||||
safeAbsolutePath(options.reviewRoot, 'reviewRoot'),
|
||||
];
|
||||
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('deployment, capture, plan and review roots must not overlap');
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot: roots[0]!,
|
||||
captureRoot: roots[1]!,
|
||||
planRoot: roots[2]!,
|
||||
reviewRoot: roots[3]!,
|
||||
allowRootService: options.allowRootService,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
operation:
|
||||
| 'local.deployment.reconciliation.review.prepare'
|
||||
| 'local.deployment.reconciliation.review.diagnostics',
|
||||
): Readonly<{
|
||||
options: Readonly<LocalReconciliationReviewOptions>;
|
||||
request: Record<string, unknown>;
|
||||
}> {
|
||||
const command = object(value, 'reconciliation review command');
|
||||
exact(command, ['operation', 'options', 'request', 'schemaVersion'], 'command');
|
||||
if (command.schemaVersion !== 1 || command.operation !== operation) {
|
||||
configurationError('reconciliation review command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
options: normalizeOptions(command.options),
|
||||
request: object(command.request, 'request'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationReviewPrepareCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationReviewPrepareCommand> {
|
||||
const command = normalizeCommand(
|
||||
value,
|
||||
'local.deployment.reconciliation.review.prepare',
|
||||
);
|
||||
exact(
|
||||
command.request,
|
||||
[
|
||||
'expectedHeadDigest',
|
||||
'expectedPlanDigest',
|
||||
'planId',
|
||||
'preparedAtMs',
|
||||
'reviewId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(command.request.preparedAtMs) ||
|
||||
(command.request.preparedAtMs as number) < 0
|
||||
) {
|
||||
configurationError('reconciliation review prepare command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.reconciliation.review.prepare' as const,
|
||||
options: command.options,
|
||||
request: Object.freeze({
|
||||
reviewId: identifier(command.request.reviewId, 'reviewId'),
|
||||
planId: identifier(command.request.planId, 'planId'),
|
||||
expectedPlanDigest: digest(
|
||||
command.request.expectedPlanDigest,
|
||||
'expectedPlanDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
command.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
preparedAtMs: command.request.preparedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationReviewDiagnosticsCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationReviewDiagnosticsCommand> {
|
||||
const command = normalizeCommand(
|
||||
value,
|
||||
'local.deployment.reconciliation.review.diagnostics',
|
||||
);
|
||||
exact(
|
||||
command.request,
|
||||
[
|
||||
'database',
|
||||
'domain',
|
||||
'expectedPreparationDigest',
|
||||
'factKind',
|
||||
'limit',
|
||||
'offset',
|
||||
'outputPath',
|
||||
'reviewId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
(command.request.database !== 'legacy' &&
|
||||
command.request.database !== 'target') ||
|
||||
!LOCAL_RECONCILIATION_PLAN_DOMAINS.includes(
|
||||
command.request.domain as LocalReconciliationPlanDomain,
|
||||
) ||
|
||||
!LOCAL_RECONCILIATION_DIAGNOSTIC_FACT_KINDS.includes(
|
||||
command.request.factKind as LocalReconciliationDiagnosticFactKind,
|
||||
) ||
|
||||
!Number.isSafeInteger(command.request.offset) ||
|
||||
(command.request.offset as number) < 0 ||
|
||||
(command.request.offset as number) > 4_096 ||
|
||||
!Number.isSafeInteger(command.request.limit) ||
|
||||
(command.request.limit as number) < 1 ||
|
||||
(command.request.limit as number) > 64
|
||||
) {
|
||||
configurationError('reconciliation review diagnostics command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.reconciliation.review.diagnostics' as const,
|
||||
options: command.options,
|
||||
request: Object.freeze({
|
||||
reviewId: identifier(command.request.reviewId, 'reviewId'),
|
||||
expectedPreparationDigest: digest(
|
||||
command.request.expectedPreparationDigest,
|
||||
'expectedPreparationDigest',
|
||||
),
|
||||
database: command.request.database,
|
||||
domain: command.request.domain as LocalReconciliationPlanDomain,
|
||||
factKind:
|
||||
command.request.factKind as LocalReconciliationDiagnosticFactKind,
|
||||
offset: command.request.offset as number,
|
||||
limit: command.request.limit as number,
|
||||
outputPath: safeAbsolutePath(command.request.outputPath, 'outputPath'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import { validatePrivateDirectory } from '../../foundation/files';
|
||||
import { cutoverDigest } from '../../cutover/targetEvidence';
|
||||
import { classifyLocalReconciliationFact } from '../planning/inventory';
|
||||
import type { LocalReconciliationPlanDomain } from '../planning/contract';
|
||||
import type {
|
||||
LocalReconciliationSealedBundle,
|
||||
LocalReconciliationSealedDatabaseKind,
|
||||
} from '../sealed-bundle/reader';
|
||||
import type {
|
||||
LocalReconciliationDiagnosticFactKind,
|
||||
LocalReconciliationReviewDiagnosticsCommand,
|
||||
} from './contract';
|
||||
|
||||
const PAGE_SCHEMA = 'qinglong3-local-reconciliation-diagnostic-page';
|
||||
const FACT_SCHEMA = 'qinglong3-local-reconciliation-diagnostic-fact';
|
||||
const MAX_PAGE_BYTES = 256 * 1024;
|
||||
const MAX_NAME_BYTES = 1_024;
|
||||
|
||||
export type LocalReconciliationDiagnosticDecisionRequirement =
|
||||
| 'informational'
|
||||
| 'required'
|
||||
| 'blocked';
|
||||
|
||||
export type LocalReconciliationDiagnosticReason =
|
||||
| 'catalog_evidence'
|
||||
| 'reviewable_fact'
|
||||
| 'unknown_schema'
|
||||
| 'secret_custody_required'
|
||||
| 'historical_integrity_required'
|
||||
| 'identity_custody_required';
|
||||
|
||||
export interface LocalReconciliationDiagnosticFact {
|
||||
readonly schema: typeof FACT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly ordinal: number;
|
||||
readonly database: LocalReconciliationSealedDatabaseKind;
|
||||
readonly domain: LocalReconciliationPlanDomain;
|
||||
readonly factKind: LocalReconciliationDiagnosticFactKind;
|
||||
readonly objectType: string;
|
||||
readonly name: string;
|
||||
readonly tableName: string;
|
||||
readonly rowCount: string | null;
|
||||
readonly decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
||||
readonly reason: LocalReconciliationDiagnosticReason;
|
||||
readonly factDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationDiagnosticPage {
|
||||
readonly schema: typeof PAGE_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'reconciliation_review_prepared';
|
||||
readonly reviewId: string;
|
||||
readonly planId: string;
|
||||
readonly planDigest: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly bundleDigest: string;
|
||||
readonly bundleFingerprintDigest: string;
|
||||
readonly database: LocalReconciliationSealedDatabaseKind;
|
||||
readonly domain: LocalReconciliationPlanDomain;
|
||||
readonly factKind: LocalReconciliationDiagnosticFactKind;
|
||||
readonly offset: number;
|
||||
readonly limit: number;
|
||||
readonly recordCount: number;
|
||||
readonly complete: boolean;
|
||||
readonly nextOffset: number | null;
|
||||
readonly records: readonly Readonly<LocalReconciliationDiagnosticFact>[];
|
||||
readonly pageDigest: string;
|
||||
}
|
||||
|
||||
export interface BuildLocalReconciliationDiagnosticPageOptions {
|
||||
readonly reviewId: string;
|
||||
readonly planId: string;
|
||||
readonly planDigest: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly bundle: Readonly<LocalReconciliationSealedBundle>;
|
||||
readonly command: Readonly<LocalReconciliationReviewDiagnosticsCommand>;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
function quotedIdentifier(name: string): string {
|
||||
return `"${name.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function requirement(
|
||||
domain: LocalReconciliationPlanDomain,
|
||||
): Readonly<{
|
||||
decisionRequirement: LocalReconciliationDiagnosticDecisionRequirement;
|
||||
reason: LocalReconciliationDiagnosticReason;
|
||||
}> {
|
||||
if (domain === 'unknown') {
|
||||
return Object.freeze({
|
||||
decisionRequirement: 'blocked' as const,
|
||||
reason: 'unknown_schema' as const,
|
||||
});
|
||||
}
|
||||
if (domain === 'secret_and_config') {
|
||||
return Object.freeze({
|
||||
decisionRequirement: 'blocked' as const,
|
||||
reason: 'secret_custody_required' as const,
|
||||
});
|
||||
}
|
||||
if (domain === 'run_history') {
|
||||
return Object.freeze({
|
||||
decisionRequirement: 'blocked' as const,
|
||||
reason: 'historical_integrity_required' as const,
|
||||
});
|
||||
}
|
||||
if (domain === 'identity_policy_audit') {
|
||||
return Object.freeze({
|
||||
decisionRequirement: 'blocked' as const,
|
||||
reason: 'identity_custody_required' as const,
|
||||
});
|
||||
}
|
||||
if (domain === 'schema_lineage') {
|
||||
return Object.freeze({
|
||||
decisionRequirement: 'informational' as const,
|
||||
reason: 'catalog_evidence' as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
decisionRequirement: 'required' as const,
|
||||
reason: 'reviewable_fact' as const,
|
||||
});
|
||||
}
|
||||
|
||||
function rowCount(
|
||||
client: DatabaseSync,
|
||||
domain: LocalReconciliationPlanDomain,
|
||||
tableName: string,
|
||||
objectType: string,
|
||||
): string | null {
|
||||
if (domain === 'unknown' || objectType !== 'table') return null;
|
||||
const counted = client
|
||||
.prepare(
|
||||
`SELECT CAST(COUNT(*) AS TEXT) AS countText FROM ${quotedIdentifier(
|
||||
tableName,
|
||||
)}`,
|
||||
)
|
||||
.get() as { readonly countText?: unknown } | undefined;
|
||||
if (
|
||||
typeof counted?.countText !== 'string' ||
|
||||
!/^(?:0|[1-9][0-9]*)$/.test(counted.countText)
|
||||
) {
|
||||
configurationError('diagnostic table row count drifted');
|
||||
}
|
||||
return counted.countText;
|
||||
}
|
||||
|
||||
function fact(
|
||||
ordinal: number,
|
||||
database: LocalReconciliationSealedDatabaseKind,
|
||||
domain: LocalReconciliationPlanDomain,
|
||||
factKind: LocalReconciliationDiagnosticFactKind,
|
||||
objectType: string,
|
||||
name: string,
|
||||
tableName: string,
|
||||
count: string | null,
|
||||
): Readonly<LocalReconciliationDiagnosticFact> {
|
||||
const review = requirement(domain);
|
||||
const payload = Object.freeze({
|
||||
schema: FACT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
ordinal,
|
||||
database,
|
||||
domain,
|
||||
factKind,
|
||||
objectType,
|
||||
name,
|
||||
tableName,
|
||||
rowCount: count,
|
||||
decisionRequirement: review.decisionRequirement,
|
||||
reason: review.reason,
|
||||
});
|
||||
return Object.freeze({ ...payload, factDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
function schemaFacts(
|
||||
client: DatabaseSync,
|
||||
database: LocalReconciliationSealedDatabaseKind,
|
||||
domain: LocalReconciliationPlanDomain,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Readonly<{ records: readonly Readonly<LocalReconciliationDiagnosticFact>[]; complete: boolean }> {
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT type, name, tbl_name AS tableName
|
||||
FROM sqlite_schema
|
||||
WHERE name NOT LIKE 'sqlite_%'
|
||||
ORDER BY type, name
|
||||
LIMIT 4097`,
|
||||
)
|
||||
.iterate() as IterableIterator<{
|
||||
readonly type?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly tableName?: unknown;
|
||||
}>;
|
||||
const records: LocalReconciliationDiagnosticFact[] = [];
|
||||
let seen = 0;
|
||||
let catalog = 0;
|
||||
let complete = true;
|
||||
for (const row of rows) {
|
||||
catalog += 1;
|
||||
if (catalog > 4_096) configurationError('diagnostic schema budget is exceeded');
|
||||
if (
|
||||
typeof row.type !== 'string' ||
|
||||
typeof row.name !== 'string' ||
|
||||
typeof row.tableName !== 'string' ||
|
||||
Buffer.byteLength(row.name, 'utf8') > MAX_NAME_BYTES ||
|
||||
Buffer.byteLength(row.tableName, 'utf8') > MAX_NAME_BYTES
|
||||
) {
|
||||
configurationError('diagnostic schema catalog drifted');
|
||||
}
|
||||
if (classifyLocalReconciliationFact(database, row.tableName) !== domain) {
|
||||
continue;
|
||||
}
|
||||
const ordinal = seen + 1;
|
||||
seen += 1;
|
||||
if (seen <= offset) continue;
|
||||
if (records.length >= limit) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
records.push(
|
||||
fact(
|
||||
ordinal,
|
||||
database,
|
||||
domain,
|
||||
'schema_object',
|
||||
row.type,
|
||||
row.name,
|
||||
row.tableName,
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Object.freeze({ records: Object.freeze(records), complete });
|
||||
}
|
||||
|
||||
function tableFacts(
|
||||
client: DatabaseSync,
|
||||
database: LocalReconciliationSealedDatabaseKind,
|
||||
domain: LocalReconciliationPlanDomain,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Readonly<{ records: readonly Readonly<LocalReconciliationDiagnosticFact>[]; complete: boolean }> {
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT name, type
|
||||
FROM pragma_table_list
|
||||
WHERE schema = 'main' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
LIMIT 513`,
|
||||
)
|
||||
.iterate() as IterableIterator<{
|
||||
readonly name?: unknown;
|
||||
readonly type?: unknown;
|
||||
}>;
|
||||
const records: LocalReconciliationDiagnosticFact[] = [];
|
||||
let seen = 0;
|
||||
let catalog = 0;
|
||||
let complete = true;
|
||||
for (const row of rows) {
|
||||
catalog += 1;
|
||||
if (catalog > 512) configurationError('diagnostic table budget is exceeded');
|
||||
if (
|
||||
typeof row.name !== 'string' ||
|
||||
typeof row.type !== 'string' ||
|
||||
Buffer.byteLength(row.name, 'utf8') > MAX_NAME_BYTES
|
||||
) {
|
||||
configurationError('diagnostic table catalog drifted');
|
||||
}
|
||||
if (classifyLocalReconciliationFact(database, row.name) !== domain) continue;
|
||||
const ordinal = seen + 1;
|
||||
seen += 1;
|
||||
if (seen <= offset) continue;
|
||||
if (records.length >= limit) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
records.push(
|
||||
fact(
|
||||
ordinal,
|
||||
database,
|
||||
domain,
|
||||
'table',
|
||||
row.type,
|
||||
row.name,
|
||||
row.name,
|
||||
rowCount(client, domain, row.name, row.type),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Object.freeze({ records: Object.freeze(records), complete });
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationDiagnosticPage(
|
||||
client: DatabaseSync,
|
||||
options: Readonly<BuildLocalReconciliationDiagnosticPageOptions>,
|
||||
): Readonly<LocalReconciliationDiagnosticPage> {
|
||||
const request = options.command.request;
|
||||
const selected =
|
||||
request.factKind === 'schema_object'
|
||||
? schemaFacts(
|
||||
client,
|
||||
request.database,
|
||||
request.domain,
|
||||
request.offset,
|
||||
request.limit,
|
||||
)
|
||||
: tableFacts(
|
||||
client,
|
||||
request.database,
|
||||
request.domain,
|
||||
request.offset,
|
||||
request.limit,
|
||||
);
|
||||
const nextOffset = selected.complete
|
||||
? null
|
||||
: request.offset + selected.records.length;
|
||||
const payload = Object.freeze({
|
||||
schema: PAGE_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'reconciliation_review_prepared' as const,
|
||||
reviewId: options.reviewId,
|
||||
planId: options.planId,
|
||||
planDigest: options.planDigest,
|
||||
preparationDigest: options.preparationDigest,
|
||||
bundleDigest: options.bundle.receipt.bundleDigest,
|
||||
bundleFingerprintDigest: options.bundle.fingerprintDigest,
|
||||
database: request.database,
|
||||
domain: request.domain,
|
||||
factKind: request.factKind,
|
||||
offset: request.offset,
|
||||
limit: request.limit,
|
||||
recordCount: selected.records.length,
|
||||
complete: selected.complete,
|
||||
nextOffset,
|
||||
records: selected.records,
|
||||
});
|
||||
const page = Object.freeze({ ...payload, pageDigest: cutoverDigest(payload) });
|
||||
if (Buffer.byteLength(`${JSON.stringify(page, null, 2)}\n`, 'utf8') > MAX_PAGE_BYTES) {
|
||||
configurationError('diagnostic page exceeds its 256 KiB budget');
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
function pageBytes(page: Readonly<LocalReconciliationDiagnosticPage>): Buffer {
|
||||
const bytes = Buffer.from(`${JSON.stringify(page, null, 2)}\n`, 'utf8');
|
||||
if (bytes.byteLength < 2 || bytes.byteLength > MAX_PAGE_BYTES) {
|
||||
configurationError('diagnostic page has an invalid size');
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function pageFile(
|
||||
filePath: string,
|
||||
bytes: Buffer,
|
||||
uid: number,
|
||||
allowedLinks: readonly number[],
|
||||
label: string,
|
||||
): fs.Stats {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
return configurationError(`${label} is unavailable`, error);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o600 ||
|
||||
!allowedLinks.includes(stat.nlink) ||
|
||||
stat.size !== bytes.byteLength ||
|
||||
!fs.readFileSync(filePath).equals(bytes)
|
||||
) {
|
||||
configurationError(`${label} identity or content drifted`);
|
||||
}
|
||||
return stat;
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string): void {
|
||||
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function writeStage(
|
||||
stagePath: string,
|
||||
bytes: Buffer,
|
||||
uid: number,
|
||||
): void {
|
||||
let descriptor: number | undefined;
|
||||
let created = false;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
stagePath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
created = true;
|
||||
fs.fchmodSync(descriptor, 0o600);
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if (!stat.isFile() || stat.uid !== uid || stat.nlink !== 1) {
|
||||
configurationError('diagnostic page stage identity drifted');
|
||||
}
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const written = fs.writeSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
);
|
||||
if (written < 1) configurationError('diagnostic page write stalled');
|
||||
offset += written;
|
||||
}
|
||||
fs.fsyncSync(descriptor);
|
||||
} catch (error) {
|
||||
if (created) {
|
||||
try {
|
||||
fs.unlinkSync(stagePath);
|
||||
} catch {
|
||||
// A failed cleanup leaves a deterministic fail-closed stage.
|
||||
}
|
||||
}
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
configurationError('diagnostic page stage cannot be written', error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function publishLocalReconciliationDiagnosticPage(
|
||||
outputPath: string,
|
||||
page: Readonly<LocalReconciliationDiagnosticPage>,
|
||||
uid: number,
|
||||
): 'prepared' | 'existing' {
|
||||
const parent = path.dirname(outputPath);
|
||||
validatePrivateDirectory(parent, uid, 'diagnosticOutputParent');
|
||||
const bytes = pageBytes(page);
|
||||
const stagePath = path.join(
|
||||
parent,
|
||||
`.${path.basename(outputPath)}.ql3-review-page-stage`,
|
||||
);
|
||||
const existed = fs.existsSync(outputPath);
|
||||
if (existed) pageFile(outputPath, bytes, uid, [1, 2], 'diagnostic page');
|
||||
if (fs.existsSync(stagePath)) {
|
||||
pageFile(stagePath, bytes, uid, [1, 2], 'diagnostic page stage');
|
||||
} else if (!existed) {
|
||||
writeStage(stagePath, bytes, uid);
|
||||
}
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
try {
|
||||
fs.linkSync(stagePath, outputPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
configurationError('diagnostic page cannot be published', error);
|
||||
}
|
||||
}
|
||||
syncDirectory(parent);
|
||||
}
|
||||
const target = pageFile(outputPath, bytes, uid, [1, 2], 'diagnostic page');
|
||||
if (fs.existsSync(stagePath)) {
|
||||
const stage = pageFile(
|
||||
stagePath,
|
||||
bytes,
|
||||
uid,
|
||||
[1, 2],
|
||||
'diagnostic page stage',
|
||||
);
|
||||
if (target.dev !== stage.dev || target.ino !== stage.ino) {
|
||||
configurationError('diagnostic page stage identity drifted');
|
||||
}
|
||||
fs.unlinkSync(stagePath);
|
||||
syncDirectory(parent);
|
||||
}
|
||||
pageFile(outputPath, bytes, uid, [1], 'diagnostic page');
|
||||
return existed ? 'existing' : 'prepared';
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
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,
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../../foundation/files';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
readLocalCutoverInstanceHead,
|
||||
type LocalCutoverInstanceHead,
|
||||
} from '../../cutover/instanceLineage';
|
||||
import { cutoverDigest } from '../../cutover/targetEvidence';
|
||||
import { readLocalReconciliationPlanTerminal } from '../planning/preparation';
|
||||
import {
|
||||
withLocalReconciliationSealedDatabase,
|
||||
type LocalReconciliationSealedBundleReaderDependencies,
|
||||
} from '../sealed-bundle/reader';
|
||||
import {
|
||||
normalizeLocalReconciliationReviewDiagnosticsCommand,
|
||||
normalizeLocalReconciliationReviewPrepareCommand,
|
||||
type LocalReconciliationReviewDiagnosticsCommand,
|
||||
type LocalReconciliationReviewDiagnosticsResult,
|
||||
type LocalReconciliationReviewPrepareCommand,
|
||||
type LocalReconciliationReviewPrepareResult,
|
||||
} from './contract';
|
||||
import {
|
||||
buildLocalReconciliationDiagnosticPage,
|
||||
publishLocalReconciliationDiagnosticPage,
|
||||
} from './diagnostics';
|
||||
|
||||
const INTENT_SCHEMA = 'qinglong3-local-reconciliation-review-intent';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_REVIEWS = 64;
|
||||
|
||||
export interface LocalReconciliationReviewIntent {
|
||||
readonly schema: typeof INTENT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'reconciliation_review_prepared';
|
||||
readonly command: Readonly<LocalReconciliationReviewPrepareCommand>;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly activationDigest: string;
|
||||
readonly bundleDigest: string;
|
||||
readonly bundleFingerprintDigest: string;
|
||||
readonly planReceiptDigest: string;
|
||||
readonly plannedHeadDigest: string;
|
||||
readonly preparationDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationReviewDependencies
|
||||
extends LocalReconciliationSealedBundleReaderDependencies {
|
||||
readonly afterHeadPrepared?: () => void;
|
||||
readonly beforeDiagnosticPublish?: () => void;
|
||||
readonly afterDiagnosticPublish?: () => void;
|
||||
}
|
||||
|
||||
interface LocalReconciliationReviewPaths {
|
||||
readonly root: string;
|
||||
readonly staging: string;
|
||||
readonly intent: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(message);
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function contents(value: unknown): string {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function overlaps(left: string, right: string): boolean {
|
||||
const relative = path.relative(left, right);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function localReconciliationReviewDirectory(
|
||||
reviewRoot: string,
|
||||
reviewId: string,
|
||||
): string {
|
||||
return path.join(reviewRoot, reviewId);
|
||||
}
|
||||
|
||||
function reviewPaths(
|
||||
reviewRoot: string,
|
||||
reviewId: string,
|
||||
): Readonly<LocalReconciliationReviewPaths> {
|
||||
const root = localReconciliationReviewDirectory(reviewRoot, reviewId);
|
||||
return Object.freeze({
|
||||
root,
|
||||
staging: path.join(root, 'staging'),
|
||||
intent: path.join(root, 'intent.json'),
|
||||
});
|
||||
}
|
||||
|
||||
function ensureReviewDirectory(
|
||||
reviewRoot: string,
|
||||
reviewId: string,
|
||||
uid: number,
|
||||
): Readonly<LocalReconciliationReviewPaths> {
|
||||
const paths = reviewPaths(reviewRoot, reviewId);
|
||||
const entries = fs.readdirSync(reviewRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
configurationError('reconciliation review catalog contains drift');
|
||||
}
|
||||
}
|
||||
if (entries.length >= MAX_REVIEWS && !fs.existsSync(paths.root)) {
|
||||
configurationError('reconciliation review retention limit is reached');
|
||||
}
|
||||
ensurePrivateDirectory(paths.root, uid, 'reconciliationReviewDirectory');
|
||||
ensurePrivateDirectory(paths.staging, uid, 'reconciliationReviewStaging');
|
||||
return paths;
|
||||
}
|
||||
|
||||
function validateCatalog(paths: Readonly<LocalReconciliationReviewPaths>): void {
|
||||
const allowed = new Set([
|
||||
'intent.json',
|
||||
'staging',
|
||||
'.intent.json.ql3-deploy-stage',
|
||||
]);
|
||||
for (const entry of fs.readdirSync(paths.root, { withFileTypes: true })) {
|
||||
if (!allowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||
configurationError('reconciliation review root contains unknown material');
|
||||
}
|
||||
}
|
||||
if (fs.readdirSync(paths.staging).length !== 0) {
|
||||
configurationError('reconciliation review staging contains unknown material');
|
||||
}
|
||||
}
|
||||
|
||||
function buildIntent(
|
||||
command: Readonly<LocalReconciliationReviewPrepareCommand>,
|
||||
terminal: ReturnType<typeof readLocalReconciliationPlanTerminal>,
|
||||
): Readonly<LocalReconciliationReviewIntent> {
|
||||
if (
|
||||
terminal.intent.command.options.deploymentRoot !==
|
||||
command.options.deploymentRoot ||
|
||||
terminal.intent.command.options.captureRoot !== command.options.captureRoot ||
|
||||
terminal.intent.command.options.planRoot !== command.options.planRoot ||
|
||||
terminal.intent.command.options.allowRootService !==
|
||||
command.options.allowRootService ||
|
||||
terminal.plan.planId !== command.request.planId ||
|
||||
terminal.plan.planDigest !== command.request.expectedPlanDigest ||
|
||||
command.request.preparedAtMs < terminal.plan.committedAtMs
|
||||
) {
|
||||
configurationError('reconciliation review is detached from its plan');
|
||||
}
|
||||
const payload = Object.freeze({
|
||||
schema: INTENT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'reconciliation_review_prepared' as const,
|
||||
command,
|
||||
profile: terminal.plan.profile,
|
||||
instanceId: terminal.intent.instanceId,
|
||||
cutoverId: terminal.intent.cutoverId,
|
||||
generation: terminal.intent.generation,
|
||||
activationDigest: terminal.intent.activationDigest,
|
||||
bundleDigest: terminal.plan.bundleDigest,
|
||||
bundleFingerprintDigest: terminal.bundle.fingerprintDigest,
|
||||
planReceiptDigest: terminal.receipt.receiptDigest,
|
||||
plannedHeadDigest: command.request.expectedHeadDigest,
|
||||
});
|
||||
return Object.freeze({ ...payload, preparationDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationReviewIntent(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationReviewIntent> {
|
||||
const intent = object(value, 'reconciliation review intent');
|
||||
exact(
|
||||
intent,
|
||||
[
|
||||
'activationDigest',
|
||||
'bundleDigest',
|
||||
'bundleFingerprintDigest',
|
||||
'command',
|
||||
'cutoverId',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'planReceiptDigest',
|
||||
'plannedHeadDigest',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'reconciliation review intent',
|
||||
);
|
||||
const command = normalizeLocalReconciliationReviewPrepareCommand(intent.command);
|
||||
const { preparationDigest, ...payload } = intent;
|
||||
if (
|
||||
intent.schema !== INTENT_SCHEMA ||
|
||||
intent.schemaVersion !== 1 ||
|
||||
intent.state !== 'reconciliation_review_prepared' ||
|
||||
(intent.profile !== 'edge' && intent.profile !== 'standalone') ||
|
||||
typeof intent.instanceId !== 'string' ||
|
||||
intent.instanceId.length < 1 ||
|
||||
intent.instanceId.length > 128 ||
|
||||
typeof intent.cutoverId !== 'string' ||
|
||||
intent.cutoverId.length < 1 ||
|
||||
intent.cutoverId.length > 128 ||
|
||||
!Number.isSafeInteger(intent.generation) ||
|
||||
(intent.generation as number) < 1 ||
|
||||
[
|
||||
intent.activationDigest,
|
||||
intent.bundleDigest,
|
||||
intent.bundleFingerprintDigest,
|
||||
intent.planReceiptDigest,
|
||||
intent.plannedHeadDigest,
|
||||
preparationDigest,
|
||||
].some(
|
||||
(candidate) =>
|
||||
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
|
||||
) ||
|
||||
cutoverDigest(payload) !== preparationDigest
|
||||
) {
|
||||
configurationError('reconciliation review intent drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
...(intent as unknown as LocalReconciliationReviewIntent),
|
||||
command,
|
||||
});
|
||||
}
|
||||
|
||||
export function readLocalReconciliationReviewIntent(
|
||||
reviewRoot: string,
|
||||
reviewId: string,
|
||||
): Readonly<LocalReconciliationReviewIntent> {
|
||||
return normalizeLocalReconciliationReviewIntent(
|
||||
readPrivateLocalCommandFile(reviewPaths(reviewRoot, reviewId).intent),
|
||||
);
|
||||
}
|
||||
|
||||
function validateHeadIdentity(
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||
): void {
|
||||
if (
|
||||
head.profile !== intent.profile ||
|
||||
head.cutoverId !== intent.cutoverId ||
|
||||
head.activationDigest !== intent.activationDigest ||
|
||||
head.generation !== intent.generation
|
||||
) {
|
||||
configurationError('reconciliation review instance head identity drifted');
|
||||
}
|
||||
}
|
||||
|
||||
function advancePreparedHead(
|
||||
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||
uid: number,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
return advanceLocalCutoverInstanceHead(
|
||||
{
|
||||
options: { deploymentRoot: intent.command.options.deploymentRoot },
|
||||
request: {
|
||||
cutoverId: intent.cutoverId,
|
||||
profile: intent.profile,
|
||||
instanceId: intent.instanceId,
|
||||
expectedActivationDigest: intent.activationDigest,
|
||||
requestedAtMs: intent.command.request.preparedAtMs,
|
||||
},
|
||||
},
|
||||
uid,
|
||||
'reconciliation_review_prepared',
|
||||
intent.generation,
|
||||
intent.preparationDigest,
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareLocalReconciliationReview(
|
||||
input: unknown,
|
||||
dependencies: LocalReconciliationReviewDependencies = {},
|
||||
): Readonly<LocalReconciliationReviewPrepareResult> {
|
||||
const command = normalizeLocalReconciliationReviewPrepareCommand(input);
|
||||
const identity = currentIdentity();
|
||||
validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot');
|
||||
validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot');
|
||||
validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot');
|
||||
validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot');
|
||||
const terminal = readLocalReconciliationPlanTerminal(
|
||||
command.options.planRoot,
|
||||
command.request.planId,
|
||||
identity.uid,
|
||||
);
|
||||
const intent = buildIntent(command, terminal);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
intent.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
validateHeadIdentity(head, intent);
|
||||
if (
|
||||
(head.state === 'reconciliation_planned' &&
|
||||
(head.headDigest !== command.request.expectedHeadDigest ||
|
||||
head.sourceRecordDigest !== command.request.expectedPlanDigest)) ||
|
||||
(head.state === 'reconciliation_review_prepared' &&
|
||||
head.sourceRecordDigest !== intent.preparationDigest) ||
|
||||
(head.state !== 'reconciliation_planned' &&
|
||||
head.state !== 'reconciliation_review_prepared')
|
||||
) {
|
||||
configurationError('review prepare lost the planned instance head compare-and-swap');
|
||||
}
|
||||
const paths = ensureReviewDirectory(
|
||||
command.options.reviewRoot,
|
||||
command.request.reviewId,
|
||||
identity.uid,
|
||||
);
|
||||
const serialized = contents(intent);
|
||||
preflightPublishedFile(
|
||||
paths.intent,
|
||||
serialized,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'reconciliation review intent',
|
||||
);
|
||||
const nextHead =
|
||||
head.state === 'reconciliation_review_prepared'
|
||||
? head
|
||||
: advancePreparedHead(intent, identity.uid);
|
||||
dependencies.afterHeadPrepared?.();
|
||||
const status = publishExactFile(
|
||||
paths.intent,
|
||||
serialized,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'reconciliation review intent',
|
||||
);
|
||||
validateCatalog(paths);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status,
|
||||
state: 'reconciliation_review_prepared' as const,
|
||||
reviewId: command.request.reviewId,
|
||||
preparationDigest: intent.preparationDigest,
|
||||
instanceHeadDigest: nextHead.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function validateDiagnosticsBinding(
|
||||
command: Readonly<LocalReconciliationReviewDiagnosticsCommand>,
|
||||
intent: Readonly<LocalReconciliationReviewIntent>,
|
||||
): void {
|
||||
if (
|
||||
intent.command.options.deploymentRoot !== command.options.deploymentRoot ||
|
||||
intent.command.options.captureRoot !== command.options.captureRoot ||
|
||||
intent.command.options.planRoot !== command.options.planRoot ||
|
||||
intent.command.options.reviewRoot !== command.options.reviewRoot ||
|
||||
intent.command.options.allowRootService !== command.options.allowRootService ||
|
||||
intent.command.request.reviewId !== command.request.reviewId ||
|
||||
intent.preparationDigest !== command.request.expectedPreparationDigest
|
||||
) {
|
||||
configurationError('review diagnostics are detached from preparation');
|
||||
}
|
||||
const roots = [
|
||||
command.options.deploymentRoot,
|
||||
command.options.captureRoot,
|
||||
command.options.planRoot,
|
||||
command.options.reviewRoot,
|
||||
];
|
||||
if (
|
||||
roots.some(
|
||||
(root) =>
|
||||
overlaps(root, command.request.outputPath) ||
|
||||
overlaps(command.request.outputPath, root),
|
||||
)
|
||||
) {
|
||||
configurationError('diagnostic output must be outside authority roots');
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLocalReconciliationReviewDiagnostics(
|
||||
input: unknown,
|
||||
dependencies: LocalReconciliationReviewDependencies = {},
|
||||
): Readonly<LocalReconciliationReviewDiagnosticsResult> {
|
||||
const command = normalizeLocalReconciliationReviewDiagnosticsCommand(input);
|
||||
const identity = currentIdentity();
|
||||
validatePrivateDirectory(command.options.deploymentRoot, identity.uid, 'deploymentRoot');
|
||||
validatePrivateDirectory(command.options.captureRoot, identity.uid, 'captureRoot');
|
||||
validatePrivateDirectory(command.options.planRoot, identity.uid, 'planRoot');
|
||||
validatePrivateDirectory(command.options.reviewRoot, identity.uid, 'reviewRoot');
|
||||
const paths = reviewPaths(command.options.reviewRoot, command.request.reviewId);
|
||||
validatePrivateDirectory(paths.root, identity.uid, 'reconciliationReviewDirectory');
|
||||
validatePrivateDirectory(paths.staging, identity.uid, 'reconciliationReviewStaging');
|
||||
const intent = readLocalReconciliationReviewIntent(
|
||||
command.options.reviewRoot,
|
||||
command.request.reviewId,
|
||||
);
|
||||
validateDiagnosticsBinding(command, intent);
|
||||
const terminal = readLocalReconciliationPlanTerminal(
|
||||
command.options.planRoot,
|
||||
intent.command.request.planId,
|
||||
identity.uid,
|
||||
);
|
||||
if (
|
||||
terminal.plan.planDigest !== intent.command.request.expectedPlanDigest ||
|
||||
terminal.plan.bundleDigest !== intent.bundleDigest ||
|
||||
terminal.receipt.receiptDigest !== intent.planReceiptDigest ||
|
||||
terminal.bundle.fingerprintDigest !== intent.bundleFingerprintDigest
|
||||
) {
|
||||
configurationError('review diagnostics lost the terminal plan binding');
|
||||
}
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
intent.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
validateHeadIdentity(head, intent);
|
||||
if (
|
||||
head.state !== 'reconciliation_review_prepared' ||
|
||||
head.sourceRecordDigest !== intent.preparationDigest
|
||||
) {
|
||||
configurationError('review diagnostics lost the prepared instance head');
|
||||
}
|
||||
const page = withLocalReconciliationSealedDatabase(
|
||||
terminal.bundle,
|
||||
command.request.database,
|
||||
identity.uid,
|
||||
dependencies,
|
||||
(client) =>
|
||||
buildLocalReconciliationDiagnosticPage(client, {
|
||||
reviewId: intent.command.request.reviewId,
|
||||
planId: intent.command.request.planId,
|
||||
planDigest: terminal.plan.planDigest,
|
||||
preparationDigest: intent.preparationDigest,
|
||||
bundle: terminal.bundle,
|
||||
command,
|
||||
}),
|
||||
);
|
||||
if (page === null) {
|
||||
configurationError('manual-required SQLite topology has no diagnostic page');
|
||||
}
|
||||
const currentHead = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
intent.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
if (
|
||||
currentHead.headDigest !== head.headDigest ||
|
||||
currentHead.state !== 'reconciliation_review_prepared' ||
|
||||
currentHead.sourceRecordDigest !== intent.preparationDigest
|
||||
) {
|
||||
configurationError('review diagnostics instance head changed while reading');
|
||||
}
|
||||
dependencies.beforeDiagnosticPublish?.();
|
||||
const status = publishLocalReconciliationDiagnosticPage(
|
||||
command.request.outputPath,
|
||||
page,
|
||||
identity.uid,
|
||||
);
|
||||
dependencies.afterDiagnosticPublish?.();
|
||||
validateCatalog(paths);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status,
|
||||
state: 'reconciliation_review_prepared' as const,
|
||||
reviewId: command.request.reviewId,
|
||||
pageDigest: page.pageDigest,
|
||||
recordCount: page.recordCount,
|
||||
complete: page.complete,
|
||||
nextOffset: page.nextOffset,
|
||||
instanceHeadDigest: currentHead.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function prepareLocalReconciliationReviewCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalReconciliationReviewPrepareResult> {
|
||||
return prepareLocalReconciliationReview(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
|
||||
export function writeLocalReconciliationReviewDiagnosticsCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalReconciliationReviewDiagnosticsResult> {
|
||||
return writeLocalReconciliationReviewDiagnostics(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -751,7 +751,9 @@ function replayResult(
|
||||
(head.state === 'reconciliation_capture_prepared' ||
|
||||
head.state === 'reconciliation_captured' ||
|
||||
head.state === 'reconciliation_plan_prepared' ||
|
||||
head.state === 'reconciliation_planned');
|
||||
head.state === 'reconciliation_planned' ||
|
||||
head.state === 'reconciliation_review_prepared' ||
|
||||
head.state === 'reconciliation_reviewed');
|
||||
if (
|
||||
record.actionId !== intent.actionId ||
|
||||
record.intentDigest !== intent.intentDigest ||
|
||||
|
||||
@@ -12,8 +12,10 @@ const {
|
||||
commitLocalReconciliationPlan,
|
||||
prepareLocalReconciliationCapture,
|
||||
prepareLocalReconciliationPlan,
|
||||
prepareLocalReconciliationReview,
|
||||
verifyLocalReconciliationCapture,
|
||||
verifyLocalReconciliationPlan,
|
||||
writeLocalReconciliationReviewDiagnostics,
|
||||
} = require('../dist/deployment/localDeployment.js');
|
||||
const {
|
||||
normalizeLocalReconciliationCaptureManifest,
|
||||
@@ -688,6 +690,74 @@ function preparedPlan(t, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function preparedReview(t, options = {}) {
|
||||
const state = preparedPlan(t, options);
|
||||
const planned = commitLocalReconciliationPlan(state.planCommitCommand);
|
||||
const root = path.dirname(state.captureRoot);
|
||||
const reviewRoot = path.join(root, `review-root-${options.reviewSuffix ?? '1'}`);
|
||||
const diagnosticRoot = path.join(
|
||||
root,
|
||||
`diagnostic-root-${options.reviewSuffix ?? '1'}`,
|
||||
);
|
||||
fs.mkdirSync(reviewRoot, { mode: 0o700 });
|
||||
fs.mkdirSync(diagnosticRoot, { mode: 0o700 });
|
||||
const reviewCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.review.prepare',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
captureRoot: state.captureRoot,
|
||||
planRoot: state.planRoot,
|
||||
reviewRoot,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request: {
|
||||
reviewId:
|
||||
options.reviewId ?? '00000000-0000-4000-8000-000000000301',
|
||||
planId: state.prepareCommand.request.planId,
|
||||
expectedPlanDigest: planned.planDigest,
|
||||
expectedHeadDigest: planned.instanceHeadDigest,
|
||||
preparedAtMs: 8_000,
|
||||
},
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
planned,
|
||||
reviewRoot,
|
||||
diagnosticRoot,
|
||||
reviewCommand,
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticCommand(
|
||||
state,
|
||||
prepared,
|
||||
{
|
||||
database = 'legacy',
|
||||
domain = 'automation',
|
||||
factKind = 'table',
|
||||
offset = 0,
|
||||
limit = 64,
|
||||
outputName = 'diagnostic-page.json',
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.review.diagnostics',
|
||||
options: state.reviewCommand.options,
|
||||
request: {
|
||||
reviewId: state.reviewCommand.request.reviewId,
|
||||
expectedPreparationDigest: prepared.preparationDigest,
|
||||
database,
|
||||
domain,
|
||||
factKind,
|
||||
offset,
|
||||
limit,
|
||||
outputPath: path.join(state.diagnosticRoot, outputName),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
||||
const source =
|
||||
mode === 'main_only_immutable'
|
||||
@@ -1547,6 +1617,234 @@ test('plan prepare recovers head response loss and CLI verify stays content-free
|
||||
);
|
||||
});
|
||||
|
||||
test('review prepare establishes one replayable fence and blocks rollback', (t) => {
|
||||
const state = preparedReview(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000301',
|
||||
reviewId: '00000000-0000-4000-8000-000000000311',
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
prepareLocalReconciliationReview(state.reviewCommand, {
|
||||
afterHeadPrepared() {
|
||||
throw new Error('review prepare response loss');
|
||||
},
|
||||
}),
|
||||
/review prepare response loss/,
|
||||
);
|
||||
const prepared = prepareLocalReconciliationReview(state.reviewCommand);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.state, 'reconciliation_review_prepared');
|
||||
const replay = prepareLocalReconciliationReview(state.reviewCommand);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.preparationDigest, prepared.preparationDigest);
|
||||
assert.equal(replay.instanceHeadDigest, prepared.instanceHeadDigest);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.deploymentRoot,
|
||||
state.command.request.instanceId,
|
||||
state.uid,
|
||||
);
|
||||
assert.equal(head.state, 'reconciliation_review_prepared');
|
||||
assert.equal(head.sourceRecordDigest, prepared.preparationDigest);
|
||||
|
||||
const competing = structuredClone(state.reviewCommand);
|
||||
competing.request.reviewId = '00000000-0000-4000-8000-000000000312';
|
||||
competing.options.reviewRoot = path.join(
|
||||
path.dirname(state.reviewRoot),
|
||||
'review-root-competing',
|
||||
);
|
||||
fs.mkdirSync(competing.options.reviewRoot, { mode: 0o700 });
|
||||
assert.throws(
|
||||
() => prepareLocalReconciliationReview(competing),
|
||||
/compare-and-swap/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
advanceLocalCutoverInstanceHead(
|
||||
state.identity,
|
||||
state.uid,
|
||||
'rollback_prepared',
|
||||
1,
|
||||
'f'.repeat(64),
|
||||
),
|
||||
/transition is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
advanceLocalCutoverInstanceHead(
|
||||
state.identity,
|
||||
state.uid,
|
||||
'target_active',
|
||||
2,
|
||||
'e'.repeat(64),
|
||||
),
|
||||
/transition is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('review diagnostics publish one private exact page without changing assets', (t) => {
|
||||
const state = preparedReview(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000321',
|
||||
reviewId: '00000000-0000-4000-8000-000000000322',
|
||||
});
|
||||
const prepared = prepareLocalReconciliationReview(state.reviewCommand);
|
||||
const assets = fs.readdirSync(capturePath(state, 'assets')).map((name) => ({
|
||||
name,
|
||||
bytes: fs.readFileSync(capturePath(state, `assets/${name}`)),
|
||||
stat: fs.statSync(capturePath(state, `assets/${name}`), { bigint: true }),
|
||||
}));
|
||||
const command = diagnosticCommand(state, prepared);
|
||||
const opens = [];
|
||||
const result = writeLocalReconciliationReviewDiagnostics(command, {
|
||||
beforeDatabaseOpen(kind, mode, cacheKiB) {
|
||||
opens.push({ kind, mode, cacheKiB });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(opens, [
|
||||
{ kind: 'legacy', mode: 'main_only_immutable', cacheKiB: 2048 },
|
||||
]);
|
||||
assert.equal(result.status, 'prepared');
|
||||
assert.equal(result.recordCount, 1);
|
||||
assert.equal(result.complete, true);
|
||||
assert.equal(result.nextOffset, null);
|
||||
assert.equal(JSON.stringify(result).includes(command.request.outputPath), false);
|
||||
assert.equal(JSON.stringify(result).includes('Crontabs'), false);
|
||||
const pageText = fs.readFileSync(command.request.outputPath, 'utf8');
|
||||
const page = JSON.parse(pageText);
|
||||
assert.equal(page.records[0].name, 'Crontabs');
|
||||
assert.equal(page.records[0].rowCount, '1');
|
||||
assert.equal(page.records[0].decisionRequirement, 'required');
|
||||
assert.equal(pageText.includes('0 0 * * *'), false);
|
||||
assert.equal(pageText.includes('private-value'), false);
|
||||
assert.equal(fs.statSync(command.request.outputPath).mode & 0o777, 0o600);
|
||||
assert.equal(
|
||||
writeLocalReconciliationReviewDiagnostics(command).status,
|
||||
'existing',
|
||||
);
|
||||
for (const before of assets) {
|
||||
const assetPath = capturePath(state, `assets/${before.name}`);
|
||||
const after = fs.statSync(assetPath, { bigint: true });
|
||||
assert.equal(fs.readFileSync(assetPath).equals(before.bytes), true);
|
||||
assert.equal(after.mtimeNs, before.stat.mtimeNs);
|
||||
assert.equal(after.ctimeNs, before.stat.ctimeNs);
|
||||
assert.equal(after.mode, before.stat.mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('review diagnostics keep secret and unknown facts blocked and row-free', (t) => {
|
||||
const state = preparedReview(t, {
|
||||
unknownTargetTable: true,
|
||||
planId: '00000000-0000-4000-8000-000000000331',
|
||||
reviewId: '00000000-0000-4000-8000-000000000332',
|
||||
});
|
||||
const prepared = prepareLocalReconciliationReview(state.reviewCommand);
|
||||
const secretCommand = diagnosticCommand(state, prepared, {
|
||||
domain: 'secret_and_config',
|
||||
outputName: 'secret.json',
|
||||
});
|
||||
writeLocalReconciliationReviewDiagnostics(secretCommand);
|
||||
const secretText = fs.readFileSync(secretCommand.request.outputPath, 'utf8');
|
||||
const secret = JSON.parse(secretText);
|
||||
assert.equal(secret.records[0].name, 'Envs');
|
||||
assert.equal(secret.records[0].decisionRequirement, 'blocked');
|
||||
assert.equal(secret.records[0].reason, 'secret_custody_required');
|
||||
assert.equal(secretText.includes('TOKEN'), false);
|
||||
assert.equal(secretText.includes('private-value'), false);
|
||||
|
||||
const unknownCommand = diagnosticCommand(state, prepared, {
|
||||
database: 'target',
|
||||
domain: 'unknown',
|
||||
outputName: 'unknown.json',
|
||||
});
|
||||
writeLocalReconciliationReviewDiagnostics(unknownCommand);
|
||||
const unknown = JSON.parse(
|
||||
fs.readFileSync(unknownCommand.request.outputPath, 'utf8'),
|
||||
);
|
||||
assert.equal(unknown.records[0].name, 'UnreviewedFacts');
|
||||
assert.equal(unknown.records[0].rowCount, null);
|
||||
assert.equal(unknown.records[0].decisionRequirement, 'blocked');
|
||||
assert.equal(unknown.records[0].reason, 'unknown_schema');
|
||||
|
||||
const overlapping = structuredClone(unknownCommand);
|
||||
overlapping.request.outputPath = path.join(state.reviewRoot, 'leak.json');
|
||||
assert.throws(
|
||||
() => writeLocalReconciliationReviewDiagnostics(overlapping),
|
||||
/outside authority roots/,
|
||||
);
|
||||
});
|
||||
|
||||
test('review diagnostics page at sixty-four and CLI output stays content-free', (t) => {
|
||||
const initializeDatabases = (paths) => {
|
||||
planningDatabaseInitializer()(paths);
|
||||
const target = new DatabaseSync(paths.targetDatabasePath);
|
||||
for (let index = 0; index < 70; index += 1) {
|
||||
target.exec(
|
||||
`CREATE TABLE "QingLong3TaskDefinitionExtra${String(index).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}" (id INTEGER PRIMARY KEY)`,
|
||||
);
|
||||
}
|
||||
target.close();
|
||||
fs.chmodSync(paths.targetDatabasePath, 0o600);
|
||||
};
|
||||
const state = preparedReview(t, {
|
||||
initializeDatabases,
|
||||
planId: '00000000-0000-4000-8000-000000000341',
|
||||
reviewId: '00000000-0000-4000-8000-000000000342',
|
||||
});
|
||||
const prepared = prepareLocalReconciliationReview(state.reviewCommand);
|
||||
const firstCommand = diagnosticCommand(state, prepared, {
|
||||
database: 'target',
|
||||
domain: 'automation',
|
||||
factKind: 'schema_object',
|
||||
outputName: 'page-1.json',
|
||||
});
|
||||
const first = writeLocalReconciliationReviewDiagnostics(firstCommand);
|
||||
assert.equal(first.recordCount, 64);
|
||||
assert.equal(first.complete, false);
|
||||
assert.equal(first.nextOffset, 64);
|
||||
const secondCommand = diagnosticCommand(state, prepared, {
|
||||
database: 'target',
|
||||
domain: 'automation',
|
||||
factKind: 'schema_object',
|
||||
offset: first.nextOffset,
|
||||
outputName: 'page-2.json',
|
||||
});
|
||||
const second = writeLocalReconciliationReviewDiagnostics(secondCommand);
|
||||
assert.equal(second.recordCount, 7);
|
||||
assert.equal(second.complete, true);
|
||||
assert.equal(second.nextOffset, null);
|
||||
|
||||
const cliCommand = diagnosticCommand(state, prepared, {
|
||||
database: 'target',
|
||||
domain: 'automation',
|
||||
factKind: 'table',
|
||||
limit: 1,
|
||||
outputName: 'cli-page.json',
|
||||
});
|
||||
const commandPath = path.join(state.deploymentRoot, 'review-diagnostic.json');
|
||||
fs.writeFileSync(commandPath, `${JSON.stringify(cliCommand)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
const cli = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||
'reconciliation-review-diagnostics',
|
||||
'--command-file',
|
||||
commandPath,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(cli.status, 0, cli.stderr);
|
||||
const output = JSON.parse(cli.stdout);
|
||||
assert.equal(output.recordCount, 1);
|
||||
assert.equal(cli.stdout.includes(state.reviewRoot), false);
|
||||
assert.equal(cli.stdout.includes(state.diagnosticRoot), false);
|
||||
assert.equal(cli.stdout.includes('QingLong3TaskDefinitions'), false);
|
||||
assert.equal(cli.stderr, '');
|
||||
});
|
||||
|
||||
test(
|
||||
'real stopped Docker target produces an independently verified bundle',
|
||||
{ skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' },
|
||||
|
||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 146,
|
||||
sourceFiles: 149,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 50,
|
||||
nestedSourceFiles: 145,
|
||||
nestedSourceFiles: 148,
|
||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user