mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): fence reconciliation capture preparation
This commit is contained in:
@@ -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, '');
|
||||
});
|
||||
Reference in New Issue
Block a user