mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): seal reconciliation capture bundles
This commit is contained in:
@@ -8,6 +8,7 @@ import { LocalDeploymentConfigurationError } from '../foundation/error';
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
publishExactFile,
|
||||
syncPublishedDirectory,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import { proveLocalReconciliationLineage } from './lineageProof';
|
||||
import { proveLocalReconciliationStoppedState } from './stoppedProof';
|
||||
import {
|
||||
copyLocalReconciliationAsset,
|
||||
localReconciliationCaptureAssetFileName,
|
||||
localReconciliationCaptureAssetPlan,
|
||||
verifyLocalReconciliationPublishedAsset,
|
||||
verifyLocalReconciliationSidecarPlan,
|
||||
@@ -56,7 +58,7 @@ const LOGICAL_NAMES = [
|
||||
|
||||
export interface LocalReconciliationCaptureManifest {
|
||||
readonly schema: typeof MANIFEST_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly schemaVersion: 2;
|
||||
readonly state: 'reconciliation_captured';
|
||||
readonly captureId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
@@ -65,6 +67,8 @@ export interface LocalReconciliationCaptureManifest {
|
||||
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>[];
|
||||
@@ -74,7 +78,7 @@ export interface LocalReconciliationCaptureManifest {
|
||||
|
||||
export interface LocalReconciliationCaptureReceipt {
|
||||
readonly schema: typeof RECEIPT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly schemaVersion: 2;
|
||||
readonly state: 'reconciliation_captured';
|
||||
readonly captureId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
@@ -93,6 +97,10 @@ export interface LocalReconciliationCaptureDependencies {
|
||||
) => void;
|
||||
readonly afterManifestPublished?: () => void;
|
||||
readonly afterReceiptPublished?: () => void;
|
||||
readonly afterAssetSealed?: (
|
||||
logicalName: LocalReconciliationCapturedAsset['logicalName'],
|
||||
) => void;
|
||||
readonly afterAssetsSealed?: () => void;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
@@ -220,6 +228,7 @@ export function normalizeLocalReconciliationCaptureManifest(
|
||||
'assets',
|
||||
'captureId',
|
||||
'committedAtMs',
|
||||
'legacyBaselineSha256',
|
||||
'lineageProjectionDigest',
|
||||
'manifestDigest',
|
||||
'preparationDigest',
|
||||
@@ -231,6 +240,7 @@ export function normalizeLocalReconciliationCaptureManifest(
|
||||
'state',
|
||||
'stoppedProofDigest',
|
||||
'stoppedRecordDigest',
|
||||
'targetBaselineSha256',
|
||||
'totalBytes',
|
||||
],
|
||||
'reconciliation capture manifest',
|
||||
@@ -244,7 +254,7 @@ export function normalizeLocalReconciliationCaptureManifest(
|
||||
const { manifestDigest, ...payload } = manifest;
|
||||
if (
|
||||
manifest.schema !== MANIFEST_SCHEMA ||
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.schemaVersion !== 2 ||
|
||||
manifest.state !== 'reconciliation_captured' ||
|
||||
typeof manifest.captureId !== 'string' ||
|
||||
(manifest.profile !== 'edge' && manifest.profile !== 'standalone') ||
|
||||
@@ -269,6 +279,8 @@ export function normalizeLocalReconciliationCaptureManifest(
|
||||
manifest.stoppedProofDigest,
|
||||
manifest.reconciliationEvidenceDigest,
|
||||
manifest.lineageProjectionDigest,
|
||||
manifest.legacyBaselineSha256,
|
||||
manifest.targetBaselineSha256,
|
||||
manifest.preparedHeadDigest,
|
||||
manifestDigest,
|
||||
].some(
|
||||
@@ -309,7 +321,7 @@ export function normalizeLocalReconciliationCaptureReceipt(
|
||||
const { bundleDigest, ...payload } = receipt;
|
||||
if (
|
||||
receipt.schema !== RECEIPT_SCHEMA ||
|
||||
receipt.schemaVersion !== 1 ||
|
||||
receipt.schemaVersion !== 2 ||
|
||||
receipt.state !== 'reconciliation_captured' ||
|
||||
typeof receipt.captureId !== 'string' ||
|
||||
(receipt.profile !== 'edge' && receipt.profile !== 'standalone') ||
|
||||
@@ -348,7 +360,7 @@ function captureReceipt(
|
||||
): Readonly<LocalReconciliationCaptureReceipt> {
|
||||
const payload = Object.freeze({
|
||||
schema: RECEIPT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
schemaVersion: 2 as const,
|
||||
state: 'reconciliation_captured' as const,
|
||||
captureId: manifest.captureId,
|
||||
profile: manifest.profile,
|
||||
@@ -386,10 +398,16 @@ function validateTerminalCatalog(
|
||||
if (fs.readdirSync(paths.staging).length !== 0) {
|
||||
configurationError('capture staging root contains unknown material');
|
||||
}
|
||||
const allowedAssets = new Set<string>(LOGICAL_NAMES);
|
||||
const allowedAssets = new Set<string>(
|
||||
LOGICAL_NAMES.map(localReconciliationCaptureAssetFileName),
|
||||
);
|
||||
if (!terminal) {
|
||||
for (const name of LOGICAL_NAMES) {
|
||||
allowedAssets.add(`.${name}.ql3-capture-stage`);
|
||||
allowedAssets.add(
|
||||
`.${localReconciliationCaptureAssetFileName(
|
||||
name,
|
||||
)}.ql3-capture-stage`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const entry of fs.readdirSync(paths.assets, { withFileTypes: true })) {
|
||||
@@ -403,6 +421,105 @@ function validateTerminalCatalog(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -412,7 +529,7 @@ function readTerminal(
|
||||
}> {
|
||||
validatePrivateDirectory(paths.root, uid, 'captureDirectory');
|
||||
validatePrivateDirectory(paths.staging, uid, 'captureStagingDirectory');
|
||||
validatePrivateDirectory(paths.assets, uid, 'captureAssetsDirectory');
|
||||
validateSealedAssetsDirectory(paths.assets, uid);
|
||||
const manifest = normalizeLocalReconciliationCaptureManifest(
|
||||
readPrivateLocalCommandFile(paths.manifest),
|
||||
);
|
||||
@@ -430,7 +547,9 @@ function readTerminal(
|
||||
configurationError('capture terminal receipt is detached from manifest');
|
||||
}
|
||||
for (const asset of manifest.assets) {
|
||||
verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid);
|
||||
verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid, [
|
||||
0o400n,
|
||||
]);
|
||||
}
|
||||
validateTerminalCatalog(paths, true);
|
||||
return Object.freeze({ manifest, receipt });
|
||||
@@ -538,6 +657,16 @@ export function commitLocalReconciliationCapture(
|
||||
);
|
||||
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 ||
|
||||
@@ -585,6 +714,8 @@ export function commitLocalReconciliationCapture(
|
||||
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
|
||||
) {
|
||||
@@ -599,6 +730,13 @@ export function commitLocalReconciliationCapture(
|
||||
'reconciliation capture receipt',
|
||||
);
|
||||
dependencies.afterReceiptPublished?.();
|
||||
sealTerminalAssets(
|
||||
paths,
|
||||
manifest,
|
||||
identity.uid,
|
||||
dependencies.afterAssetSealed,
|
||||
);
|
||||
dependencies.afterAssetsSealed?.();
|
||||
const terminal = readTerminal(paths, identity.uid);
|
||||
const terminalHead = advanceCapturedHead(
|
||||
intent,
|
||||
@@ -677,7 +815,7 @@ export function commitLocalReconciliationCapture(
|
||||
const totalBytes = assets.reduce((total, asset) => total + asset.bytes, 0);
|
||||
const manifestPayload = Object.freeze({
|
||||
schema: MANIFEST_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
schemaVersion: 2 as const,
|
||||
state: 'reconciliation_captured' as const,
|
||||
captureId: command.request.captureId,
|
||||
profile: intent.command.request.profile,
|
||||
@@ -686,6 +824,8 @@ export function commitLocalReconciliationCapture(
|
||||
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,
|
||||
@@ -712,6 +852,13 @@ export function commitLocalReconciliationCapture(
|
||||
'reconciliation capture receipt',
|
||||
);
|
||||
dependencies.afterReceiptPublished?.();
|
||||
sealTerminalAssets(
|
||||
paths,
|
||||
manifest,
|
||||
identity.uid,
|
||||
dependencies.afterAssetSealed,
|
||||
);
|
||||
dependencies.afterAssetsSealed?.();
|
||||
const terminal = readTerminal(paths, identity.uid);
|
||||
const terminalHead = advanceCapturedHead(
|
||||
intent,
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface LocalReconciliationLineageProjection {
|
||||
readonly legacyDataApplicationCommitDigest: string;
|
||||
readonly legacyDataApplicationReceiptDigest: string;
|
||||
readonly adoptedBundleDigest: string;
|
||||
readonly sourceSha256: string;
|
||||
readonly targetSha256: string;
|
||||
readonly recoverySha256: string;
|
||||
readonly projectionDigest: string;
|
||||
}
|
||||
@@ -360,6 +362,10 @@ export function proveLocalReconciliationLineage(
|
||||
textDigest(command.request.targetDatabasePath) ||
|
||||
typeof activation.document.adoptionManifestDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activation.document.adoptionManifestDigest) ||
|
||||
typeof activation.document.sourceSha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activation.document.sourceSha256) ||
|
||||
typeof activation.document.targetSha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activation.document.targetSha256) ||
|
||||
typeof activation.document.recoverySha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activation.document.recoverySha256)
|
||||
) {
|
||||
@@ -444,6 +450,7 @@ export function proveLocalReconciliationLineage(
|
||||
adoptedBundle.document.manifestDigest !== manifest.digest ||
|
||||
adoptedBundle.document.sourcePathDigest !==
|
||||
textDigest(command.request.legacySourcePath) ||
|
||||
adoptedBundle.document.sourceSha256 !== activation.document.sourceSha256 ||
|
||||
adoptedBundle.document.recoverySha256 !== activation.document.recoverySha256
|
||||
) {
|
||||
configurationError('adopted bundle lineage drifted');
|
||||
@@ -457,6 +464,8 @@ export function proveLocalReconciliationLineage(
|
||||
legacyDataApplicationCommitDigest: dataCommit.commitDigest,
|
||||
legacyDataApplicationReceiptDigest: dataCommit.receiptDigest,
|
||||
adoptedBundleDigest: adoptedBundle.digest,
|
||||
sourceSha256: activation.document.sourceSha256 as string,
|
||||
targetSha256: activation.document.targetSha256 as string,
|
||||
recoverySha256: activation.document.recoverySha256 as string,
|
||||
});
|
||||
return Object.freeze({
|
||||
|
||||
@@ -123,6 +123,8 @@ export function normalizeLocalReconciliationCaptureIntent(
|
||||
'legacyDataApplicationReceiptDigest',
|
||||
'projectionDigest',
|
||||
'recoverySha256',
|
||||
'sourceSha256',
|
||||
'targetSha256',
|
||||
],
|
||||
'reconciliation lineage projection',
|
||||
);
|
||||
|
||||
@@ -59,6 +59,26 @@ export interface LocalReconciliationStableCopyDependencies {
|
||||
readonly unlink?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
const CAPTURE_ASSET_FILE_NAMES: Readonly<
|
||||
Record<LocalReconciliationCaptureSourceAsset['logicalName'], string>
|
||||
> = Object.freeze({
|
||||
'target-main': 'target.sqlite',
|
||||
'target-wal': 'target.sqlite-wal',
|
||||
'target-shm': 'target.sqlite-shm',
|
||||
'target-journal': 'target.sqlite-journal',
|
||||
'legacy-main': 'legacy.sqlite',
|
||||
'legacy-wal': 'legacy.sqlite-wal',
|
||||
'legacy-shm': 'legacy.sqlite-shm',
|
||||
'legacy-journal': 'legacy.sqlite-journal',
|
||||
'recovery-main': 'recovery.sqlite',
|
||||
});
|
||||
|
||||
export function localReconciliationCaptureAssetFileName(
|
||||
logicalName: LocalReconciliationCaptureSourceAsset['logicalName'],
|
||||
): string {
|
||||
return CAPTURE_ASSET_FILE_NAMES[logicalName];
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
@@ -226,12 +246,13 @@ function validateOutputDescriptor(
|
||||
descriptor: number,
|
||||
uid: number,
|
||||
allowedLinks: readonly bigint[],
|
||||
allowedModes: readonly bigint[] = [0o600n],
|
||||
): fs.BigIntStats {
|
||||
const stat = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.uid !== BigInt(uid) ||
|
||||
(stat.mode & 0o777n) !== 0o600n ||
|
||||
!allowedModes.includes(stat.mode & 0o777n) ||
|
||||
!allowedLinks.includes(stat.nlink) ||
|
||||
stat.size > BigInt(Number.MAX_SAFE_INTEGER)
|
||||
) {
|
||||
@@ -247,6 +268,7 @@ function verifyPublishedAsset(
|
||||
expectedSha256: string,
|
||||
buffer: Buffer,
|
||||
allowedLinks: readonly bigint[] = [1n],
|
||||
allowedModes: readonly bigint[] = [0o600n],
|
||||
): void {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
@@ -255,7 +277,12 @@ function verifyPublishedAsset(
|
||||
targetPath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = validateOutputDescriptor(descriptor, uid, allowedLinks);
|
||||
const opened = validateOutputDescriptor(
|
||||
descriptor,
|
||||
uid,
|
||||
allowedLinks,
|
||||
allowedModes,
|
||||
);
|
||||
if (
|
||||
pathStat.isSymbolicLink() ||
|
||||
!sameStat(pathStat, opened) ||
|
||||
@@ -314,10 +341,13 @@ export function copyLocalReconciliationAsset(
|
||||
uid: number,
|
||||
dependencies: LocalReconciliationStableCopyDependencies = {},
|
||||
): Readonly<LocalReconciliationStableCopyResult> {
|
||||
const targetPath = path.join(assetsDirectory, asset.logicalName);
|
||||
const publishedName = localReconciliationCaptureAssetFileName(
|
||||
asset.logicalName,
|
||||
);
|
||||
const targetPath = path.join(assetsDirectory, publishedName);
|
||||
const stagePath = path.join(
|
||||
assetsDirectory,
|
||||
`.${asset.logicalName}.ql3-capture-stage`,
|
||||
`.${publishedName}.ql3-capture-stage`,
|
||||
);
|
||||
const buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES);
|
||||
const unlink = dependencies.unlink ?? fs.unlinkSync;
|
||||
@@ -535,15 +565,21 @@ export function verifyLocalReconciliationPublishedAsset(
|
||||
asset: Readonly<LocalReconciliationCapturedAsset>,
|
||||
assetsDirectory: string,
|
||||
uid: number,
|
||||
allowedModes: readonly bigint[] = [0o600n],
|
||||
): void {
|
||||
const buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES);
|
||||
try {
|
||||
verifyPublishedAsset(
|
||||
path.join(assetsDirectory, asset.logicalName),
|
||||
path.join(
|
||||
assetsDirectory,
|
||||
localReconciliationCaptureAssetFileName(asset.logicalName),
|
||||
),
|
||||
uid,
|
||||
asset.bytes,
|
||||
asset.sha256,
|
||||
buffer,
|
||||
[1n],
|
||||
allowedModes,
|
||||
);
|
||||
} finally {
|
||||
buffer.fill(0);
|
||||
|
||||
@@ -11,6 +11,9 @@ const {
|
||||
prepareLocalReconciliationCapture,
|
||||
verifyLocalReconciliationCapture,
|
||||
} = require('../dist/deployment/localDeployment.js');
|
||||
const {
|
||||
normalizeLocalReconciliationCaptureManifest,
|
||||
} = require('../dist/deployment/reconciliation/bundle.js');
|
||||
const {
|
||||
createLocalDataDirectoryApplicationCommit,
|
||||
} = require('@qinglong/local-sqlite/data-directory-application-commit');
|
||||
@@ -42,6 +45,35 @@ function rootAcknowledgement() {
|
||||
return typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
}
|
||||
|
||||
const CAPTURE_ASSET_NAMES = Object.freeze({
|
||||
'target-main': 'target.sqlite',
|
||||
'target-wal': 'target.sqlite-wal',
|
||||
'target-shm': 'target.sqlite-shm',
|
||||
'target-journal': 'target.sqlite-journal',
|
||||
'legacy-main': 'legacy.sqlite',
|
||||
'legacy-wal': 'legacy.sqlite-wal',
|
||||
'legacy-shm': 'legacy.sqlite-shm',
|
||||
'legacy-journal': 'legacy.sqlite-journal',
|
||||
'recovery-main': 'recovery.sqlite',
|
||||
});
|
||||
|
||||
function removeFixtureRoot(root) {
|
||||
if (!fs.existsSync(root)) return;
|
||||
const unlock = (candidate) => {
|
||||
const stat = fs.lstatSync(candidate);
|
||||
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
||||
fs.chmodSync(candidate, 0o700);
|
||||
for (const name of fs.readdirSync(candidate)) {
|
||||
unlock(path.join(candidate, name));
|
||||
}
|
||||
} else if (!stat.isSymbolicLink()) {
|
||||
fs.chmodSync(candidate, 0o600);
|
||||
}
|
||||
};
|
||||
unlock(root);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function fixture(
|
||||
t,
|
||||
{
|
||||
@@ -54,7 +86,7 @@ function fixture(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-reconciliation-capture-')),
|
||||
);
|
||||
fs.chmodSync(root, 0o700);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
t.after(() => removeFixtureRoot(root));
|
||||
const deploymentRoot = path.join(root, 'runtime');
|
||||
const serviceRoot = path.join(deploymentRoot, 'service');
|
||||
const cutoverId = 'capture-cutover-1';
|
||||
@@ -416,6 +448,7 @@ function fixture(
|
||||
};
|
||||
return {
|
||||
command,
|
||||
activation: Object.freeze({ ...activationPayload, activationDigest }),
|
||||
deploymentRoot,
|
||||
captureRoot,
|
||||
identity,
|
||||
@@ -555,6 +588,17 @@ function capturePath(state, name) {
|
||||
return path.join(state.captureRoot, state.command.request.captureId, name);
|
||||
}
|
||||
|
||||
function captureAssetPath(state, logicalName) {
|
||||
return capturePath(state, `assets/${CAPTURE_ASSET_NAMES[logicalName]}`);
|
||||
}
|
||||
|
||||
function captureAssetStagePath(state, logicalName) {
|
||||
return capturePath(
|
||||
state,
|
||||
`assets/.${CAPTURE_ASSET_NAMES[logicalName]}.ql3-capture-stage`,
|
||||
);
|
||||
}
|
||||
|
||||
test('commit captures main, sidecars and recovery then verifies without sources', (t) => {
|
||||
const state = preparedCapture(t);
|
||||
fs.writeFileSync(`${state.targetDatabasePath}.unrelated`, 'ignored\n', {
|
||||
@@ -567,6 +611,9 @@ test('commit captures main, sidecars and recovery then verifies without sources'
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(capturePath(state, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(manifest.schemaVersion, 2);
|
||||
assert.equal(manifest.legacyBaselineSha256, state.activation.sourceSha256);
|
||||
assert.equal(manifest.targetBaselineSha256, state.activation.targetSha256);
|
||||
assert.deepEqual(
|
||||
manifest.assets.map((asset) => asset.logicalName),
|
||||
[
|
||||
@@ -585,13 +632,23 @@ test('commit captures main, sidecars and recovery then verifies without sources'
|
||||
assert.equal(manifestText.includes(state.targetDatabasePath), false);
|
||||
assert.equal(manifestText.includes(state.legacySourcePath), false);
|
||||
assert.equal(
|
||||
fs.readFileSync(capturePath(state, 'assets/target-main'), 'utf8'),
|
||||
fs.readFileSync(captureAssetPath(state, 'target-main'), 'utf8'),
|
||||
'target-mutated\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(capturePath(state, 'assets/target-wal'), 'utf8'),
|
||||
fs.readFileSync(captureAssetPath(state, 'target-wal'), 'utf8'),
|
||||
'target-wal-facts\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.statSync(capturePath(state, 'assets')).mode & 0o777,
|
||||
0o500,
|
||||
);
|
||||
for (const asset of manifest.assets) {
|
||||
assert.equal(
|
||||
fs.statSync(captureAssetPath(state, asset.logicalName)).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
}
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.deploymentRoot,
|
||||
state.command.request.instanceId,
|
||||
@@ -647,7 +704,7 @@ test('commit resumes after an asset publication crash without replacement', (t)
|
||||
}),
|
||||
/asset crash/,
|
||||
);
|
||||
const targetAsset = capturePath(state, 'assets/target-main');
|
||||
const targetAsset = captureAssetPath(state, 'target-main');
|
||||
const before = fs.statSync(targetAsset, { bigint: true });
|
||||
assert.equal(fs.existsSync(capturePath(state, 'manifest.json')), false);
|
||||
const committed = commitLocalReconciliationCapture(state.commitCommand);
|
||||
@@ -709,6 +766,54 @@ test('commit resumes after manifest and receipt crash windows', (t) => {
|
||||
assert.equal(resumed.state, 'reconciliation_captured');
|
||||
});
|
||||
|
||||
test('commit converges a partially sealed terminal bundle without sources', (t) => {
|
||||
const state = preparedCapture(t);
|
||||
let failed = false;
|
||||
assert.throws(
|
||||
() =>
|
||||
commitLocalReconciliationCapture(state.commitCommand, {
|
||||
afterAssetSealed(logicalName) {
|
||||
if (!failed && logicalName === 'target-main') {
|
||||
failed = true;
|
||||
throw new Error('seal crash');
|
||||
}
|
||||
},
|
||||
}),
|
||||
/seal crash/,
|
||||
);
|
||||
assert.equal(fs.existsSync(capturePath(state, 'receipt.json')), true);
|
||||
assert.equal(
|
||||
fs.statSync(captureAssetPath(state, 'target-main')).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
assert.equal(
|
||||
fs.statSync(captureAssetPath(state, 'target-wal')).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
assert.equal(
|
||||
fs.statSync(capturePath(state, 'assets')).mode & 0o777,
|
||||
0o700,
|
||||
);
|
||||
fs.unlinkSync(state.targetDatabasePath);
|
||||
fs.unlinkSync(state.legacySourcePath);
|
||||
fs.unlinkSync(state.recoveryPath);
|
||||
const resumed = commitLocalReconciliationCapture(state.commitCommand);
|
||||
assert.equal(resumed.state, 'reconciliation_captured');
|
||||
assert.equal(
|
||||
fs.statSync(capturePath(state, 'assets')).mode & 0o777,
|
||||
0o500,
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(capturePath(state, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
for (const asset of manifest.assets) {
|
||||
assert.equal(
|
||||
fs.statSync(captureAssetPath(state, asset.logicalName)).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('hard-link publication replay removes only the exact retained stage', (t) => {
|
||||
const state = preparedCapture(t);
|
||||
const cleanupError = Object.assign(new Error('stage cleanup unavailable'), {
|
||||
@@ -725,8 +830,8 @@ test('hard-link publication replay removes only the exact retained stage', (t) =
|
||||
}),
|
||||
/capture asset cannot be published/,
|
||||
);
|
||||
const target = capturePath(state, 'assets/target-main');
|
||||
const stage = capturePath(state, 'assets/.target-main.ql3-capture-stage');
|
||||
const target = captureAssetPath(state, 'target-main');
|
||||
const stage = captureAssetStagePath(state, 'target-main');
|
||||
const targetBefore = fs.statSync(target, { bigint: true });
|
||||
const stageBefore = fs.statSync(stage, { bigint: true });
|
||||
assert.equal(targetBefore.ino, stageBefore.ino);
|
||||
@@ -794,7 +899,7 @@ test('a cleanup-resistant partial stage resumes only from its exact prefix', (t)
|
||||
}),
|
||||
/capture asset cannot be published/,
|
||||
);
|
||||
const stage = capturePath(state, 'assets/.target-main.ql3-capture-stage');
|
||||
const stage = captureAssetStagePath(state, 'target-main');
|
||||
assert.equal(fs.statSync(stage).size, 4);
|
||||
assert.equal(
|
||||
fs
|
||||
@@ -864,13 +969,29 @@ test('terminal verify rejects asset drift and CLI output remains content-free',
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout.includes(state.captureRoot), false);
|
||||
assert.equal(result.stdout.includes(state.targetDatabasePath), false);
|
||||
fs.writeFileSync(capturePath(state, 'assets/target-main'), 'drift\n');
|
||||
const targetAsset = captureAssetPath(state, 'target-main');
|
||||
fs.chmodSync(targetAsset, 0o600);
|
||||
fs.writeFileSync(targetAsset, 'drift\n');
|
||||
fs.chmodSync(targetAsset, 0o400);
|
||||
assert.throws(
|
||||
() => verifyLocalReconciliationCapture(verifyCommand),
|
||||
/asset drifted/,
|
||||
);
|
||||
});
|
||||
|
||||
test('capture manifest schema v1 is rejected instead of silently upgraded', (t) => {
|
||||
const state = preparedCapture(t);
|
||||
commitLocalReconciliationCapture(state.commitCommand);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(capturePath(state, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
manifest.schemaVersion = 1;
|
||||
assert.throws(
|
||||
() => normalizeLocalReconciliationCaptureManifest(manifest),
|
||||
/reconciliation capture manifest (?:drifted|schemaVersion must be 2)/,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'real stopped Docker target produces an independently verified bundle',
|
||||
{ skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' },
|
||||
@@ -931,7 +1052,7 @@ test(
|
||||
});
|
||||
const committed = commitLocalReconciliationCapture(state.commitCommand);
|
||||
assert.equal(
|
||||
fs.readFileSync(capturePath(state, 'assets/target-main'), 'utf8'),
|
||||
fs.readFileSync(captureAssetPath(state, 'target-main'), 'utf8'),
|
||||
'target-docker-mutated\n',
|
||||
);
|
||||
const verified = verifyLocalReconciliationCapture({
|
||||
|
||||
Reference in New Issue
Block a user