From a789c4a4d65d8b25e877029a24ae92e82f21d13c Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 21 Aug 2026 15:54:49 +0800 Subject: [PATCH] feat(ql3): fence reconciliation capture preparation --- .../src/deployment/cutover/instanceLineage.ts | 12 + .../src/deployment/localDeployment.ts | 16 + .../src/deployment/localDeploymentCli.ts | 6 +- .../src/deployment/reconciliation/contract.ts | 220 +++++++++++ .../deployment/reconciliation/preparation.ts | 181 +++++++++ .../deployment/reconciliation/stoppedProof.ts | 205 ++++++++++ .../reconciliationCapturePrepare.test.cjs | 356 ++++++++++++++++++ 7 files changed, 995 insertions(+), 1 deletion(-) create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/contract.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/preparation.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts create mode 100644 packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts index 05718bd7..7100829d 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts @@ -23,6 +23,8 @@ export type LocalCutoverInstanceHeadState = | 'legacy_stopped' | 'target_active' | 'target_stopped' + | 'reconciliation_capture_prepared' + | 'reconciliation_captured' | 'rollback_prepared' | 'legacy_restart_requested' | 'legacy_running' @@ -154,6 +156,8 @@ function parseHead(value: unknown): Readonly { head.state !== 'legacy_stopped' && head.state !== 'target_active' && head.state !== 'target_stopped' && + head.state !== 'reconciliation_capture_prepared' && + head.state !== 'reconciliation_captured' && head.state !== 'rollback_prepared' && head.state !== 'legacy_restart_requested' && head.state !== 'legacy_running' && @@ -320,6 +324,8 @@ export function advanceLocalCutoverInstanceHead( | 'legacy_stopped' | 'target_active' | 'target_stopped' + | 'reconciliation_capture_prepared' + | 'reconciliation_captured' | 'rollback_prepared' | 'legacy_restart_requested' | 'legacy_running' @@ -358,6 +364,8 @@ export function advanceLocalCutoverInstanceHead( state === 'target_stopped' && current.generation === generation && (current.state === 'rollback_prepared' || + current.state === 'reconciliation_capture_prepared' || + current.state === 'reconciliation_captured' || current.state === 'legacy_restart_requested' || current.state === 'legacy_running' || current.state === 'legacy_ready') @@ -373,6 +381,10 @@ export function advanceLocalCutoverInstanceHead( (current.state === 'legacy_stopped' || current.state === 'target_active')) || (state === 'target_stopped' && current.state === 'target_active') || + (state === 'reconciliation_capture_prepared' && + current.state === 'target_stopped') || + (state === 'reconciliation_captured' && + current.state === 'reconciliation_capture_prepared') || (state === 'rollback_prepared' && current.state === 'target_stopped') || (state === 'legacy_restart_requested' && current.state === 'rollback_prepared') || diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts index 29664a1c..e681261e 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts @@ -93,6 +93,10 @@ import { consumeLocalServiceManagerLegacyRollback, consumeLocalServiceManagerLegacyRollbackCommandFile, } from './service-manager/legacy-rollback/consumer'; +import { + prepareLocalReconciliationCapture, + prepareLocalReconciliationCaptureCommandFile, +} from './reconciliation/preparation'; export { LocalDeploymentConfigurationError, @@ -122,6 +126,16 @@ export { type LocalDeploymentStatusCommand, type LocalDeploymentStatusResult, } from './foundation/contract'; +export { + normalizeLocalReconciliationCapturePrepareCommand, + type LocalReconciliationCapturePrepareCommand, + type LocalReconciliationCapturePrepareResult, + type LocalReconciliationStoppedAuthority, +} from './reconciliation/contract'; +export { + localReconciliationCaptureDirectory, + localReconciliationCaptureIntentPath, +} from './reconciliation/preparation'; export { prepareLocalDeploymentAdoptedBundle, runLocalDeploymentAdoptedBundleCommandFile, @@ -225,6 +239,8 @@ export { authorizeLocalServiceManagerLegacyRollbackCommandFile, consumeLocalServiceManagerLegacyRollback, consumeLocalServiceManagerLegacyRollbackCommandFile, + prepareLocalReconciliationCapture, + prepareLocalReconciliationCaptureCommandFile, }; export { localServiceManagerIntentDigest, diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts index 2ee56672..a8dca174 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts @@ -13,6 +13,7 @@ import { preflightLocalDeploymentComposeCommandFile, prepareLocalServiceManagerIntentCommandFile, prepareLocalServiceManagerLegacyRollbackCommandFile, + prepareLocalReconciliationCaptureCommandFile, prepareLocalDeploymentCommandFile, proveLocalDeploymentLegacyReadinessCommandFile, restoreLocalDeploymentComposeCommitCommandFile, @@ -27,7 +28,7 @@ import { } from './localDeployment'; const USAGE = - 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; + 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; async function main(argv: readonly string[]): Promise { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { @@ -56,6 +57,7 @@ async function main(argv: readonly string[]): Promise { argv[0] !== 'cutover-manual-diagnose' && argv[0] !== 'cutover-manual-resolution-prepare' && argv[0] !== 'cutover-manual-resolution-commit' && + argv[0] !== 'reconciliation-capture-prepare' && argv[0] !== 'compose-revision' && argv[0] !== 'compose-preflight' && argv[0] !== 'compose-apply' && @@ -126,6 +128,8 @@ async function main(argv: readonly string[]): Promise { ? 'local.deployment.cutover.manual-resolution-prepare' : 'local.deployment.cutover.manual-resolution-commit', ) + : argv[0] === 'reconciliation-capture-prepare' + ? prepareLocalReconciliationCaptureCommandFile(argv[2]!) : argv[0] === 'compose-revision' ? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!) : argv[0] === 'compose-preflight' diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/contract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/contract.ts new file mode 100644 index 00000000..39063ae8 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/contract.ts @@ -0,0 +1,220 @@ +import path from 'node:path'; + +import { currentIdentity } from '../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../foundation/error'; + +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 type LocalReconciliationStoppedAuthority = + | 'docker' + | 'service-manager'; + +export interface LocalReconciliationCapturePrepareCommand { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.capture.prepare'; + readonly options: Readonly<{ + deploymentRoot: string; + captureRoot: string; + allowRootService: boolean; + }>; + readonly request: Readonly<{ + captureId: string; + stoppedAuthority: LocalReconciliationStoppedAuthority; + profile: 'edge' | 'standalone'; + instanceId: string; + cutoverId: string; + generation: number; + activationPath: string; + legacySourcePath: string; + targetDatabasePath: string; + recoveryPath: string; + expectedActivationDigest: string; + expectedHeadDigest: string; + expectedStoppedRecordDigest: string; + preparedAtMs: number; + }>; +} + +export interface LocalReconciliationCapturePrepareResult { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.capture.prepare'; + readonly status: 'prepared' | 'existing'; + readonly state: 'reconciliation_capture_prepared'; + readonly captureId: string; + readonly preparationDigest: string; + readonly instanceHeadDigest: string; +} + +function configurationError(message: string): never { + throw new LocalDeploymentConfigurationError(message); +} + +function object(value: unknown, label: string): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function 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 digest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + configurationError(`${label} must be a SHA-256 digest`); + } + return value; +} + +export function normalizeLocalReconciliationCapturePrepareCommand( + value: unknown, +): Readonly { + const command = object(value, 'reconciliation capture prepare command'); + exact( + command, + ['operation', 'options', 'request', 'schemaVersion'], + 'command', + ); + const options = object(command.options, 'options'); + exact( + options, + ['allowRootService', 'captureRoot', 'deploymentRoot'], + 'options', + ); + const request = object(command.request, 'request'); + exact( + request, + [ + 'activationPath', + 'captureId', + 'cutoverId', + 'expectedActivationDigest', + 'expectedHeadDigest', + 'expectedStoppedRecordDigest', + 'generation', + 'instanceId', + 'legacySourcePath', + 'preparedAtMs', + 'profile', + 'recoveryPath', + 'stoppedAuthority', + 'targetDatabasePath', + ], + 'request', + ); + const identity = currentIdentity(); + if ( + command.schemaVersion !== 1 || + command.operation !== + 'local.deployment.reconciliation.capture.prepare' || + typeof options.allowRootService !== 'boolean' || + (identity.uid === 0) !== options.allowRootService || + typeof request.captureId !== 'string' || + !UUID_V4_PATTERN.test(request.captureId) || + (request.stoppedAuthority !== 'docker' && + request.stoppedAuthority !== 'service-manager') || + (request.profile !== 'edge' && request.profile !== 'standalone') || + typeof request.instanceId !== 'string' || + request.instanceId.length < 1 || + request.instanceId.length > 128 || + typeof request.cutoverId !== 'string' || + request.cutoverId.length < 1 || + request.cutoverId.length > 128 || + !Number.isSafeInteger(request.generation) || + (request.generation as number) < 1 || + !Number.isSafeInteger(request.preparedAtMs) || + (request.preparedAtMs as number) < 0 + ) { + configurationError('reconciliation capture prepare command is invalid'); + } + const deploymentRoot = safeAbsolutePath( + options.deploymentRoot, + 'deploymentRoot', + ); + const captureRoot = safeAbsolutePath(options.captureRoot, 'captureRoot'); + if (captureRoot === deploymentRoot) { + configurationError('captureRoot must be distinct from deploymentRoot'); + } + return Object.freeze({ + schemaVersion: 1 as const, + operation: + 'local.deployment.reconciliation.capture.prepare' as const, + options: Object.freeze({ + deploymentRoot, + captureRoot, + allowRootService: options.allowRootService, + }), + request: Object.freeze({ + captureId: request.captureId, + stoppedAuthority: request.stoppedAuthority, + profile: request.profile, + instanceId: request.instanceId, + cutoverId: request.cutoverId, + generation: request.generation as number, + activationPath: safeAbsolutePath( + request.activationPath, + 'activationPath', + ), + legacySourcePath: safeAbsolutePath( + request.legacySourcePath, + 'legacySourcePath', + ), + targetDatabasePath: safeAbsolutePath( + request.targetDatabasePath, + 'targetDatabasePath', + ), + recoveryPath: safeAbsolutePath(request.recoveryPath, 'recoveryPath'), + expectedActivationDigest: digest( + request.expectedActivationDigest, + 'expectedActivationDigest', + ), + expectedHeadDigest: digest( + request.expectedHeadDigest, + 'expectedHeadDigest', + ), + expectedStoppedRecordDigest: digest( + request.expectedStoppedRecordDigest, + 'expectedStoppedRecordDigest', + ), + preparedAtMs: request.preparedAtMs as number, + }), + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/preparation.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/preparation.ts new file mode 100644 index 00000000..ff2d7c42 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/preparation.ts @@ -0,0 +1,181 @@ +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, +} from '../cutover/instanceLineage'; +import { cutoverDigest } from '../cutover/targetEvidence'; +import { + normalizeLocalReconciliationCapturePrepareCommand, + type LocalReconciliationCapturePrepareCommand, + type LocalReconciliationCapturePrepareResult, +} from './contract'; +import { proveLocalReconciliationStoppedState } from './stoppedProof'; + +const INTENT_SCHEMA = 'qinglong3-local-reconciliation-capture-intent'; + +interface LocalReconciliationCaptureIntent { + readonly schema: typeof INTENT_SCHEMA; + readonly schemaVersion: 1; + readonly state: 'reconciliation_capture_prepared'; + readonly command: Readonly; + readonly stoppedProofDigest: string; + readonly reconciliationEvidenceDigest: string; + readonly preparationDigest: string; +} + +function configurationError(message: string): never { + throw new LocalDeploymentConfigurationError(message); +} + +export function localReconciliationCaptureDirectory( + captureRoot: string, + captureId: string, +): string { + return path.join(captureRoot, captureId); +} + +export function localReconciliationCaptureIntentPath( + captureRoot: string, + captureId: string, +): string { + return path.join( + localReconciliationCaptureDirectory(captureRoot, captureId), + 'intent.json', + ); +} + +function intentContents(intent: Readonly): string { + return `${JSON.stringify(intent, null, 2)}\n`; +} + +export function prepareLocalReconciliationCapture( + input: unknown, +): Readonly { + const command = normalizeLocalReconciliationCapturePrepareCommand(input); + const identity = currentIdentity(); + validatePrivateDirectory( + command.options.deploymentRoot, + identity.uid, + 'deploymentRoot', + ); + validatePrivateDirectory( + command.options.captureRoot, + identity.uid, + 'captureRoot', + ); + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + command.request.instanceId, + identity.uid, + ); + if ( + head.profile !== command.request.profile || + head.cutoverId !== command.request.cutoverId || + head.activationDigest !== command.request.expectedActivationDigest || + head.generation !== command.request.generation || + (head.state === 'target_stopped' && + (head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== + command.request.expectedStoppedRecordDigest)) || + (head.state !== 'target_stopped' && + head.state !== 'reconciliation_capture_prepared') + ) { + configurationError( + 'capture prepare lost the stopped instance head compare-and-swap', + ); + } + const proof = proveLocalReconciliationStoppedState(command, identity.uid); + const payload = Object.freeze({ + schema: INTENT_SCHEMA, + schemaVersion: 1 as const, + state: 'reconciliation_capture_prepared' as const, + command, + stoppedProofDigest: proof.proofDigest, + reconciliationEvidenceDigest: proof.reconciliationEvidenceDigest, + }); + const intent: Readonly = Object.freeze({ + ...payload, + preparationDigest: cutoverDigest(payload), + }); + if ( + head.state === 'reconciliation_capture_prepared' && + head.sourceRecordDigest !== intent.preparationDigest + ) { + configurationError('another capture owns the reconciliation fence'); + } + const captureDirectory = localReconciliationCaptureDirectory( + command.options.captureRoot, + command.request.captureId, + ); + ensurePrivateDirectory(captureDirectory, identity.uid, 'captureDirectory'); + ensurePrivateDirectory( + path.join(captureDirectory, 'staging'), + identity.uid, + 'captureStagingDirectory', + ); + const intentPath = localReconciliationCaptureIntentPath( + command.options.captureRoot, + command.request.captureId, + ); + const contents = intentContents(intent); + preflightPublishedFile( + intentPath, + contents, + 0o600, + identity.uid, + 'reconciliation capture intent', + ); + const nextHead = + head.state === 'reconciliation_capture_prepared' + ? head + : advanceLocalCutoverInstanceHead( + { + options: { deploymentRoot: command.options.deploymentRoot }, + request: { + cutoverId: command.request.cutoverId, + profile: command.request.profile, + instanceId: command.request.instanceId, + expectedActivationDigest: + command.request.expectedActivationDigest, + requestedAtMs: command.request.preparedAtMs, + }, + }, + identity.uid, + 'reconciliation_capture_prepared', + command.request.generation, + intent.preparationDigest, + ); + const status = publishExactFile( + intentPath, + contents, + 0o600, + identity.uid, + 'reconciliation capture intent', + ); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + status, + state: 'reconciliation_capture_prepared' as const, + captureId: command.request.captureId, + preparationDigest: intent.preparationDigest, + instanceHeadDigest: nextHead.headDigest, + }); +} + +export function prepareLocalReconciliationCaptureCommandFile( + filePath: string, +): Readonly { + return prepareLocalReconciliationCapture(readPrivateLocalCommandFile(filePath)); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts new file mode 100644 index 00000000..cf7de864 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts @@ -0,0 +1,205 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readPrivateLocalCommandFile } from '@qinglong/local-command-file'; + +import { LocalDeploymentConfigurationError } from '../foundation/error'; +import { cutoverDigest } from '../cutover/targetEvidence'; +import { + readTargetDataReconciliationEvidenceForPaths, + verifyTargetDataReconciliationEvidence, + type TargetDataReconciliationEvidence, +} from '../cutover/targetDataEvidence'; +import { + targetStopPhasePath, + targetStopSequence, +} from '../cutover/target-run/targetRunJournal'; +import { normalizeLocalServiceManagerCutoverRecord } from '../service-manager/serviceCutoverJournal'; +import type { LocalReconciliationCapturePrepareCommand } from './contract'; + +const DOCKER_RECORD_SCHEMA = 'qinglong3-local-cutover-journal-record'; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; + +export interface LocalReconciliationStoppedProof { + readonly stoppedRecordDigest: string; + readonly reconciliationEvidenceDigest: string; + readonly proofDigest: string; +} + +function configurationError(message: string): never { + throw new LocalDeploymentConfigurationError(message); +} + +function object(value: unknown, label: string): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function serviceManagerStoppedPath( + command: Readonly, +): string { + return path.join( + command.options.deploymentRoot, + 'service', + 'cutovers', + command.request.cutoverId, + `service-manager-g${String(command.request.generation).padStart( + 2, + '0', + )}-stopped.json`, + ); +} + +function dockerStoppedEvidence( + command: Readonly, +): Readonly { + const recordPath = targetStopPhasePath( + path.join( + command.options.deploymentRoot, + 'service', + 'cutovers', + command.request.cutoverId, + ), + command.request.generation, + 'outcome', + ); + const record = object( + readPrivateLocalCommandFile(recordPath), + 'docker target stopped record', + ); + exact( + record, + [ + 'activationDigest', + 'cutoverId', + 'evidence', + 'generation', + 'instanceId', + 'previousRecordDigest', + 'profile', + 'recordDigest', + 'requestedAtMs', + 'schema', + 'schemaVersion', + 'sequence', + 'state', + ], + 'docker target stopped record', + ); + const { recordDigest, ...payload } = record; + const evidence = object(record.evidence, 'docker target stopped evidence'); + exact( + evidence, + [ + 'activeRecordDigest', + 'reconciliation', + 'startupReceiptDigest', + 'targetApplicationBindingDigest', + 'targetContainerIdentityDigest', + ], + 'docker target stopped evidence', + ); + if ( + record.schema !== DOCKER_RECORD_SCHEMA || + record.schemaVersion !== 1 || + record.sequence !== + targetStopSequence(command.request.generation, 'outcome') || + record.state !== 'target_stopped' || + record.cutoverId !== command.request.cutoverId || + record.profile !== command.request.profile || + record.instanceId !== command.request.instanceId || + record.activationDigest !== command.request.expectedActivationDigest || + record.generation !== command.request.generation || + recordDigest !== command.request.expectedStoppedRecordDigest || + typeof record.previousRecordDigest !== 'string' || + !DIGEST_PATTERN.test(record.previousRecordDigest) || + !Number.isSafeInteger(record.requestedAtMs) || + (record.requestedAtMs as number) < 0 || + typeof recordDigest !== 'string' || + !DIGEST_PATTERN.test(recordDigest) || + cutoverDigest(payload) !== recordDigest + ) { + configurationError('docker target stopped record drifted'); + } + return verifyTargetDataReconciliationEvidence(evidence.reconciliation); +} + +function serviceManagerStoppedRecord( + command: Readonly, +): void { + const record = normalizeLocalServiceManagerCutoverRecord( + readPrivateLocalCommandFile(serviceManagerStoppedPath(command)), + ); + if ( + record.state !== 'target_stopped' || + record.cutoverId !== command.request.cutoverId || + record.profile !== command.request.profile || + record.instanceId !== command.request.instanceId || + record.activationDigest !== command.request.expectedActivationDigest || + record.generation !== command.request.generation || + record.recordDigest !== command.request.expectedStoppedRecordDigest || + record.action !== 'stop' || + record.evidence.shutdownReceiptDigest === null || + record.evidence.manualReason !== null + ) { + configurationError('service manager target stopped record drifted'); + } +} + +export function proveLocalReconciliationStoppedState( + command: Readonly, + uid: number, +): Readonly { + const persisted = + command.request.stoppedAuthority === 'docker' + ? dockerStoppedEvidence(command) + : (serviceManagerStoppedRecord(command), undefined); + const current = readTargetDataReconciliationEvidenceForPaths( + { + profile: command.request.profile, + activationPath: command.request.activationPath, + legacySourcePath: command.request.legacySourcePath, + targetDatabasePath: command.request.targetDatabasePath, + expectedActivationDigest: command.request.expectedActivationDigest, + }, + uid, + ); + if ( + current.disposition !== 'reconciliation_required' || + (persisted !== undefined && + persisted.evidenceDigest !== current.evidenceDigest) + ) { + configurationError( + 'stopped data does not have exact reconciliation-required evidence', + ); + } + const payload = Object.freeze({ + stoppedAuthority: command.request.stoppedAuthority, + stoppedRecordDigest: command.request.expectedStoppedRecordDigest, + reconciliationEvidenceDigest: current.evidenceDigest, + }); + return Object.freeze({ ...payload, proofDigest: cutoverDigest(payload) }); +} diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs new file mode 100644 index 00000000..9b92d556 --- /dev/null +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -0,0 +1,356 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +const { + prepareLocalReconciliationCapture, +} = require('../dist/deployment/localDeployment.js'); +const { + advanceLocalCutoverInstanceHead, + claimLocalCutoverInstance, + readLocalCutoverInstanceHead, +} = require('../dist/deployment/cutover/instanceLineage.js'); +const { + readTargetDataReconciliationEvidenceForPaths, +} = require('../dist/deployment/cutover/targetDataEvidence.js'); +const { + targetRunJournalRecord, + targetStopPhasePath, + targetStopSequence, +} = require('../dist/deployment/cutover/target-run/targetRunJournal.js'); +const { + targetStoppedEvidence, +} = require('../dist/deployment/cutover/targetStopRecordEvidence.js'); + +function digest(value) { + return crypto + .createHash('sha256') + .update(JSON.stringify(value), 'utf8') + .digest('hex'); +} + +function rootAcknowledgement() { + return typeof process.getuid === 'function' && process.getuid() === 0; +} + +function fixture( + t, + { reconciliationRequired = true, stoppedAuthority = 'docker' } = {}, +) { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-reconciliation-capture-')), + ); + fs.chmodSync(root, 0o700); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const deploymentRoot = path.join(root, 'runtime'); + const serviceRoot = path.join(deploymentRoot, 'service'); + const cutoverId = 'capture-cutover-1'; + const journal = path.join(serviceRoot, 'cutovers', cutoverId); + const instanceRoot = path.join(serviceRoot, 'cutover-instances'); + const captureRoot = path.join(root, 'capture-root'); + for (const directory of [ + deploymentRoot, + serviceRoot, + path.dirname(journal), + journal, + instanceRoot, + captureRoot, + ]) { + if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 }); + } + const legacySourcePath = path.join(root, 'database.sqlite'); + const targetDatabasePath = path.join(root, 'database.ql3.sqlite'); + const recoveryPath = path.join(root, 'database.recovery.sqlite'); + const activationPath = path.join(root, 'activation.json'); + fs.writeFileSync(legacySourcePath, 'legacy-source\n', { mode: 0o600 }); + fs.writeFileSync(targetDatabasePath, 'target-initial\n', { mode: 0o600 }); + fs.writeFileSync(recoveryPath, 'legacy-source\n', { mode: 0o600 }); + const targetStat = fs.statSync(targetDatabasePath, { bigint: true }); + const activationPayload = { + schemaVersion: 1, + kind: 'qinglong3-local-sqlite-activation', + state: 'prepared', + profile: 'edge', + createdAtMs: 1_000, + adoptionManifestDigest: '1'.repeat(64), + planDigest: '2'.repeat(64), + sourcePathDigest: crypto + .createHash('sha256') + .update(legacySourcePath, 'utf8') + .digest('hex'), + sourceSha256: crypto + .createHash('sha256') + .update(fs.readFileSync(legacySourcePath)) + .digest('hex'), + recoverySha256: crypto + .createHash('sha256') + .update(fs.readFileSync(recoveryPath)) + .digest('hex'), + targetSha256: crypto + .createHash('sha256') + .update(fs.readFileSync(targetDatabasePath)) + .digest('hex'), + targetPathDigest: crypto + .createHash('sha256') + .update(targetDatabasePath, 'utf8') + .digest('hex'), + targetDevice: targetStat.dev.toString(), + targetInode: targetStat.ino.toString(), + }; + const activationDigest = digest(activationPayload); + fs.writeFileSync( + activationPath, + `${JSON.stringify({ ...activationPayload, activationDigest })}\n`, + { mode: 0o600 }, + ); + if (reconciliationRequired) { + fs.writeFileSync(targetDatabasePath, 'target-mutated\n', { mode: 0o600 }); + } + const identity = { + options: { deploymentRoot }, + request: { + cutoverId, + profile: 'edge', + instanceId: 'edge-router-1', + expectedActivationDigest: activationDigest, + requestedAtMs: 2_000, + }, + }; + const uid = typeof process.getuid === 'function' ? process.getuid() : 0; + claimLocalCutoverInstance(identity, uid, '3'.repeat(64)); + advanceLocalCutoverInstanceHead( + identity, + uid, + 'legacy_stopped', + 0, + '4'.repeat(64), + ); + advanceLocalCutoverInstanceHead( + identity, + uid, + 'target_active', + 1, + '5'.repeat(64), + ); + const reconciliation = readTargetDataReconciliationEvidenceForPaths( + { + profile: 'edge', + activationPath, + legacySourcePath, + targetDatabasePath, + expectedActivationDigest: activationDigest, + }, + uid, + ); + const runCommand = { + request: { + cutoverId, + profile: 'edge', + instanceId: 'edge-router-1', + expectedActivationDigest: activationDigest, + generation: 1, + requestedAtMs: 3_000, + }, + }; + let stoppedRecord; + if (stoppedAuthority === 'docker') { + stoppedRecord = targetRunJournalRecord( + runCommand, + targetStopSequence(1, 'outcome'), + 'target_stopped', + '6'.repeat(64), + targetStoppedEvidence( + { + activeRecordDigest: '5'.repeat(64), + targetContainerIdentityDigest: '7'.repeat(64), + targetApplicationBindingDigest: '8'.repeat(64), + startupReceiptDigest: '9'.repeat(64), + }, + reconciliation, + ), + ); + fs.writeFileSync( + targetStopPhasePath(journal, 1, 'outcome'), + `${JSON.stringify(stoppedRecord)}\n`, + { mode: 0o600 }, + ); + } else { + const evidence = { + managerOutcomeDigest: '6'.repeat(64), + managerObservationDigest: '7'.repeat(64), + applicationConfigDigest: '8'.repeat(64), + activationDigest, + commitmentDigest: '9'.repeat(64), + targetDataIdentityDigest: 'a'.repeat(64), + startupReceiptDigest: 'b'.repeat(64), + shutdownReceiptDigest: 'c'.repeat(64), + processIdentityDigest: 'd'.repeat(64), + manualReason: null, + }; + const payload = { + schema: 'qinglong3-local-service-manager-cutover-record', + schemaVersion: 1, + actionId: '00000000-0000-4000-8000-000000000201', + action: 'stop', + state: 'target_stopped', + cutoverId, + profile: 'edge', + instanceId: 'edge-router-1', + activationDigest, + generation: 1, + previousRecordDigest: '5'.repeat(64), + intentDigest: 'e'.repeat(64), + requestedAtMs: 2_500, + completedAtMs: 3_000, + evidence, + }; + stoppedRecord = { ...payload, recordDigest: digest(payload) }; + fs.writeFileSync( + path.join(journal, 'service-manager-g01-stopped.json'), + `${JSON.stringify(stoppedRecord)}\n`, + { mode: 0o600 }, + ); + } + const stoppedHead = advanceLocalCutoverInstanceHead( + identity, + uid, + 'target_stopped', + 1, + stoppedRecord.recordDigest, + ); + const command = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.capture.prepare', + options: { + deploymentRoot, + captureRoot, + allowRootService: rootAcknowledgement(), + }, + request: { + captureId: '00000000-0000-4000-8000-000000000101', + stoppedAuthority, + profile: 'edge', + instanceId: 'edge-router-1', + cutoverId, + generation: 1, + activationPath, + legacySourcePath, + targetDatabasePath, + recoveryPath, + expectedActivationDigest: activationDigest, + expectedHeadDigest: stoppedHead.headDigest, + expectedStoppedRecordDigest: stoppedRecord.recordDigest, + preparedAtMs: 4_000, + }, + }; + return { command, deploymentRoot, captureRoot, identity, stoppedHead, uid }; +} + +test('capture prepare establishes one replayable reconciliation fence', (t) => { + const state = fixture(t); + const prepared = prepareLocalReconciliationCapture(state.command); + assert.equal(prepared.status, 'prepared'); + assert.equal(prepared.state, 'reconciliation_capture_prepared'); + assert.equal( + fs.existsSync( + path.join( + state.captureRoot, + state.command.request.captureId, + 'intent.json', + ), + ), + true, + ); + const head = readLocalCutoverInstanceHead( + state.deploymentRoot, + state.command.request.instanceId, + state.uid, + ); + assert.equal(head.state, 'reconciliation_capture_prepared'); + assert.equal(head.sourceRecordDigest, prepared.preparationDigest); + + const replay = prepareLocalReconciliationCapture(state.command); + assert.equal(replay.status, 'existing'); + assert.equal(replay.instanceHeadDigest, prepared.instanceHeadDigest); + + assert.throws( + () => + advanceLocalCutoverInstanceHead( + state.identity, + state.uid, + 'rollback_prepared', + 1, + 'a'.repeat(64), + ), + /transition is invalid/, + ); + const conflicting = structuredClone(state.command); + conflicting.request.captureId = '00000000-0000-4000-8000-000000000102'; + assert.throws( + () => prepareLocalReconciliationCapture(conflicting), + /another capture owns/, + ); +}); + +test('capture prepare rejects rollback-candidate stopped data', (t) => { + const state = fixture(t, { reconciliationRequired: false }); + assert.throws( + () => prepareLocalReconciliationCapture(state.command), + /reconciliation-required evidence/, + ); + const head = readLocalCutoverInstanceHead( + state.deploymentRoot, + state.command.request.instanceId, + state.uid, + ); + assert.equal(head.headDigest, state.stoppedHead.headDigest); + assert.equal( + fs.existsSync( + path.join( + state.captureRoot, + state.command.request.captureId, + 'intent.json', + ), + ), + false, + ); +}); + +test('service-manager stopped authority uses the same capture fence', (t) => { + const state = fixture(t, { stoppedAuthority: 'service-manager' }); + const prepared = prepareLocalReconciliationCapture(state.command); + assert.equal(prepared.status, 'prepared'); + assert.equal(prepared.state, 'reconciliation_capture_prepared'); + const replay = prepareLocalReconciliationCapture(state.command); + assert.equal(replay.status, 'existing'); + assert.equal(replay.preparationDigest, prepared.preparationDigest); +}); + +test('capture prepare CLI consumes a private command and emits no paths', (t) => { + const state = fixture(t); + const commandPath = path.join(state.deploymentRoot, 'capture-command.json'); + fs.writeFileSync(commandPath, `${JSON.stringify(state.command)}\n`, { + mode: 0o600, + }); + const result = spawnSync( + process.execPath, + [ + path.join(__dirname, '../dist/deployment/localDeploymentCli.js'), + 'reconciliation-capture-prepare', + '--command-file', + commandPath, + ], + { encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.equal(output.state, 'reconciliation_capture_prepared'); + assert.equal(output.captureId, state.command.request.captureId); + assert.equal(result.stdout.includes(state.captureRoot), false); + assert.equal(result.stdout.includes(state.command.request.targetDatabasePath), false); + assert.equal(result.stderr, ''); +});