Files
qinglong/packages/ql3-local-owner-cli/src/deployment/reconciliation/bundle.ts
T

951 lines
30 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { currentIdentity } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import {
ensurePrivateDirectory,
publishExactFile,
syncPublishedDirectory,
validatePrivateDirectory,
} from '../foundation/files';
import {
advanceLocalCutoverInstanceHead,
readLocalCutoverInstanceHead,
type LocalCutoverInstanceHead,
} from '../cutover/instanceLineage';
import { cutoverDigest } from '../cutover/targetEvidence';
import {
normalizeLocalReconciliationCaptureCommitCommand,
normalizeLocalReconciliationCaptureVerifyCommand,
type LocalReconciliationCaptureCommitCommand,
type LocalReconciliationCaptureTerminalResult,
} from './contract';
import {
localReconciliationCaptureDirectory,
readLocalReconciliationCaptureIntent,
type LocalReconciliationCaptureIntent,
} from './preparation';
import { proveLocalReconciliationLineage } from './lineageProof';
import { proveLocalReconciliationStoppedState } from './stoppedProof';
import {
copyLocalReconciliationAsset,
localReconciliationCaptureAssetFileName,
localReconciliationCaptureAssetPlan,
verifyLocalReconciliationPublishedAsset,
verifyLocalReconciliationSidecarPlan,
verifyLocalReconciliationSourceSnapshot,
type LocalReconciliationCapturedAsset,
type LocalReconciliationStableCopyDependencies,
} from './stableCopy';
const MANIFEST_SCHEMA = 'qinglong3-local-reconciliation-capture-manifest';
const RECEIPT_SCHEMA = 'qinglong3-local-reconciliation-capture-receipt';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const LOGICAL_NAMES = [
'target-main',
'target-wal',
'target-shm',
'target-journal',
'legacy-main',
'legacy-wal',
'legacy-shm',
'legacy-journal',
'recovery-main',
] as const;
export interface LocalReconciliationCaptureManifest {
readonly schema: typeof MANIFEST_SCHEMA;
readonly schemaVersion: 2;
readonly state: 'reconciliation_captured';
readonly captureId: string;
readonly profile: 'edge' | 'standalone';
readonly preparationDigest: string;
readonly stoppedRecordDigest: string;
readonly stoppedProofDigest: string;
readonly reconciliationEvidenceDigest: string;
readonly lineageProjectionDigest: string;
readonly legacyBaselineSha256: string;
readonly targetBaselineSha256: string;
readonly preparedHeadDigest: string;
readonly committedAtMs: number;
readonly assets: readonly Readonly<LocalReconciliationCapturedAsset>[];
readonly totalBytes: number;
readonly manifestDigest: string;
}
export interface LocalReconciliationCaptureReceipt {
readonly schema: typeof RECEIPT_SCHEMA;
readonly schemaVersion: 2;
readonly state: 'reconciliation_captured';
readonly captureId: string;
readonly profile: 'edge' | 'standalone';
readonly preparationDigest: string;
readonly manifestDigest: string;
readonly assetCount: number;
readonly totalBytes: number;
readonly committedAtMs: number;
readonly bundleDigest: string;
}
export interface LocalReconciliationCaptureDependencies {
readonly stableCopy?: LocalReconciliationStableCopyDependencies;
readonly afterAssetPublished?: (
logicalName: LocalReconciliationCapturedAsset['logicalName'],
) => void;
readonly afterManifestPublished?: () => void;
readonly afterReceiptPublished?: () => void;
readonly afterAssetSealed?: (
logicalName: LocalReconciliationCapturedAsset['logicalName'],
) => void;
readonly afterAssetsSealed?: () => void;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
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 capturePaths(
captureRoot: string,
captureId: string,
): Readonly<{
root: string;
staging: string;
assets: string;
manifest: string;
receipt: string;
}> {
const root = localReconciliationCaptureDirectory(captureRoot, captureId);
return Object.freeze({
root,
staging: path.join(root, 'staging'),
assets: path.join(root, 'assets'),
manifest: path.join(root, 'manifest.json'),
receipt: path.join(root, 'receipt.json'),
});
}
function validateIntentBinding(
intent: Readonly<LocalReconciliationCaptureIntent>,
command: Readonly<LocalReconciliationCaptureCommitCommand>,
): void {
if (
intent.command.options.deploymentRoot !== command.options.deploymentRoot ||
intent.command.options.captureRoot !== command.options.captureRoot ||
intent.command.options.allowRootService !==
command.options.allowRootService ||
intent.command.request.captureId !== command.request.captureId ||
intent.preparationDigest !== command.request.expectedPreparationDigest ||
command.request.committedAtMs < intent.command.request.preparedAtMs
) {
configurationError('capture commit is not bound to its exact preparation');
}
}
function validateHeadIdentity(
head: Readonly<LocalCutoverInstanceHead>,
intent: Readonly<LocalReconciliationCaptureIntent>,
): void {
if (
head.profile !== intent.command.request.profile ||
head.cutoverId !== intent.command.request.cutoverId ||
head.activationDigest !== intent.command.request.expectedActivationDigest ||
head.generation !== intent.command.request.generation
) {
configurationError('capture instance head identity drifted');
}
}
function normalizeAsset(
value: unknown,
): Readonly<LocalReconciliationCapturedAsset> {
const asset = object(value, 'capture manifest asset');
exact(
asset,
['bytes', 'logicalName', 'sha256', 'sourceIdentityDigest'],
'capture manifest asset',
);
if (
typeof asset.logicalName !== 'string' ||
!LOGICAL_NAMES.includes(
asset.logicalName as (typeof LOGICAL_NAMES)[number],
) ||
!Number.isSafeInteger(asset.bytes) ||
(asset.bytes as number) < 0 ||
typeof asset.sha256 !== 'string' ||
!DIGEST_PATTERN.test(asset.sha256) ||
typeof asset.sourceIdentityDigest !== 'string' ||
!DIGEST_PATTERN.test(asset.sourceIdentityDigest)
) {
configurationError('capture manifest asset drifted');
}
return Object.freeze({
logicalName:
asset.logicalName as LocalReconciliationCapturedAsset['logicalName'],
bytes: asset.bytes as number,
sha256: asset.sha256,
sourceIdentityDigest: asset.sourceIdentityDigest,
});
}
export function normalizeLocalReconciliationCaptureManifest(
value: unknown,
): Readonly<LocalReconciliationCaptureManifest> {
const manifest = object(value, 'reconciliation capture manifest');
exact(
manifest,
[
'assets',
'captureId',
'committedAtMs',
'legacyBaselineSha256',
'lineageProjectionDigest',
'manifestDigest',
'preparationDigest',
'preparedHeadDigest',
'profile',
'reconciliationEvidenceDigest',
'schema',
'schemaVersion',
'state',
'stoppedProofDigest',
'stoppedRecordDigest',
'targetBaselineSha256',
'totalBytes',
],
'reconciliation capture manifest',
);
if (!Array.isArray(manifest.assets)) {
configurationError('capture manifest assets must be an array');
}
const assets = Object.freeze(manifest.assets.map(normalizeAsset));
const names = assets.map((asset) => asset.logicalName);
const order = names.map((name) => LOGICAL_NAMES.indexOf(name));
const { manifestDigest, ...payload } = manifest;
if (
manifest.schema !== MANIFEST_SCHEMA ||
manifest.schemaVersion !== 2 ||
manifest.state !== 'reconciliation_captured' ||
typeof manifest.captureId !== 'string' ||
(manifest.profile !== 'edge' && manifest.profile !== 'standalone') ||
assets.length < 3 ||
assets.length > LOGICAL_NAMES.length ||
new Set(names).size !== names.length ||
order.some(
(index, position) => position > 0 && index <= order[position - 1]!,
) ||
names[0] !== 'target-main' ||
!names.includes('legacy-main') ||
names.at(-1) !== 'recovery-main' ||
!Number.isSafeInteger(manifest.totalBytes) ||
(manifest.totalBytes as number) < 1 ||
assets.reduce((total, asset) => total + asset.bytes, 0) !==
manifest.totalBytes ||
!Number.isSafeInteger(manifest.committedAtMs) ||
(manifest.committedAtMs as number) < 0 ||
[
manifest.preparationDigest,
manifest.stoppedRecordDigest,
manifest.stoppedProofDigest,
manifest.reconciliationEvidenceDigest,
manifest.lineageProjectionDigest,
manifest.legacyBaselineSha256,
manifest.targetBaselineSha256,
manifest.preparedHeadDigest,
manifestDigest,
].some(
(candidate) =>
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
) ||
cutoverDigest(payload) !== manifestDigest
) {
configurationError('reconciliation capture manifest drifted');
}
return Object.freeze({
...(manifest as unknown as LocalReconciliationCaptureManifest),
assets,
});
}
export function normalizeLocalReconciliationCaptureReceipt(
value: unknown,
): Readonly<LocalReconciliationCaptureReceipt> {
const receipt = object(value, 'reconciliation capture receipt');
exact(
receipt,
[
'assetCount',
'bundleDigest',
'captureId',
'committedAtMs',
'manifestDigest',
'preparationDigest',
'profile',
'schema',
'schemaVersion',
'state',
'totalBytes',
],
'reconciliation capture receipt',
);
const { bundleDigest, ...payload } = receipt;
if (
receipt.schema !== RECEIPT_SCHEMA ||
receipt.schemaVersion !== 2 ||
receipt.state !== 'reconciliation_captured' ||
typeof receipt.captureId !== 'string' ||
(receipt.profile !== 'edge' && receipt.profile !== 'standalone') ||
!Number.isSafeInteger(receipt.assetCount) ||
(receipt.assetCount as number) < 3 ||
(receipt.assetCount as number) > LOGICAL_NAMES.length ||
!Number.isSafeInteger(receipt.totalBytes) ||
(receipt.totalBytes as number) < 1 ||
!Number.isSafeInteger(receipt.committedAtMs) ||
(receipt.committedAtMs as number) < 0 ||
[receipt.preparationDigest, receipt.manifestDigest, bundleDigest].some(
(candidate) =>
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
) ||
cutoverDigest(payload) !== bundleDigest
) {
configurationError('reconciliation capture receipt drifted');
}
return receipt as unknown as Readonly<LocalReconciliationCaptureReceipt>;
}
function manifestContents(
manifest: Readonly<LocalReconciliationCaptureManifest>,
): string {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
function receiptContents(
receipt: Readonly<LocalReconciliationCaptureReceipt>,
): string {
return `${JSON.stringify(receipt, null, 2)}\n`;
}
function captureReceipt(
manifest: Readonly<LocalReconciliationCaptureManifest>,
): Readonly<LocalReconciliationCaptureReceipt> {
const payload = Object.freeze({
schema: RECEIPT_SCHEMA,
schemaVersion: 2 as const,
state: 'reconciliation_captured' as const,
captureId: manifest.captureId,
profile: manifest.profile,
preparationDigest: manifest.preparationDigest,
manifestDigest: manifest.manifestDigest,
assetCount: manifest.assets.length,
totalBytes: manifest.totalBytes,
committedAtMs: manifest.committedAtMs,
});
return Object.freeze({
...payload,
bundleDigest: cutoverDigest(payload),
});
}
function validateTerminalCatalog(
paths: ReturnType<typeof capturePaths>,
terminal: boolean,
): void {
const allowedRoot = new Set([
'assets',
'intent.json',
'manifest.json',
'receipt.json',
'staging',
...(!terminal
? ['.manifest.json.ql3-deploy-stage', '.receipt.json.ql3-deploy-stage']
: []),
]);
for (const entry of fs.readdirSync(paths.root, { withFileTypes: true })) {
if (!allowedRoot.has(entry.name) || entry.isSymbolicLink()) {
configurationError('capture bundle root contains unknown material');
}
}
if (fs.readdirSync(paths.staging).length !== 0) {
configurationError('capture staging root contains unknown material');
}
const allowedAssets = new Set<string>(
LOGICAL_NAMES.map(localReconciliationCaptureAssetFileName),
);
if (!terminal) {
for (const name of LOGICAL_NAMES) {
allowedAssets.add(
`.${localReconciliationCaptureAssetFileName(
name,
)}.ql3-capture-stage`,
);
}
}
for (const entry of fs.readdirSync(paths.assets, { withFileTypes: true })) {
if (
!entry.isFile() ||
entry.isSymbolicLink() ||
!allowedAssets.has(entry.name)
) {
configurationError('capture assets contain unknown material');
}
}
}
function validateSealedAssetsDirectory(directory: string, uid: number): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(directory);
} catch (error) {
return configurationError('sealed capture assets are unavailable', error);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o500 ||
fs.realpathSync(directory) !== directory
) {
configurationError(
'sealed capture assets must be a canonical current-UID 0500 directory',
);
}
}
function sealTerminalAssets(
paths: ReturnType<typeof capturePaths>,
manifest: Readonly<LocalReconciliationCaptureManifest>,
uid: number,
afterAssetSealed?: (
logicalName: LocalReconciliationCapturedAsset['logicalName'],
) => void,
): void {
const directory = fs.lstatSync(paths.assets);
if (
!directory.isDirectory() ||
directory.isSymbolicLink() ||
directory.uid !== uid ||
![0o700, 0o500].includes(directory.mode & 0o777) ||
fs.realpathSync(paths.assets) !== paths.assets
) {
configurationError('capture assets cannot be sealed');
}
if ((directory.mode & 0o777) === 0o500) {
for (const asset of manifest.assets) {
verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid, [
0o400n,
]);
}
return;
}
for (const asset of manifest.assets) {
verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid, [
0o600n,
0o400n,
]);
const assetPath = path.join(
paths.assets,
localReconciliationCaptureAssetFileName(asset.logicalName),
);
let descriptor: number | undefined;
try {
descriptor = fs.openSync(
assetPath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor);
if (
!opened.isFile() ||
opened.uid !== uid ||
opened.nlink !== 1 ||
![0o600, 0o400].includes(opened.mode & 0o777)
) {
configurationError('capture asset seal identity drifted');
}
if ((opened.mode & 0o777) === 0o600) {
fs.fchmodSync(descriptor, 0o400);
fs.fsyncSync(descriptor);
}
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
configurationError('capture asset cannot be sealed', error);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid, [
0o400n,
]);
afterAssetSealed?.(asset.logicalName);
}
let directoryDescriptor: number | undefined;
try {
directoryDescriptor = fs.openSync(paths.assets, fs.constants.O_RDONLY);
fs.fchmodSync(directoryDescriptor, 0o500);
fs.fsyncSync(directoryDescriptor);
} catch (error) {
configurationError('capture assets directory cannot be sealed', error);
} finally {
if (directoryDescriptor !== undefined) fs.closeSync(directoryDescriptor);
}
syncPublishedDirectory(paths.root);
validateSealedAssetsDirectory(paths.assets, uid);
}
function readTerminal(
paths: ReturnType<typeof capturePaths>,
uid: number,
): Readonly<{
manifest: Readonly<LocalReconciliationCaptureManifest>;
receipt: Readonly<LocalReconciliationCaptureReceipt>;
}> {
validatePrivateDirectory(paths.root, uid, 'captureDirectory');
validatePrivateDirectory(paths.staging, uid, 'captureStagingDirectory');
validateSealedAssetsDirectory(paths.assets, uid);
const manifest = normalizeLocalReconciliationCaptureManifest(
readPrivateLocalCommandFile(paths.manifest),
);
const receipt = normalizeLocalReconciliationCaptureReceipt(
readPrivateLocalCommandFile(paths.receipt),
);
if (
receipt.captureId !== manifest.captureId ||
receipt.profile !== manifest.profile ||
receipt.preparationDigest !== manifest.preparationDigest ||
receipt.manifestDigest !== manifest.manifestDigest ||
receipt.assetCount !== manifest.assets.length ||
receipt.totalBytes !== manifest.totalBytes
) {
configurationError('capture terminal receipt is detached from manifest');
}
for (const asset of manifest.assets) {
verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid, [
0o400n,
]);
}
validateTerminalCatalog(paths, true);
return Object.freeze({ manifest, receipt });
}
export function readLocalReconciliationCaptureTerminal(
captureRoot: string,
captureId: string,
uid: number,
): Readonly<{
manifest: Readonly<LocalReconciliationCaptureManifest>;
receipt: Readonly<LocalReconciliationCaptureReceipt>;
}> {
return readTerminal(capturePaths(captureRoot, captureId), uid);
}
function readPublishedManifest(
paths: ReturnType<typeof capturePaths>,
uid: number,
): Readonly<LocalReconciliationCaptureManifest> {
validatePrivateDirectory(paths.root, uid, 'captureDirectory');
validatePrivateDirectory(paths.staging, uid, 'captureStagingDirectory');
validatePrivateDirectory(paths.assets, uid, 'captureAssetsDirectory');
const manifest = normalizeLocalReconciliationCaptureManifest(
readPrivateLocalCommandFile(paths.manifest),
);
for (const asset of manifest.assets) {
verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid);
}
validateTerminalCatalog(paths, false);
return manifest;
}
function terminalResult(
operation: LocalReconciliationCaptureTerminalResult['operation'],
status: LocalReconciliationCaptureTerminalResult['status'],
terminal: ReturnType<typeof readTerminal>,
head: Readonly<LocalCutoverInstanceHead>,
): Readonly<LocalReconciliationCaptureTerminalResult> {
return Object.freeze({
schemaVersion: 1 as const,
operation,
status,
state: 'reconciliation_captured' as const,
captureId: terminal.receipt.captureId,
bundleDigest: terminal.receipt.bundleDigest,
profile: terminal.receipt.profile,
assetCount: terminal.receipt.assetCount,
totalBytes: terminal.receipt.totalBytes,
instanceHeadDigest: head.headDigest,
});
}
function advanceCapturedHead(
intent: Readonly<LocalReconciliationCaptureIntent>,
uid: number,
committedAtMs: number,
bundleDigest: string,
): Readonly<LocalCutoverInstanceHead> {
return advanceLocalCutoverInstanceHead(
{
options: {
deploymentRoot: intent.command.options.deploymentRoot,
},
request: {
cutoverId: intent.command.request.cutoverId,
profile: intent.command.request.profile,
instanceId: intent.command.request.instanceId,
expectedActivationDigest:
intent.command.request.expectedActivationDigest,
requestedAtMs: committedAtMs,
},
},
uid,
'reconciliation_captured',
intent.command.request.generation,
bundleDigest,
);
}
export function commitLocalReconciliationCapture(
input: unknown,
dependencies: LocalReconciliationCaptureDependencies = {},
): Readonly<LocalReconciliationCaptureTerminalResult> {
const command = normalizeLocalReconciliationCaptureCommitCommand(input);
const identity = currentIdentity();
validatePrivateDirectory(
command.options.deploymentRoot,
identity.uid,
'deploymentRoot',
);
validatePrivateDirectory(
command.options.captureRoot,
identity.uid,
'captureRoot',
);
const intent = readLocalReconciliationCaptureIntent(
command.options.captureRoot,
command.request.captureId,
);
validateIntentBinding(intent, command);
const paths = capturePaths(
command.options.captureRoot,
command.request.captureId,
);
validatePrivateDirectory(paths.root, identity.uid, 'captureDirectory');
validatePrivateDirectory(
paths.staging,
identity.uid,
'captureStagingDirectory',
);
const head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
intent.command.request.instanceId,
identity.uid,
);
validateHeadIdentity(head, intent);
if (fs.existsSync(paths.receipt)) {
const manifest = normalizeLocalReconciliationCaptureManifest(
readPrivateLocalCommandFile(paths.manifest),
);
sealTerminalAssets(
paths,
manifest,
identity.uid,
dependencies.afterAssetSealed,
);
dependencies.afterAssetsSealed?.();
const terminal = readTerminal(paths, identity.uid);
if (
terminal.receipt.preparationDigest !== intent.preparationDigest ||
terminal.receipt.committedAtMs !== command.request.committedAtMs ||
(head.state !== 'reconciliation_capture_prepared' &&
head.state !== 'reconciliation_captured') ||
(head.state === 'reconciliation_capture_prepared' &&
head.sourceRecordDigest !== intent.preparationDigest) ||
(head.state === 'reconciliation_captured' &&
head.sourceRecordDigest !== terminal.receipt.bundleDigest)
) {
configurationError('terminal capture lost its instance head binding');
}
const terminalHead =
head.state === 'reconciliation_captured'
? head
: advanceCapturedHead(
intent,
identity.uid,
terminal.receipt.committedAtMs,
terminal.receipt.bundleDigest,
);
return terminalResult(
command.operation,
head.state === 'reconciliation_captured' ? 'existing' : 'prepared',
terminal,
terminalHead,
);
}
if (
head.state !== 'reconciliation_capture_prepared' ||
head.sourceRecordDigest !== intent.preparationDigest
) {
configurationError('capture commit lost the prepared instance head fence');
}
if (fs.existsSync(paths.manifest)) {
const manifest = readPublishedManifest(paths, identity.uid);
if (
manifest.captureId !== command.request.captureId ||
manifest.profile !== intent.command.request.profile ||
manifest.preparationDigest !== intent.preparationDigest ||
manifest.stoppedRecordDigest !==
intent.command.request.expectedStoppedRecordDigest ||
manifest.stoppedProofDigest !== intent.stoppedProofDigest ||
manifest.reconciliationEvidenceDigest !==
intent.reconciliationEvidenceDigest ||
manifest.lineageProjectionDigest !== intent.lineage.projectionDigest ||
manifest.legacyBaselineSha256 !== intent.lineage.sourceSha256 ||
manifest.targetBaselineSha256 !== intent.lineage.targetSha256 ||
manifest.preparedHeadDigest !== head.headDigest ||
manifest.committedAtMs !== command.request.committedAtMs
) {
configurationError('published capture manifest lost its preparation');
}
const receipt = captureReceipt(manifest);
publishExactFile(
paths.receipt,
receiptContents(receipt),
0o600,
identity.uid,
'reconciliation capture receipt',
);
dependencies.afterReceiptPublished?.();
sealTerminalAssets(
paths,
manifest,
identity.uid,
dependencies.afterAssetSealed,
);
dependencies.afterAssetsSealed?.();
const terminal = readTerminal(paths, identity.uid);
const terminalHead = advanceCapturedHead(
intent,
identity.uid,
manifest.committedAtMs,
receipt.bundleDigest,
);
return terminalResult(
command.operation,
'prepared',
terminal,
terminalHead,
);
}
const stoppedBefore = proveLocalReconciliationStoppedState(
intent.command,
identity.uid,
);
const lineageBefore = proveLocalReconciliationLineage(
intent.command,
identity.uid,
);
if (
stoppedBefore.proofDigest !== intent.stoppedProofDigest ||
stoppedBefore.reconciliationEvidenceDigest !==
intent.reconciliationEvidenceDigest ||
lineageBefore.projectionDigest !== intent.lineage.projectionDigest
) {
configurationError('capture source lineage drifted before copy');
}
ensurePrivateDirectory(paths.assets, identity.uid, 'captureAssetsDirectory');
validateTerminalCatalog(paths, false);
const plan = localReconciliationCaptureAssetPlan(intent);
const copied = plan.assets.map((asset) => {
const result = copyLocalReconciliationAsset(
asset,
paths.assets,
identity.uid,
dependencies.stableCopy,
);
dependencies.afterAssetPublished?.(asset.logicalName);
return result;
});
for (const result of copied) {
verifyLocalReconciliationSourceSnapshot(result.sourceSnapshot);
}
verifyLocalReconciliationSidecarPlan(
intent,
plan.targetSidecars,
plan.legacySidecars,
);
const recovery = copied.find(
(result) => result.manifest.logicalName === 'recovery-main',
);
if (
recovery === undefined ||
recovery.manifest.sha256 !== intent.lineage.recoverySha256
) {
configurationError('captured recovery database drifted from activation');
}
const stoppedAfter = proveLocalReconciliationStoppedState(
intent.command,
identity.uid,
);
const lineageAfter = proveLocalReconciliationLineage(
intent.command,
identity.uid,
);
if (
stoppedAfter.proofDigest !== stoppedBefore.proofDigest ||
lineageAfter.projectionDigest !== lineageBefore.projectionDigest
) {
configurationError('capture source lineage changed during copy');
}
const assets = Object.freeze(copied.map((result) => result.manifest));
const totalBytes = assets.reduce((total, asset) => total + asset.bytes, 0);
const manifestPayload = Object.freeze({
schema: MANIFEST_SCHEMA,
schemaVersion: 2 as const,
state: 'reconciliation_captured' as const,
captureId: command.request.captureId,
profile: intent.command.request.profile,
preparationDigest: intent.preparationDigest,
stoppedRecordDigest: intent.command.request.expectedStoppedRecordDigest,
stoppedProofDigest: intent.stoppedProofDigest,
reconciliationEvidenceDigest: intent.reconciliationEvidenceDigest,
lineageProjectionDigest: intent.lineage.projectionDigest,
legacyBaselineSha256: intent.lineage.sourceSha256,
targetBaselineSha256: intent.lineage.targetSha256,
preparedHeadDigest: head.headDigest,
committedAtMs: command.request.committedAtMs,
assets,
totalBytes,
});
const manifest: Readonly<LocalReconciliationCaptureManifest> = Object.freeze({
...manifestPayload,
manifestDigest: cutoverDigest(manifestPayload),
});
publishExactFile(
paths.manifest,
manifestContents(manifest),
0o600,
identity.uid,
'reconciliation capture manifest',
);
dependencies.afterManifestPublished?.();
const receipt = captureReceipt(manifest);
publishExactFile(
paths.receipt,
receiptContents(receipt),
0o600,
identity.uid,
'reconciliation capture receipt',
);
dependencies.afterReceiptPublished?.();
sealTerminalAssets(
paths,
manifest,
identity.uid,
dependencies.afterAssetSealed,
);
dependencies.afterAssetsSealed?.();
const terminal = readTerminal(paths, identity.uid);
const terminalHead = advanceCapturedHead(
intent,
identity.uid,
command.request.committedAtMs,
receipt.bundleDigest,
);
return terminalResult(command.operation, 'prepared', terminal, terminalHead);
}
export function verifyLocalReconciliationCapture(
input: unknown,
): Readonly<LocalReconciliationCaptureTerminalResult> {
const command = normalizeLocalReconciliationCaptureVerifyCommand(input);
const identity = currentIdentity();
validatePrivateDirectory(
command.options.deploymentRoot,
identity.uid,
'deploymentRoot',
);
validatePrivateDirectory(
command.options.captureRoot,
identity.uid,
'captureRoot',
);
const intent = readLocalReconciliationCaptureIntent(
command.options.captureRoot,
command.request.captureId,
);
if (
intent.command.options.deploymentRoot !== command.options.deploymentRoot ||
intent.command.options.captureRoot !== command.options.captureRoot ||
intent.command.options.allowRootService !==
command.options.allowRootService ||
intent.command.request.captureId !== command.request.captureId
) {
configurationError('capture verify is detached from preparation');
}
const terminal = readTerminal(
capturePaths(command.options.captureRoot, command.request.captureId),
identity.uid,
);
if (
terminal.receipt.bundleDigest !== command.request.expectedBundleDigest ||
terminal.receipt.preparationDigest !== intent.preparationDigest
) {
configurationError('capture verify expected bundle drifted');
}
const head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
intent.command.request.instanceId,
identity.uid,
);
validateHeadIdentity(head, intent);
if (
head.state !== 'reconciliation_captured' ||
head.sourceRecordDigest !== terminal.receipt.bundleDigest
) {
configurationError('capture verify lost the terminal instance head');
}
return terminalResult(command.operation, 'verified', terminal, head);
}
export function commitLocalReconciliationCaptureCommandFile(
filePath: string,
): Readonly<LocalReconciliationCaptureTerminalResult> {
return commitLocalReconciliationCapture(
readPrivateLocalCommandFile(filePath),
);
}
export function verifyLocalReconciliationCaptureCommandFile(
filePath: string,
): Readonly<LocalReconciliationCaptureTerminalResult> {
return verifyLocalReconciliationCapture(
readPrivateLocalCommandFile(filePath),
);
}