feat(ql3): fence reconciliation capture preparation

This commit is contained in:
whyour
2026-08-21 15:54:49 +08:00
parent 1bf14f807c
commit a789c4a4d6
7 changed files with 995 additions and 1 deletions
@@ -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<LocalCutoverInstanceHead> {
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') ||
@@ -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,
@@ -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 <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|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|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')) {
@@ -56,6 +57,7 @@ async function main(argv: readonly string[]): Promise<void> {
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<void> {
? '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'
@@ -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<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 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<LocalReconciliationCapturePrepareCommand> {
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,
}),
});
}
@@ -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<LocalReconciliationCapturePrepareCommand>;
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<LocalReconciliationCaptureIntent>): string {
return `${JSON.stringify(intent, null, 2)}\n`;
}
export function prepareLocalReconciliationCapture(
input: unknown,
): Readonly<LocalReconciliationCapturePrepareResult> {
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<LocalReconciliationCaptureIntent> = 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<LocalReconciliationCapturePrepareResult> {
return prepareLocalReconciliationCapture(readPrivateLocalCommandFile(filePath));
}
@@ -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<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 serviceManagerStoppedPath(
command: Readonly<LocalReconciliationCapturePrepareCommand>,
): 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<LocalReconciliationCapturePrepareCommand>,
): Readonly<TargetDataReconciliationEvidence> {
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<LocalReconciliationCapturePrepareCommand>,
): 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<LocalReconciliationCapturePrepareCommand>,
uid: number,
): Readonly<LocalReconciliationStoppedProof> {
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) });
}
@@ -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, '');
});