mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
type LocalDeploymentProfile,
|
||||
} from '../foundation/contract';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface LocalDeploymentLegacyStopCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.cutover.legacy-stop';
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
dockerExecutable: string;
|
||||
dockerSocketPath: string;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
cutoverId: string;
|
||||
profile: LocalDeploymentProfile;
|
||||
instanceId: string;
|
||||
activationPath: string;
|
||||
legacySourcePath: string;
|
||||
expectedLegacyDatabasePath: string;
|
||||
expectedActivationDigest: string;
|
||||
expectedLegacyContainerId: string;
|
||||
requestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentLegacyStopResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.cutover.legacy-stop';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'legacy_stopped';
|
||||
readonly cutoverId: string;
|
||||
readonly commitmentDigest: string;
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${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])
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${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
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} must be a supervisor-safe normalized absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function trustedExecutable(value: unknown, uid: number): string {
|
||||
const filePath = safeAbsolutePath(value, 'dockerExecutable');
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable is unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
(stat.uid !== 0 && stat.uid !== uid) ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(stat.mode & 0o111) === 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable must be a canonical trusted executable',
|
||||
);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new LocalDeploymentConfigurationError('requestedAtMs is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function normalizeLocalDeploymentLegacyStopCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentLegacyStopCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !== 'local.deployment.cutover.legacy-stop'
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'schemaVersion or operation is invalid',
|
||||
);
|
||||
}
|
||||
const identity = currentIdentity();
|
||||
const options = object(command.options, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'deploymentRoot',
|
||||
'dockerExecutable',
|
||||
'dockerSocketPath',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'allowRootService does not match the current identity',
|
||||
);
|
||||
}
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'activationPath',
|
||||
'cutoverId',
|
||||
'expectedActivationDigest',
|
||||
'expectedLegacyDatabasePath',
|
||||
'expectedLegacyContainerId',
|
||||
'instanceId',
|
||||
'legacySourcePath',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
typeof request.cutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(request.cutoverId) ||
|
||||
(request.profile !== 'edge' && request.profile !== 'standalone') ||
|
||||
typeof request.instanceId !== 'string' ||
|
||||
!INSTANCE_ID_PATTERN.test(request.instanceId) ||
|
||||
typeof request.expectedActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedActivationDigest) ||
|
||||
typeof request.expectedLegacyContainerId !== 'string' ||
|
||||
!CONTAINER_ID_PATTERN.test(request.expectedLegacyContainerId)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'cutover request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.cutover.legacy-stop' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
dockerExecutable: trustedExecutable(
|
||||
options.dockerExecutable,
|
||||
identity.uid,
|
||||
),
|
||||
dockerSocketPath: safeAbsolutePath(
|
||||
options.dockerSocketPath,
|
||||
'dockerSocketPath',
|
||||
),
|
||||
allowRootService: options.allowRootService,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
cutoverId: request.cutoverId,
|
||||
profile: request.profile,
|
||||
instanceId: request.instanceId,
|
||||
activationPath: safeAbsolutePath(
|
||||
request.activationPath,
|
||||
'activationPath',
|
||||
),
|
||||
legacySourcePath: safeAbsolutePath(
|
||||
request.legacySourcePath,
|
||||
'legacySourcePath',
|
||||
),
|
||||
expectedLegacyDatabasePath: safeAbsolutePath(
|
||||
request.expectedLegacyDatabasePath,
|
||||
'expectedLegacyDatabasePath',
|
||||
),
|
||||
expectedActivationDigest: request.expectedActivationDigest,
|
||||
expectedLegacyContainerId: request.expectedLegacyContainerId,
|
||||
requestedAtMs: timestamp(request.requestedAtMs),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
replaceExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import { cutoverDigest } from './targetEvidence';
|
||||
|
||||
const HEAD_SCHEMA = 'qinglong3-local-cutover-instance-head';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const ZERO_DIGEST = '0'.repeat(64);
|
||||
const MAX_INSTANCES = 64;
|
||||
|
||||
export type LocalCutoverInstanceHeadState =
|
||||
| 'legacy_stop_requested'
|
||||
| 'legacy_stopped'
|
||||
| 'target_active'
|
||||
| 'target_stopped'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
| 'manual_required'
|
||||
| 'resolution_authorized';
|
||||
|
||||
export interface LocalCutoverIdentity {
|
||||
readonly options: Readonly<{ deploymentRoot: string }>;
|
||||
readonly request: Readonly<{
|
||||
cutoverId: string;
|
||||
profile: 'edge' | 'standalone';
|
||||
instanceId: string;
|
||||
expectedActivationDigest: string;
|
||||
requestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalCutoverInstanceHead {
|
||||
readonly schema: typeof HEAD_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly revision: number;
|
||||
readonly instanceId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly cutoverId: string;
|
||||
readonly activationDigest: string;
|
||||
readonly state: LocalCutoverInstanceHeadState;
|
||||
readonly generation: number;
|
||||
readonly previousHeadDigest: string;
|
||||
readonly sourceRecordDigest: string;
|
||||
readonly updatedAtMs: number;
|
||||
readonly headDigest: 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 contents(head: Readonly<LocalCutoverInstanceHead>): string {
|
||||
return `${JSON.stringify(head, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function record(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
revision: number,
|
||||
state: LocalCutoverInstanceHeadState,
|
||||
generation: number,
|
||||
previousHeadDigest: string,
|
||||
sourceRecordDigest: string,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const payload = Object.freeze({
|
||||
schema: HEAD_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
revision,
|
||||
instanceId: identity.request.instanceId,
|
||||
profile: identity.request.profile,
|
||||
cutoverId: identity.request.cutoverId,
|
||||
activationDigest: identity.request.expectedActivationDigest,
|
||||
state,
|
||||
generation,
|
||||
previousHeadDigest,
|
||||
sourceRecordDigest,
|
||||
updatedAtMs: identity.request.requestedAtMs,
|
||||
});
|
||||
return Object.freeze({ ...payload, headDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
||||
const head = object(value, 'cutover instance head');
|
||||
exact(
|
||||
head,
|
||||
[
|
||||
'activationDigest',
|
||||
'cutoverId',
|
||||
'generation',
|
||||
'headDigest',
|
||||
'instanceId',
|
||||
'previousHeadDigest',
|
||||
'profile',
|
||||
'revision',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'sourceRecordDigest',
|
||||
'state',
|
||||
'updatedAtMs',
|
||||
],
|
||||
'cutover instance head',
|
||||
);
|
||||
const { headDigest, ...payload } = head;
|
||||
if (
|
||||
head.schema !== HEAD_SCHEMA ||
|
||||
head.schemaVersion !== 1 ||
|
||||
!Number.isSafeInteger(head.revision) ||
|
||||
(head.revision as number) < 1 ||
|
||||
typeof head.instanceId !== 'string' ||
|
||||
(head.profile !== 'edge' && head.profile !== 'standalone') ||
|
||||
typeof head.cutoverId !== 'string' ||
|
||||
typeof head.activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(head.activationDigest) ||
|
||||
(head.state !== 'legacy_stop_requested' &&
|
||||
head.state !== 'legacy_stopped' &&
|
||||
head.state !== 'target_active' &&
|
||||
head.state !== 'target_stopped' &&
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested' &&
|
||||
head.state !== 'legacy_running' &&
|
||||
head.state !== 'manual_required' &&
|
||||
head.state !== 'resolution_authorized') ||
|
||||
!Number.isSafeInteger(head.generation) ||
|
||||
(head.generation as number) < 0 ||
|
||||
typeof head.previousHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(head.previousHeadDigest) ||
|
||||
typeof head.sourceRecordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(head.sourceRecordDigest) ||
|
||||
!Number.isSafeInteger(head.updatedAtMs) ||
|
||||
(head.updatedAtMs as number) < 0 ||
|
||||
typeof headDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(headDigest) ||
|
||||
cutoverDigest(payload) !== headDigest
|
||||
) {
|
||||
configurationError('cutover instance head drifted');
|
||||
}
|
||||
return head as unknown as Readonly<LocalCutoverInstanceHead>;
|
||||
}
|
||||
|
||||
export function localCutoverInstanceDirectory(
|
||||
deploymentRoot: string,
|
||||
instanceId: string,
|
||||
): string {
|
||||
return path.join(deploymentRoot, 'service', 'cutover-instances', instanceId);
|
||||
}
|
||||
|
||||
export function localCutoverInstanceHeadPath(
|
||||
deploymentRoot: string,
|
||||
instanceId: string,
|
||||
): string {
|
||||
return path.join(
|
||||
localCutoverInstanceDirectory(deploymentRoot, instanceId),
|
||||
'head.json',
|
||||
);
|
||||
}
|
||||
|
||||
function ensureInstanceDirectory(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
): string {
|
||||
const serviceRoot = path.join(identity.options.deploymentRoot, 'service');
|
||||
validatePrivateDirectory(
|
||||
identity.options.deploymentRoot,
|
||||
uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(serviceRoot, uid, 'serviceDescriptorRoot');
|
||||
const root = path.join(serviceRoot, 'cutover-instances');
|
||||
ensurePrivateDirectory(root, uid, 'cutoverInstanceRoot');
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
configurationError('cutover instance catalog contains drift');
|
||||
}
|
||||
}
|
||||
const directory = localCutoverInstanceDirectory(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
);
|
||||
if (entries.length >= MAX_INSTANCES && !fs.existsSync(directory)) {
|
||||
configurationError('cutover instance retention limit is reached');
|
||||
}
|
||||
ensurePrivateDirectory(directory, uid, 'cutoverInstanceDirectory');
|
||||
return directory;
|
||||
}
|
||||
|
||||
export function readLocalCutoverInstanceHead(
|
||||
deploymentRoot: string,
|
||||
instanceId: string,
|
||||
uid: number,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const directory = localCutoverInstanceDirectory(deploymentRoot, instanceId);
|
||||
validatePrivateDirectory(directory, uid, 'cutoverInstanceDirectory');
|
||||
return parseHead(
|
||||
readPrivateLocalCommandFile(
|
||||
localCutoverInstanceHeadPath(deploymentRoot, instanceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function replaceHead(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
current: Readonly<LocalCutoverInstanceHead>,
|
||||
next: Readonly<LocalCutoverInstanceHead>,
|
||||
): 'prepared' | 'existing' {
|
||||
return replaceExactFile(
|
||||
localCutoverInstanceHeadPath(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
),
|
||||
contents(current),
|
||||
contents(next),
|
||||
0o600,
|
||||
uid,
|
||||
'cutover instance head',
|
||||
);
|
||||
}
|
||||
|
||||
export function claimLocalCutoverInstance(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
intentDigest: string,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
ensureInstanceDirectory(identity, uid);
|
||||
const headPath = localCutoverInstanceHeadPath(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
);
|
||||
if (!fs.existsSync(headPath)) {
|
||||
const initial = record(
|
||||
identity,
|
||||
1,
|
||||
'legacy_stop_requested',
|
||||
0,
|
||||
ZERO_DIGEST,
|
||||
intentDigest,
|
||||
);
|
||||
const serialized = contents(initial);
|
||||
preflightPublishedFile(
|
||||
headPath,
|
||||
serialized,
|
||||
0o600,
|
||||
uid,
|
||||
'cutover instance head',
|
||||
);
|
||||
publishExactFile(headPath, serialized, 0o600, uid, 'cutover instance head');
|
||||
}
|
||||
const current = readLocalCutoverInstanceHead(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
current.profile !== identity.request.profile ||
|
||||
current.cutoverId !== identity.request.cutoverId ||
|
||||
current.activationDigest !== identity.request.expectedActivationDigest
|
||||
) {
|
||||
configurationError(
|
||||
'another cutover owns the instance; an explicit manual resolution is required',
|
||||
);
|
||||
}
|
||||
if (current.state !== 'resolution_authorized') return current;
|
||||
const next = record(
|
||||
identity,
|
||||
current.revision + 1,
|
||||
'legacy_stop_requested',
|
||||
0,
|
||||
current.headDigest,
|
||||
intentDigest,
|
||||
);
|
||||
replaceHead(identity, uid, current, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function advanceLocalCutoverInstanceHead(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
state:
|
||||
| 'legacy_stopped'
|
||||
| 'target_active'
|
||||
| 'target_stopped'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
| 'manual_required',
|
||||
generation: number,
|
||||
sourceRecordDigest: string,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const current = readLocalCutoverInstanceHead(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
current.profile !== identity.request.profile ||
|
||||
current.cutoverId !== identity.request.cutoverId ||
|
||||
current.activationDigest !== identity.request.expectedActivationDigest
|
||||
) {
|
||||
configurationError('cutover instance head does not match the command');
|
||||
}
|
||||
if (
|
||||
current.state === state &&
|
||||
current.generation === generation &&
|
||||
current.sourceRecordDigest === sourceRecordDigest
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
current.state === 'target_active' &&
|
||||
state === 'target_active' &&
|
||||
current.generation > generation
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
state === 'target_stopped' &&
|
||||
current.generation === generation &&
|
||||
(current.state === 'rollback_prepared' ||
|
||||
current.state === 'legacy_restart_requested' ||
|
||||
current.state === 'legacy_running')
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
if (current.state === 'manual_required') {
|
||||
configurationError('manual-required cutover instance head is terminal');
|
||||
}
|
||||
const allowed =
|
||||
(state === 'legacy_stopped' && current.state === 'legacy_stop_requested') ||
|
||||
(state === 'target_active' &&
|
||||
(current.state === 'legacy_stopped' ||
|
||||
current.state === 'target_active')) ||
|
||||
(state === 'target_stopped' && current.state === 'target_active') ||
|
||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||
(state === 'legacy_restart_requested' &&
|
||||
current.state === 'rollback_prepared') ||
|
||||
(state === 'legacy_running' &&
|
||||
current.state === 'legacy_restart_requested') ||
|
||||
(state === 'manual_required' &&
|
||||
(current.state === 'legacy_stopped' ||
|
||||
current.state === 'target_active' ||
|
||||
current.state === 'rollback_prepared' ||
|
||||
current.state === 'legacy_restart_requested'));
|
||||
if (!allowed)
|
||||
configurationError('cutover instance head transition is invalid');
|
||||
const next = record(
|
||||
identity,
|
||||
current.revision + 1,
|
||||
state,
|
||||
generation,
|
||||
current.headDigest,
|
||||
sourceRecordDigest,
|
||||
);
|
||||
replaceHead(identity, uid, current, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function assertLocalCutoverTargetHead(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
head.profile !== identity.request.profile ||
|
||||
head.cutoverId !== identity.request.cutoverId ||
|
||||
head.activationDigest !== identity.request.expectedActivationDigest ||
|
||||
(head.state !== 'legacy_stopped' &&
|
||||
head.state !== 'target_active' &&
|
||||
head.state !== 'manual_required')
|
||||
) {
|
||||
configurationError(
|
||||
'target command is not bound to the instance lineage head',
|
||||
);
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
export function authorizeResolvedLocalCutoverInstance(
|
||||
currentIdentity: Readonly<LocalCutoverIdentity>,
|
||||
nextIdentity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
expectedHeadDigest: string,
|
||||
resolutionDigest: string,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const current = readLocalCutoverInstanceHead(
|
||||
currentIdentity.options.deploymentRoot,
|
||||
currentIdentity.request.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
current.headDigest !== expectedHeadDigest ||
|
||||
current.state !== 'manual_required' ||
|
||||
current.profile !== currentIdentity.request.profile ||
|
||||
current.cutoverId !== currentIdentity.request.cutoverId ||
|
||||
current.activationDigest !==
|
||||
currentIdentity.request.expectedActivationDigest
|
||||
) {
|
||||
configurationError(
|
||||
'manual resolution lost the instance head compare-and-swap',
|
||||
);
|
||||
}
|
||||
const next = record(
|
||||
nextIdentity,
|
||||
current.revision + 1,
|
||||
'resolution_authorized',
|
||||
0,
|
||||
current.headDigest,
|
||||
resolutionDigest,
|
||||
);
|
||||
replaceHead(nextIdentity, uid, current, next);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../foundation/docker';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
localCutoverInstanceDirectory,
|
||||
readLocalCutoverInstanceHead,
|
||||
type LocalCutoverInstanceHead,
|
||||
} from './instanceLineage';
|
||||
import {
|
||||
EMPTY_ROLLBACK_PREPARATION_DIGEST,
|
||||
legacyRollbackTargetRunCommand,
|
||||
normalizeLocalDeploymentLegacyRollbackCommand,
|
||||
type LocalDeploymentLegacyRollbackCommand,
|
||||
type LocalDeploymentLegacyRollbackResult,
|
||||
} from './legacyRollbackContract';
|
||||
import {
|
||||
readTargetDataReconciliationEvidence,
|
||||
type TargetDataReconciliationEvidence,
|
||||
} from './targetDataEvidence';
|
||||
import {
|
||||
cutoverDigest,
|
||||
legacyCommitmentPath,
|
||||
parseActiveLegacyEvidence,
|
||||
parseStoppedLegacyEvidence,
|
||||
parseTargetContainerEvidence,
|
||||
readLegacySilenceEvidence,
|
||||
readTargetApplicationBinding,
|
||||
type LegacySilenceEvidence,
|
||||
type TargetApplicationBinding,
|
||||
} from './targetEvidence';
|
||||
import {
|
||||
legacyRollbackPhasePath,
|
||||
legacyRollbackSequence,
|
||||
publishTargetRunJournalRecord,
|
||||
readTargetRunJournalRecord,
|
||||
targetRunJournalRecord,
|
||||
targetRunManualEvidence,
|
||||
targetRunPhasePath,
|
||||
targetRunSequence,
|
||||
targetStopPhasePath,
|
||||
targetStopSequence,
|
||||
verifyTargetRunManualEvidence,
|
||||
type TargetRunJournalContext,
|
||||
type TargetRunJournalRecord,
|
||||
} from './target-run/targetRunJournal';
|
||||
import {
|
||||
verifyTargetActiveEvidence,
|
||||
verifyTargetRequestEvidence,
|
||||
} from './target-run/targetRunRecordEvidence';
|
||||
import {
|
||||
verifyTargetStoppedEvidence,
|
||||
verifyTargetStopRequestEvidence,
|
||||
type TargetStopActiveEvidence,
|
||||
} from './targetStopRecordEvidence';
|
||||
import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract';
|
||||
|
||||
const PREPARATION_SCHEMA = 'qinglong3-local-legacy-rollback-preparation';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_PREPARATIONS_PER_INSTANCE = 15;
|
||||
|
||||
export interface LocalDeploymentLegacyRollbackDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
readonly afterBarrier?: () => void;
|
||||
readonly afterStart?: () => void;
|
||||
}
|
||||
|
||||
interface RollbackContext {
|
||||
readonly rollbackCommand: Readonly<LocalDeploymentLegacyRollbackCommand>;
|
||||
readonly command: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly sourceCommand: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly journalCommand: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly journal: string;
|
||||
readonly uid: number;
|
||||
readonly commitment: Readonly<LegacySilenceEvidence>;
|
||||
readonly application: Readonly<TargetApplicationBinding>;
|
||||
}
|
||||
|
||||
interface RollbackSource {
|
||||
readonly active: Readonly<TargetStopActiveEvidence>;
|
||||
readonly stoppedRecord: Readonly<TargetRunJournalRecord>;
|
||||
readonly reconciliation: Readonly<TargetDataReconciliationEvidence>;
|
||||
}
|
||||
|
||||
interface RollbackPreparation {
|
||||
readonly schema: typeof PREPARATION_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'rollback_prepared';
|
||||
readonly cutoverId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly activationDigest: string;
|
||||
readonly generation: number;
|
||||
readonly expectedInstanceHeadDigest: string;
|
||||
readonly stoppedRecordDigest: string;
|
||||
readonly reconciliationEvidenceDigest: string;
|
||||
readonly legacyContainerIdentityDigest: string;
|
||||
readonly legacySourceBindingDigest: string;
|
||||
readonly targetContainerIdentityDigest: string;
|
||||
readonly targetApplicationBindingDigest: string;
|
||||
readonly rollbackRequestedAtMs: number;
|
||||
readonly preparationDigest: string;
|
||||
}
|
||||
|
||||
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 journalCommand(
|
||||
source: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
requestedAtMs: number,
|
||||
): Readonly<LocalDeploymentTargetRunCommand> {
|
||||
return Object.freeze({
|
||||
...source,
|
||||
request: Object.freeze({ ...source.request, requestedAtMs }),
|
||||
});
|
||||
}
|
||||
|
||||
function targetContext(
|
||||
context: Readonly<RollbackContext>,
|
||||
): Readonly<TargetRunJournalContext> {
|
||||
return Object.freeze({ command: context.sourceCommand, uid: context.uid });
|
||||
}
|
||||
|
||||
function rollbackContext(
|
||||
context: Readonly<RollbackContext>,
|
||||
): Readonly<TargetRunJournalContext> {
|
||||
return Object.freeze({ command: context.journalCommand, uid: context.uid });
|
||||
}
|
||||
|
||||
function readRollbackSource(
|
||||
context: Readonly<RollbackContext>,
|
||||
): Readonly<RollbackSource> {
|
||||
const generation = context.sourceCommand.request.generation;
|
||||
const request = readTargetRunJournalRecord(
|
||||
targetRunPhasePath(context.journal, generation, 'request'),
|
||||
targetContext(context),
|
||||
{
|
||||
sequence: targetRunSequence(generation, 'request'),
|
||||
generation,
|
||||
states: [
|
||||
generation === 1
|
||||
? 'target_start_requested'
|
||||
: 'target_restart_requested',
|
||||
],
|
||||
},
|
||||
);
|
||||
const requestEvidence = verifyTargetRequestEvidence(context, request);
|
||||
const activeRecord = readTargetRunJournalRecord(
|
||||
targetRunPhasePath(context.journal, generation, 'outcome'),
|
||||
targetContext(context),
|
||||
{
|
||||
sequence: targetRunSequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_active'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
},
|
||||
);
|
||||
const active = Object.freeze({
|
||||
activeRecordDigest: activeRecord.recordDigest,
|
||||
targetContainerIdentityDigest:
|
||||
requestEvidence.targetContainerIdentityDigest,
|
||||
targetApplicationBindingDigest:
|
||||
requestEvidence.targetApplicationBindingDigest,
|
||||
startupReceiptDigest: verifyTargetActiveEvidence(
|
||||
context,
|
||||
activeRecord,
|
||||
requestEvidence,
|
||||
),
|
||||
});
|
||||
const stopRequest = readTargetRunJournalRecord(
|
||||
targetStopPhasePath(context.journal, generation, 'request'),
|
||||
targetContext(context),
|
||||
{
|
||||
sequence: targetStopSequence(generation, 'request'),
|
||||
generation,
|
||||
states: ['target_stop_requested'],
|
||||
previousRecordDigest: activeRecord.recordDigest,
|
||||
requestedAtMs: context.sourceCommand.request.requestedAtMs,
|
||||
},
|
||||
);
|
||||
verifyTargetStopRequestEvidence(stopRequest, active);
|
||||
const stoppedRecord = readTargetRunJournalRecord(
|
||||
targetStopPhasePath(context.journal, generation, 'outcome'),
|
||||
targetContext(context),
|
||||
{
|
||||
sequence: targetStopSequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_stopped'],
|
||||
previousRecordDigest: stopRequest.recordDigest,
|
||||
requestedAtMs: context.sourceCommand.request.requestedAtMs,
|
||||
},
|
||||
);
|
||||
const reconciliation = verifyTargetStoppedEvidence(stoppedRecord, active);
|
||||
if (
|
||||
stoppedRecord.recordDigest !==
|
||||
context.rollbackCommand.request.expectedStoppedRecordDigest ||
|
||||
reconciliation.disposition !== 'rollback_candidate'
|
||||
) {
|
||||
configurationError('legacy rollback requires the exact rollback candidate');
|
||||
}
|
||||
return Object.freeze({ active, stoppedRecord, reconciliation });
|
||||
}
|
||||
|
||||
function preparationPath(
|
||||
command: Readonly<LocalDeploymentLegacyRollbackCommand>,
|
||||
): string {
|
||||
return path.join(
|
||||
localCutoverInstanceDirectory(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
),
|
||||
`rollback-${command.request.cutoverId}-${String(
|
||||
command.request.generation,
|
||||
).padStart(2, '0')}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function preparationRecord(
|
||||
context: Readonly<RollbackContext>,
|
||||
source: Readonly<RollbackSource>,
|
||||
): Readonly<RollbackPreparation> {
|
||||
const payload = Object.freeze({
|
||||
schema: PREPARATION_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'rollback_prepared' as const,
|
||||
cutoverId: context.sourceCommand.request.cutoverId,
|
||||
profile: context.sourceCommand.request.profile,
|
||||
instanceId: context.sourceCommand.request.instanceId,
|
||||
activationDigest: context.sourceCommand.request.expectedActivationDigest,
|
||||
generation: context.sourceCommand.request.generation,
|
||||
expectedInstanceHeadDigest:
|
||||
context.rollbackCommand.request.expectedInstanceHeadDigest,
|
||||
stoppedRecordDigest: source.stoppedRecord.recordDigest,
|
||||
reconciliationEvidenceDigest: source.reconciliation.evidenceDigest,
|
||||
legacyContainerIdentityDigest:
|
||||
context.commitment.legacyContainerIdentityDigest,
|
||||
legacySourceBindingDigest: context.commitment.legacySourceBindingDigest,
|
||||
targetContainerIdentityDigest: source.active.targetContainerIdentityDigest,
|
||||
targetApplicationBindingDigest:
|
||||
source.active.targetApplicationBindingDigest,
|
||||
rollbackRequestedAtMs:
|
||||
context.rollbackCommand.request.rollbackRequestedAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
preparationDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function parsePreparation(
|
||||
value: unknown,
|
||||
context: Readonly<RollbackContext>,
|
||||
source: Readonly<RollbackSource>,
|
||||
): Readonly<RollbackPreparation> {
|
||||
const record = object(value, 'legacy rollback preparation');
|
||||
exact(
|
||||
record,
|
||||
[
|
||||
'activationDigest',
|
||||
'cutoverId',
|
||||
'expectedInstanceHeadDigest',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacyContainerIdentityDigest',
|
||||
'legacySourceBindingDigest',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'reconciliationEvidenceDigest',
|
||||
'rollbackRequestedAtMs',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
'stoppedRecordDigest',
|
||||
'targetApplicationBindingDigest',
|
||||
'targetContainerIdentityDigest',
|
||||
],
|
||||
'legacy rollback preparation',
|
||||
);
|
||||
const { preparationDigest, ...payload } = record;
|
||||
if (
|
||||
record.schema !== PREPARATION_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.state !== 'rollback_prepared' ||
|
||||
record.cutoverId !== context.sourceCommand.request.cutoverId ||
|
||||
record.profile !== context.sourceCommand.request.profile ||
|
||||
record.instanceId !== context.sourceCommand.request.instanceId ||
|
||||
record.activationDigest !==
|
||||
context.sourceCommand.request.expectedActivationDigest ||
|
||||
record.generation !== context.sourceCommand.request.generation ||
|
||||
record.expectedInstanceHeadDigest !==
|
||||
context.rollbackCommand.request.expectedInstanceHeadDigest ||
|
||||
record.stoppedRecordDigest !== source.stoppedRecord.recordDigest ||
|
||||
record.reconciliationEvidenceDigest !==
|
||||
source.reconciliation.evidenceDigest ||
|
||||
record.legacyContainerIdentityDigest !==
|
||||
context.commitment.legacyContainerIdentityDigest ||
|
||||
record.legacySourceBindingDigest !==
|
||||
context.commitment.legacySourceBindingDigest ||
|
||||
record.targetContainerIdentityDigest !==
|
||||
source.active.targetContainerIdentityDigest ||
|
||||
record.targetApplicationBindingDigest !==
|
||||
source.active.targetApplicationBindingDigest ||
|
||||
record.rollbackRequestedAtMs !==
|
||||
context.rollbackCommand.request.rollbackRequestedAtMs ||
|
||||
typeof preparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(preparationDigest) ||
|
||||
cutoverDigest(payload) !== preparationDigest
|
||||
) {
|
||||
configurationError('legacy rollback preparation drifted');
|
||||
}
|
||||
return record as unknown as Readonly<RollbackPreparation>;
|
||||
}
|
||||
|
||||
function docker(
|
||||
context: Readonly<RollbackContext>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
): string {
|
||||
return runDocker({
|
||||
executable: context.sourceCommand.options.dockerExecutable,
|
||||
socketPath: context.sourceCommand.options.dockerSocketPath,
|
||||
args,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
function stoppedObservations(
|
||||
context: Readonly<RollbackContext>,
|
||||
source: Readonly<RollbackSource>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
): void {
|
||||
const legacy = parseStoppedLegacyEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'inspect',
|
||||
context.sourceCommand.request.expectedLegacyContainerId,
|
||||
],
|
||||
30_000,
|
||||
),
|
||||
context.sourceCommand,
|
||||
);
|
||||
const target = parseTargetContainerEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'inspect',
|
||||
context.sourceCommand.request.expectedTargetContainerId,
|
||||
],
|
||||
30_000,
|
||||
),
|
||||
context.sourceCommand,
|
||||
context.application,
|
||||
'stopped',
|
||||
);
|
||||
const reconciliation = readTargetDataReconciliationEvidence(
|
||||
context.sourceCommand,
|
||||
context.uid,
|
||||
);
|
||||
if (
|
||||
legacy.identityDigest !==
|
||||
context.commitment.legacyContainerIdentityDigest ||
|
||||
legacy.sourceBindingDigest !==
|
||||
context.commitment.legacySourceBindingDigest ||
|
||||
target.identityDigest !== source.active.targetContainerIdentityDigest ||
|
||||
target.applicationBindingDigest !==
|
||||
source.active.targetApplicationBindingDigest ||
|
||||
reconciliation.disposition !== 'rollback_candidate' ||
|
||||
reconciliation.evidenceDigest !== source.reconciliation.evidenceDigest
|
||||
) {
|
||||
configurationError('legacy rollback stopped evidence drifted');
|
||||
}
|
||||
}
|
||||
|
||||
function result(
|
||||
context: Readonly<RollbackContext>,
|
||||
status: 'prepared' | 'existing',
|
||||
state: LocalDeploymentLegacyRollbackResult['state'],
|
||||
preparationDigest: string,
|
||||
recordDigest: string,
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const head = advanceLocalCutoverInstanceHead(
|
||||
context.sourceCommand,
|
||||
context.uid,
|
||||
state,
|
||||
context.sourceCommand.request.generation,
|
||||
state === 'rollback_prepared' ? preparationDigest : recordDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: context.rollbackCommand.operation,
|
||||
status,
|
||||
state,
|
||||
cutoverId: context.sourceCommand.request.cutoverId,
|
||||
generation: context.sourceCommand.request.generation,
|
||||
preparationDigest,
|
||||
recordDigest,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function requestEvidence(
|
||||
preparation: Readonly<RollbackPreparation>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
preparationDigest: preparation.preparationDigest,
|
||||
stoppedRecordDigest: preparation.stoppedRecordDigest,
|
||||
reconciliationEvidenceDigest: preparation.reconciliationEvidenceDigest,
|
||||
legacyContainerIdentityDigest: preparation.legacyContainerIdentityDigest,
|
||||
legacySourceBindingDigest: preparation.legacySourceBindingDigest,
|
||||
targetContainerIdentityDigest: preparation.targetContainerIdentityDigest,
|
||||
targetApplicationBindingDigest: preparation.targetApplicationBindingDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function verifyRequestEvidence(
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
preparation: Readonly<RollbackPreparation>,
|
||||
): void {
|
||||
const evidence = object(record.evidence, 'legacy rollback request evidence');
|
||||
const expected = requestEvidence(preparation);
|
||||
exact(evidence, Object.keys(expected), 'legacy rollback request evidence');
|
||||
if (
|
||||
Object.entries(expected).some(([key, value]) => evidence[key] !== value)
|
||||
) {
|
||||
configurationError('legacy rollback request evidence drifted');
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeEvidence(
|
||||
request: Readonly<TargetRunJournalRecord>,
|
||||
preparation: Readonly<RollbackPreparation>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
preparationDigest: preparation.preparationDigest,
|
||||
requestRecordDigest: request.recordDigest,
|
||||
legacyContainerIdentityDigest: preparation.legacyContainerIdentityDigest,
|
||||
legacySourceBindingDigest: preparation.legacySourceBindingDigest,
|
||||
targetContainerIdentityDigest: preparation.targetContainerIdentityDigest,
|
||||
targetApplicationBindingDigest: preparation.targetApplicationBindingDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function verifyOutcomeEvidence(
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
request: Readonly<TargetRunJournalRecord>,
|
||||
preparation: Readonly<RollbackPreparation>,
|
||||
): void {
|
||||
const evidence = object(record.evidence, 'legacy rollback outcome evidence');
|
||||
const expected = outcomeEvidence(request, preparation);
|
||||
exact(evidence, Object.keys(expected), 'legacy rollback outcome evidence');
|
||||
if (
|
||||
Object.entries(expected).some(([key, value]) => evidence[key] !== value)
|
||||
) {
|
||||
configurationError('legacy rollback outcome evidence drifted');
|
||||
}
|
||||
}
|
||||
|
||||
function publishManual(
|
||||
context: Readonly<RollbackContext>,
|
||||
filePath: string,
|
||||
sequence: number,
|
||||
previousRecordDigest: string,
|
||||
reason:
|
||||
| 'legacy_restart_preflight_unproved'
|
||||
| 'legacy_restart_result_unproved',
|
||||
preparationDigest: string,
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const record = targetRunJournalRecord(
|
||||
context.journalCommand,
|
||||
sequence,
|
||||
'manual_required',
|
||||
previousRecordDigest,
|
||||
targetRunManualEvidence(reason),
|
||||
);
|
||||
const status = publishTargetRunJournalRecord(
|
||||
rollbackContext(context),
|
||||
filePath,
|
||||
record,
|
||||
'legacy rollback manual resolution',
|
||||
);
|
||||
return result(
|
||||
context,
|
||||
status,
|
||||
'manual_required',
|
||||
preparationDigest,
|
||||
record.recordDigest,
|
||||
);
|
||||
}
|
||||
|
||||
function replayCommit(
|
||||
context: Readonly<RollbackContext>,
|
||||
preparation: Readonly<RollbackPreparation>,
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> | undefined {
|
||||
const generation = context.sourceCommand.request.generation;
|
||||
const requestPath = legacyRollbackPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'request',
|
||||
);
|
||||
if (!fs.existsSync(requestPath)) return undefined;
|
||||
const request = readTargetRunJournalRecord(
|
||||
requestPath,
|
||||
rollbackContext(context),
|
||||
{
|
||||
sequence: legacyRollbackSequence(generation, 'request'),
|
||||
generation,
|
||||
states: ['legacy_restart_requested', 'manual_required'],
|
||||
previousRecordDigest: preparation.preparationDigest,
|
||||
requestedAtMs: context.rollbackCommand.request.rollbackRequestedAtMs,
|
||||
},
|
||||
);
|
||||
if (request.state === 'manual_required') {
|
||||
verifyTargetRunManualEvidence(request);
|
||||
return result(
|
||||
context,
|
||||
'existing',
|
||||
'manual_required',
|
||||
preparation.preparationDigest,
|
||||
request.recordDigest,
|
||||
);
|
||||
}
|
||||
verifyRequestEvidence(request, preparation);
|
||||
const outcomePath = legacyRollbackPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'outcome',
|
||||
);
|
||||
if (!fs.existsSync(outcomePath)) return undefined;
|
||||
const outcome = readTargetRunJournalRecord(
|
||||
outcomePath,
|
||||
rollbackContext(context),
|
||||
{
|
||||
sequence: legacyRollbackSequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['legacy_running', 'manual_required'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
requestedAtMs: context.rollbackCommand.request.rollbackRequestedAtMs,
|
||||
},
|
||||
);
|
||||
if (outcome.state === 'manual_required') {
|
||||
verifyTargetRunManualEvidence(outcome);
|
||||
return result(
|
||||
context,
|
||||
'existing',
|
||||
'manual_required',
|
||||
preparation.preparationDigest,
|
||||
outcome.recordDigest,
|
||||
);
|
||||
}
|
||||
verifyOutcomeEvidence(outcome, request, preparation);
|
||||
return result(
|
||||
context,
|
||||
'existing',
|
||||
'legacy_running',
|
||||
preparation.preparationDigest,
|
||||
outcome.recordDigest,
|
||||
);
|
||||
}
|
||||
|
||||
function prepare(
|
||||
context: Readonly<RollbackContext>,
|
||||
source: Readonly<RollbackSource>,
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
dependencies: LocalDeploymentLegacyRollbackDependencies,
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const filePath = preparationPath(context.rollbackCommand);
|
||||
if (head.state === 'rollback_prepared') {
|
||||
const preparation = parsePreparation(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
context,
|
||||
source,
|
||||
);
|
||||
if (
|
||||
head.previousHeadDigest !==
|
||||
context.rollbackCommand.request.expectedInstanceHeadDigest ||
|
||||
head.sourceRecordDigest !== preparation.preparationDigest
|
||||
) {
|
||||
configurationError('legacy rollback preparation lost the instance head');
|
||||
}
|
||||
return result(
|
||||
context,
|
||||
'existing',
|
||||
'rollback_prepared',
|
||||
preparation.preparationDigest,
|
||||
source.stoppedRecord.recordDigest,
|
||||
);
|
||||
}
|
||||
if (
|
||||
head.state !== 'target_stopped' ||
|
||||
head.headDigest !==
|
||||
context.rollbackCommand.request.expectedInstanceHeadDigest ||
|
||||
head.sourceRecordDigest !== source.stoppedRecord.recordDigest ||
|
||||
head.generation !== context.sourceCommand.request.generation
|
||||
) {
|
||||
configurationError(
|
||||
'legacy rollback prepare is not bound to target stopped',
|
||||
);
|
||||
}
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(context.sourceCommand.options.dockerSocketPath, context.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
stoppedObservations(context, source, runDocker);
|
||||
const preparation = preparationRecord(context, source);
|
||||
const serialized = `${JSON.stringify(preparation, null, 2)}\n`;
|
||||
const directory = path.dirname(filePath);
|
||||
const preparationEntries = fs
|
||||
.readdirSync(directory, { withFileTypes: true })
|
||||
.filter((entry) => entry.name.startsWith('rollback-'));
|
||||
if (preparationEntries.some((entry) => !entry.isFile())) {
|
||||
configurationError(
|
||||
'legacy rollback preparation directory contains an unsafe entry',
|
||||
);
|
||||
}
|
||||
if (
|
||||
preparationEntries.length >= MAX_PREPARATIONS_PER_INSTANCE &&
|
||||
!fs.existsSync(filePath)
|
||||
) {
|
||||
configurationError(
|
||||
'legacy rollback preparation retention limit is reached',
|
||||
);
|
||||
}
|
||||
preflightPublishedFile(
|
||||
filePath,
|
||||
serialized,
|
||||
0o600,
|
||||
context.uid,
|
||||
'legacy rollback preparation',
|
||||
);
|
||||
const status = publishExactFile(
|
||||
filePath,
|
||||
serialized,
|
||||
0o600,
|
||||
context.uid,
|
||||
'legacy rollback preparation',
|
||||
);
|
||||
return result(
|
||||
context,
|
||||
status,
|
||||
'rollback_prepared',
|
||||
preparation.preparationDigest,
|
||||
source.stoppedRecord.recordDigest,
|
||||
);
|
||||
}
|
||||
|
||||
function commit(
|
||||
context: Readonly<RollbackContext>,
|
||||
source: Readonly<RollbackSource>,
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
dependencies: LocalDeploymentLegacyRollbackDependencies,
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const preparation = parsePreparation(
|
||||
readPrivateLocalCommandFile(preparationPath(context.rollbackCommand)),
|
||||
context,
|
||||
source,
|
||||
);
|
||||
if (
|
||||
preparation.preparationDigest !==
|
||||
context.rollbackCommand.request.expectedPreparationDigest
|
||||
) {
|
||||
configurationError('legacy rollback commit preparation is invalid');
|
||||
}
|
||||
const replay = replayCommit(context, preparation);
|
||||
if (replay !== undefined) return replay;
|
||||
if (
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested'
|
||||
) {
|
||||
configurationError('legacy rollback commit is not bound to preparation');
|
||||
}
|
||||
if (
|
||||
head.state === 'rollback_prepared' &&
|
||||
(head.sourceRecordDigest !== preparation.preparationDigest ||
|
||||
head.previousHeadDigest !==
|
||||
context.rollbackCommand.request.expectedInstanceHeadDigest)
|
||||
) {
|
||||
configurationError('legacy rollback commit lost the instance head');
|
||||
}
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(context.sourceCommand.options.dockerSocketPath, context.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
const generation = context.sourceCommand.request.generation;
|
||||
const requestPath = legacyRollbackPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'request',
|
||||
);
|
||||
let request: Readonly<TargetRunJournalRecord>;
|
||||
let shouldStart = false;
|
||||
if (fs.existsSync(requestPath)) {
|
||||
request = readTargetRunJournalRecord(
|
||||
requestPath,
|
||||
rollbackContext(context),
|
||||
{
|
||||
sequence: legacyRollbackSequence(generation, 'request'),
|
||||
generation,
|
||||
states: ['legacy_restart_requested'],
|
||||
previousRecordDigest: preparation.preparationDigest,
|
||||
requestedAtMs: context.rollbackCommand.request.rollbackRequestedAtMs,
|
||||
},
|
||||
);
|
||||
verifyRequestEvidence(request, preparation);
|
||||
advanceLocalCutoverInstanceHead(
|
||||
context.sourceCommand,
|
||||
context.uid,
|
||||
'legacy_restart_requested',
|
||||
generation,
|
||||
request.recordDigest,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
stoppedObservations(context, source, runDocker);
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
requestPath,
|
||||
legacyRollbackSequence(generation, 'request'),
|
||||
preparation.preparationDigest,
|
||||
'legacy_restart_preflight_unproved',
|
||||
preparation.preparationDigest,
|
||||
);
|
||||
}
|
||||
request = targetRunJournalRecord(
|
||||
context.journalCommand,
|
||||
legacyRollbackSequence(generation, 'request'),
|
||||
'legacy_restart_requested',
|
||||
preparation.preparationDigest,
|
||||
requestEvidence(preparation),
|
||||
);
|
||||
publishTargetRunJournalRecord(
|
||||
rollbackContext(context),
|
||||
requestPath,
|
||||
request,
|
||||
'legacy rollback start barrier',
|
||||
);
|
||||
advanceLocalCutoverInstanceHead(
|
||||
context.sourceCommand,
|
||||
context.uid,
|
||||
'legacy_restart_requested',
|
||||
generation,
|
||||
request.recordDigest,
|
||||
);
|
||||
shouldStart = true;
|
||||
dependencies.afterBarrier?.();
|
||||
}
|
||||
if (shouldStart) {
|
||||
try {
|
||||
stoppedObservations(context, source, runDocker);
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
legacyRollbackPhasePath(context.journal, generation, 'outcome'),
|
||||
legacyRollbackSequence(generation, 'outcome'),
|
||||
request.recordDigest,
|
||||
'legacy_restart_result_unproved',
|
||||
preparation.preparationDigest,
|
||||
);
|
||||
}
|
||||
try {
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'start',
|
||||
context.sourceCommand.request.expectedLegacyContainerId,
|
||||
],
|
||||
45_000,
|
||||
);
|
||||
} catch {
|
||||
// The exact running inspection below resolves a lost start response.
|
||||
}
|
||||
dependencies.afterStart?.();
|
||||
}
|
||||
const outcomePath = legacyRollbackPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'outcome',
|
||||
);
|
||||
try {
|
||||
const legacy = parseActiveLegacyEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'inspect',
|
||||
context.sourceCommand.request.expectedLegacyContainerId,
|
||||
],
|
||||
30_000,
|
||||
),
|
||||
context.sourceCommand,
|
||||
);
|
||||
const target = parseTargetContainerEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'inspect',
|
||||
context.sourceCommand.request.expectedTargetContainerId,
|
||||
],
|
||||
30_000,
|
||||
),
|
||||
context.sourceCommand,
|
||||
context.application,
|
||||
'stopped',
|
||||
);
|
||||
if (
|
||||
legacy.identityDigest !==
|
||||
context.commitment.legacyContainerIdentityDigest ||
|
||||
legacy.sourceBindingDigest !==
|
||||
context.commitment.legacySourceBindingDigest ||
|
||||
target.identityDigest !== source.active.targetContainerIdentityDigest ||
|
||||
target.applicationBindingDigest !==
|
||||
source.active.targetApplicationBindingDigest
|
||||
) {
|
||||
configurationError('legacy rollback outcome identity drifted');
|
||||
}
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
outcomePath,
|
||||
legacyRollbackSequence(generation, 'outcome'),
|
||||
request.recordDigest,
|
||||
'legacy_restart_result_unproved',
|
||||
preparation.preparationDigest,
|
||||
);
|
||||
}
|
||||
const outcome = targetRunJournalRecord(
|
||||
context.journalCommand,
|
||||
legacyRollbackSequence(generation, 'outcome'),
|
||||
'legacy_running',
|
||||
request.recordDigest,
|
||||
outcomeEvidence(request, preparation),
|
||||
);
|
||||
const status = publishTargetRunJournalRecord(
|
||||
rollbackContext(context),
|
||||
outcomePath,
|
||||
outcome,
|
||||
'legacy rollback running commitment',
|
||||
);
|
||||
return result(
|
||||
context,
|
||||
status,
|
||||
'legacy_running',
|
||||
preparation.preparationDigest,
|
||||
outcome.recordDigest,
|
||||
);
|
||||
}
|
||||
|
||||
export function runLocalDeploymentLegacyRollback(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentLegacyRollbackDependencies = {},
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const rollbackCommand = normalizeLocalDeploymentLegacyRollbackCommand(input);
|
||||
const sourceCommand = legacyRollbackTargetRunCommand(rollbackCommand);
|
||||
const identity = currentIdentity();
|
||||
const journal = path.dirname(legacyCommitmentPath(sourceCommand));
|
||||
validatePrivateDirectory(
|
||||
sourceCommand.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
path.join(sourceCommand.options.deploymentRoot, 'service'),
|
||||
identity.uid,
|
||||
'serviceDescriptorRoot',
|
||||
);
|
||||
validatePrivateDirectory(journal, identity.uid, 'cutoverJournal');
|
||||
validatePrivateDirectory(
|
||||
localCutoverInstanceDirectory(
|
||||
sourceCommand.options.deploymentRoot,
|
||||
sourceCommand.request.instanceId,
|
||||
),
|
||||
identity.uid,
|
||||
'cutoverInstanceDirectory',
|
||||
);
|
||||
const context = Object.freeze({
|
||||
rollbackCommand,
|
||||
command: sourceCommand,
|
||||
sourceCommand,
|
||||
journalCommand: journalCommand(
|
||||
sourceCommand,
|
||||
rollbackCommand.request.rollbackRequestedAtMs,
|
||||
),
|
||||
journal,
|
||||
uid: identity.uid,
|
||||
commitment: readLegacySilenceEvidence(sourceCommand),
|
||||
application: readTargetApplicationBinding(sourceCommand),
|
||||
});
|
||||
const source = readRollbackSource(context);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
sourceCommand.options.deploymentRoot,
|
||||
sourceCommand.request.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
if (
|
||||
head.profile !== sourceCommand.request.profile ||
|
||||
head.cutoverId !== sourceCommand.request.cutoverId ||
|
||||
head.activationDigest !== sourceCommand.request.expectedActivationDigest ||
|
||||
head.generation !== sourceCommand.request.generation
|
||||
) {
|
||||
configurationError('legacy rollback is not bound to the instance lineage');
|
||||
}
|
||||
return rollbackCommand.operation ===
|
||||
'local.deployment.cutover.legacy-rollback-prepare'
|
||||
? prepare(context, source, head, dependencies)
|
||||
: commit(context, source, head, dependencies);
|
||||
}
|
||||
|
||||
export function runLocalDeploymentLegacyRollbackCommandFile(
|
||||
filePath: string,
|
||||
expectedOperation?: LocalDeploymentLegacyRollbackCommand['operation'],
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const input = readPrivateLocalCommandFile(filePath);
|
||||
if (
|
||||
expectedOperation !== undefined &&
|
||||
(!input ||
|
||||
typeof input !== 'object' ||
|
||||
Array.isArray(input) ||
|
||||
(input as Record<string, unknown>).operation !== expectedOperation)
|
||||
) {
|
||||
configurationError(
|
||||
'legacy rollback command does not match the CLI operation',
|
||||
);
|
||||
}
|
||||
return runLocalDeploymentLegacyRollback(input);
|
||||
}
|
||||
|
||||
export { EMPTY_ROLLBACK_PREPARATION_DIGEST };
|
||||
@@ -0,0 +1,169 @@
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import {
|
||||
normalizeLocalDeploymentTargetStopCommand,
|
||||
targetStopRunCommand,
|
||||
type LocalDeploymentTargetStopCommand,
|
||||
} from './targetStopContract';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
export const EMPTY_ROLLBACK_PREPARATION_DIGEST = '0'.repeat(64);
|
||||
|
||||
export type LocalDeploymentLegacyRollbackOperation =
|
||||
| 'local.deployment.cutover.legacy-rollback-prepare'
|
||||
| 'local.deployment.cutover.legacy-rollback-commit';
|
||||
|
||||
export interface LocalDeploymentLegacyRollbackCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentLegacyRollbackOperation;
|
||||
readonly options: LocalDeploymentTargetStopCommand['options'];
|
||||
readonly request: LocalDeploymentTargetStopCommand['request'] &
|
||||
Readonly<{
|
||||
expectedInstanceHeadDigest: string;
|
||||
expectedStoppedRecordDigest: string;
|
||||
expectedPreparationDigest: string;
|
||||
rollbackRequestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentLegacyRollbackResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentLegacyRollbackOperation;
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'rollback_prepared' | 'legacy_running' | 'manual_required';
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly preparationDigest: string;
|
||||
readonly recordDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${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])
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLocalDeploymentLegacyRollbackCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentLegacyRollbackCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
(command.operation !== 'local.deployment.cutover.legacy-rollback-prepare' &&
|
||||
command.operation !== 'local.deployment.cutover.legacy-rollback-commit')
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'legacy rollback schemaVersion or operation is invalid',
|
||||
);
|
||||
}
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'activationPath',
|
||||
'applicationConfigPath',
|
||||
'cutoverId',
|
||||
'expectedActivationDigest',
|
||||
'expectedInstanceHeadDigest',
|
||||
'expectedLegacyCommitmentDigest',
|
||||
'expectedLegacyContainerId',
|
||||
'expectedLegacyDatabasePath',
|
||||
'expectedPreparationDigest',
|
||||
'expectedStoppedRecordDigest',
|
||||
'expectedTargetApplicationConfigPath',
|
||||
'expectedTargetCommitmentPath',
|
||||
'expectedTargetContainerId',
|
||||
'expectedTargetImage',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacySourcePath',
|
||||
'manifestPath',
|
||||
'profile',
|
||||
'recoveryPath',
|
||||
'requestedAtMs',
|
||||
'rollbackRequestedAtMs',
|
||||
'targetDatabasePath',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const {
|
||||
expectedInstanceHeadDigest,
|
||||
expectedStoppedRecordDigest,
|
||||
expectedPreparationDigest,
|
||||
rollbackRequestedAtMs,
|
||||
...targetRequest
|
||||
} = request;
|
||||
const normalized = normalizeLocalDeploymentTargetStopCommand({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.cutover.target-stop',
|
||||
options: command.options,
|
||||
request: targetRequest,
|
||||
});
|
||||
if (
|
||||
typeof expectedInstanceHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(expectedInstanceHeadDigest) ||
|
||||
typeof expectedStoppedRecordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(expectedStoppedRecordDigest) ||
|
||||
typeof expectedPreparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(expectedPreparationDigest) ||
|
||||
(command.operation ===
|
||||
'local.deployment.cutover.legacy-rollback-commit') ===
|
||||
(expectedPreparationDigest === EMPTY_ROLLBACK_PREPARATION_DIGEST) ||
|
||||
!Number.isSafeInteger(rollbackRequestedAtMs) ||
|
||||
(rollbackRequestedAtMs as number) < 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'legacy rollback request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
options: normalized.options,
|
||||
request: Object.freeze({
|
||||
...normalized.request,
|
||||
expectedInstanceHeadDigest,
|
||||
expectedStoppedRecordDigest,
|
||||
expectedPreparationDigest,
|
||||
rollbackRequestedAtMs: rollbackRequestedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function legacyRollbackTargetRunCommand(
|
||||
command: Readonly<LocalDeploymentLegacyRollbackCommand>,
|
||||
) {
|
||||
return targetStopRunCommand({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.cutover.target-stop',
|
||||
options: command.options,
|
||||
request: command.request,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../foundation/docker';
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import {
|
||||
normalizeLocalDeploymentLegacyStopCommand,
|
||||
type LocalDeploymentLegacyStopCommand,
|
||||
type LocalDeploymentLegacyStopResult,
|
||||
} from './contract';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
claimLocalCutoverInstance,
|
||||
} from './instanceLineage';
|
||||
|
||||
const INTENT_SCHEMA = 'qinglong3-local-cutover-journal-record';
|
||||
const COMMITMENT_KIND = 'qinglong3-local-legacy-silence-commitment';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_CUTOVERS = 64;
|
||||
|
||||
export interface LocalDeploymentLegacyStopDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function textDigest(value: string): string {
|
||||
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
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 verifyActivation(
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
): void {
|
||||
let sourceStat: fs.Stats;
|
||||
try {
|
||||
sourceStat = fs.lstatSync(command.request.legacySourcePath);
|
||||
} catch (error) {
|
||||
configurationError('legacy source is unavailable', error);
|
||||
}
|
||||
if (
|
||||
!sourceStat.isFile() ||
|
||||
sourceStat.isSymbolicLink() ||
|
||||
fs.realpathSync(command.request.legacySourcePath) !==
|
||||
command.request.legacySourcePath
|
||||
) {
|
||||
configurationError('legacy source must be a canonical regular file');
|
||||
}
|
||||
const activation = object(
|
||||
readPrivateLocalCommandFile(command.request.activationPath),
|
||||
'activation',
|
||||
);
|
||||
const expectedKeys = [
|
||||
'activationDigest',
|
||||
'adoptionManifestDigest',
|
||||
'createdAtMs',
|
||||
'kind',
|
||||
'planDigest',
|
||||
'profile',
|
||||
'recoverySha256',
|
||||
'schemaVersion',
|
||||
'sourcePathDigest',
|
||||
'state',
|
||||
'targetDevice',
|
||||
'targetInode',
|
||||
'targetPathDigest',
|
||||
'targetSha256',
|
||||
];
|
||||
exact(activation, expectedKeys, 'activation');
|
||||
if (
|
||||
activation.schemaVersion !== 1 ||
|
||||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
|
||||
activation.state !== 'prepared' ||
|
||||
activation.profile !== command.request.profile ||
|
||||
activation.sourcePathDigest !==
|
||||
textDigest(command.request.legacySourcePath) ||
|
||||
activation.activationDigest !== command.request.expectedActivationDigest
|
||||
) {
|
||||
configurationError('activation does not match the cutover request');
|
||||
}
|
||||
const { activationDigest, ...payload } = activation;
|
||||
if (
|
||||
typeof activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activationDigest) ||
|
||||
digest(payload) !== activationDigest
|
||||
) {
|
||||
configurationError('activation digest does not match');
|
||||
}
|
||||
}
|
||||
|
||||
function cutoverDirectory(
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
uid: number,
|
||||
): string {
|
||||
const serviceRoot = path.join(command.options.deploymentRoot, 'service');
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(serviceRoot, uid, 'serviceDescriptorRoot');
|
||||
const catalog = path.join(serviceRoot, 'cutovers');
|
||||
ensurePrivateDirectory(catalog, uid, 'cutoverRoot');
|
||||
const entries = fs.readdirSync(catalog, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
configurationError('cutover catalog contains drift');
|
||||
}
|
||||
}
|
||||
const target = path.join(catalog, command.request.cutoverId);
|
||||
if (entries.length >= MAX_CUTOVERS && !fs.existsSync(target)) {
|
||||
configurationError('cutover retention limit is reached');
|
||||
}
|
||||
ensurePrivateDirectory(target, uid, 'cutoverJournal');
|
||||
return target;
|
||||
}
|
||||
|
||||
function endpointDigest(
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
): string {
|
||||
return digest({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
});
|
||||
}
|
||||
|
||||
function intentRecord(command: Readonly<LocalDeploymentLegacyStopCommand>) {
|
||||
const payload = Object.freeze({
|
||||
schema: INTENT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
sequence: 1 as const,
|
||||
state: 'legacy_stop_requested' as const,
|
||||
cutoverId: command.request.cutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
activationDigest: command.request.expectedActivationDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
controller: Object.freeze({
|
||||
kind: 'docker' as const,
|
||||
endpointDigest: endpointDigest(command),
|
||||
legacyContainerId: command.request.expectedLegacyContainerId,
|
||||
requestedSourceBindingDigest: digest({
|
||||
legacySourcePath: command.request.legacySourcePath,
|
||||
legacyDatabasePath: command.request.expectedLegacyDatabasePath,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return Object.freeze({ ...payload, recordDigest: digest(payload) });
|
||||
}
|
||||
|
||||
function parseStoppedContainer(
|
||||
output: string,
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
): Readonly<{ identityDigest: string; sourceBindingDigest: string }> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (error) {
|
||||
configurationError('legacy container inspection is invalid', error);
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
||||
configurationError('legacy container inspection count is invalid');
|
||||
}
|
||||
const container = object(parsed[0], 'legacy container');
|
||||
const state = object(container.State, 'legacy container state');
|
||||
const hostConfig = object(
|
||||
container.HostConfig,
|
||||
'legacy container host config',
|
||||
);
|
||||
const restartPolicy = object(
|
||||
hostConfig.RestartPolicy,
|
||||
'legacy container restart policy',
|
||||
);
|
||||
const config = object(container.Config, 'legacy container config');
|
||||
if (
|
||||
container.Id !== command.request.expectedLegacyContainerId ||
|
||||
state.Running !== false ||
|
||||
state.Restarting !== false ||
|
||||
state.Paused !== false ||
|
||||
state.Pid !== 0 ||
|
||||
(state.Status !== 'exited' && state.Status !== 'dead') ||
|
||||
(restartPolicy.Name !== '' && restartPolicy.Name !== 'no') ||
|
||||
typeof container.Created !== 'string' ||
|
||||
container.Created.length < 1 ||
|
||||
container.Created.length > 128 ||
|
||||
typeof container.Name !== 'string' ||
|
||||
container.Name.length < 2 ||
|
||||
container.Name.length > 256 ||
|
||||
typeof config.Image !== 'string' ||
|
||||
config.Image.length < 1 ||
|
||||
config.Image.length > 512
|
||||
) {
|
||||
configurationError(
|
||||
'legacy container is not durably stopped with restart disabled',
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(container.Mounts)) {
|
||||
configurationError('legacy container mount evidence is unavailable');
|
||||
}
|
||||
const matchingMounts = container.Mounts.flatMap((value) => {
|
||||
const mount = object(value, 'legacy container mount');
|
||||
if (
|
||||
mount.Type !== 'bind' ||
|
||||
typeof mount.Source !== 'string' ||
|
||||
typeof mount.Destination !== 'string' ||
|
||||
typeof mount.RW !== 'boolean' ||
|
||||
!path.isAbsolute(mount.Source) ||
|
||||
!path.isAbsolute(mount.Destination) ||
|
||||
path.normalize(mount.Source) !== mount.Source ||
|
||||
path.normalize(mount.Destination) !== mount.Destination
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const relative = path.relative(
|
||||
mount.Source,
|
||||
command.request.legacySourcePath,
|
||||
);
|
||||
if (
|
||||
relative.startsWith('..') ||
|
||||
path.isAbsolute(relative) ||
|
||||
path.join(mount.Destination, relative) !==
|
||||
command.request.expectedLegacyDatabasePath
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
Object.freeze({
|
||||
source: mount.Source,
|
||||
destination: mount.Destination,
|
||||
readWrite: mount.RW,
|
||||
}),
|
||||
];
|
||||
});
|
||||
if (matchingMounts.length !== 1) {
|
||||
configurationError(
|
||||
'legacy container does not have one exact activation source binding',
|
||||
);
|
||||
}
|
||||
const matchingMount = matchingMounts[0]!;
|
||||
return Object.freeze({
|
||||
identityDigest: digest({
|
||||
containerId: container.Id,
|
||||
created: container.Created,
|
||||
image: config.Image,
|
||||
name: container.Name,
|
||||
}),
|
||||
sourceBindingDigest: digest({
|
||||
sourcePathDigest: textDigest(command.request.legacySourcePath),
|
||||
databasePathDigest: textDigest(
|
||||
command.request.expectedLegacyDatabasePath,
|
||||
),
|
||||
mountSourceDigest: textDigest(matchingMount.source),
|
||||
mountDestinationDigest: textDigest(matchingMount.destination),
|
||||
readWrite: matchingMount.readWrite,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function commitmentRecord(
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
previousRecordDigest: string,
|
||||
legacyContainerIdentityDigest: string,
|
||||
legacySourceBindingDigest: string,
|
||||
) {
|
||||
const payload = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
kind: COMMITMENT_KIND,
|
||||
state: 'legacy_stopped' as const,
|
||||
cutoverId: command.request.cutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
activationDigest: command.request.expectedActivationDigest,
|
||||
previousRecordDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
observedAtMs: command.request.requestedAtMs,
|
||||
controller: Object.freeze({
|
||||
kind: 'docker' as const,
|
||||
endpointDigest: endpointDigest(command),
|
||||
legacyContainerId: command.request.expectedLegacyContainerId,
|
||||
legacyContainerIdentityDigest,
|
||||
legacySourceBindingDigest,
|
||||
}),
|
||||
});
|
||||
return Object.freeze({ ...payload, commitmentDigest: digest(payload) });
|
||||
}
|
||||
|
||||
function verifyExistingCommitment(
|
||||
value: unknown,
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
previousRecordDigest: string,
|
||||
): Readonly<{ commitmentDigest: string }> {
|
||||
const commitment = object(value, 'legacy silence commitment');
|
||||
exact(
|
||||
commitment,
|
||||
[
|
||||
'activationDigest',
|
||||
'commitmentDigest',
|
||||
'controller',
|
||||
'cutoverId',
|
||||
'instanceId',
|
||||
'kind',
|
||||
'observedAtMs',
|
||||
'previousRecordDigest',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'legacy silence commitment',
|
||||
);
|
||||
const controller = object(commitment.controller, 'commitment controller');
|
||||
exact(
|
||||
controller,
|
||||
[
|
||||
'endpointDigest',
|
||||
'kind',
|
||||
'legacyContainerId',
|
||||
'legacyContainerIdentityDigest',
|
||||
'legacySourceBindingDigest',
|
||||
],
|
||||
'commitment controller',
|
||||
);
|
||||
const { commitmentDigest, ...payload } = commitment;
|
||||
if (
|
||||
commitment.schemaVersion !== 1 ||
|
||||
commitment.kind !== COMMITMENT_KIND ||
|
||||
commitment.state !== 'legacy_stopped' ||
|
||||
commitment.cutoverId !== command.request.cutoverId ||
|
||||
commitment.profile !== command.request.profile ||
|
||||
commitment.instanceId !== command.request.instanceId ||
|
||||
commitment.activationDigest !== command.request.expectedActivationDigest ||
|
||||
commitment.previousRecordDigest !== previousRecordDigest ||
|
||||
commitment.requestedAtMs !== command.request.requestedAtMs ||
|
||||
commitment.observedAtMs !== command.request.requestedAtMs ||
|
||||
controller.kind !== 'docker' ||
|
||||
controller.endpointDigest !== endpointDigest(command) ||
|
||||
controller.legacyContainerId !==
|
||||
command.request.expectedLegacyContainerId ||
|
||||
typeof controller.legacyContainerIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(controller.legacyContainerIdentityDigest) ||
|
||||
typeof controller.legacySourceBindingDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(controller.legacySourceBindingDigest) ||
|
||||
typeof commitmentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(commitmentDigest) ||
|
||||
digest(payload) !== commitmentDigest
|
||||
) {
|
||||
configurationError('legacy silence commitment drifted');
|
||||
}
|
||||
return Object.freeze({ commitmentDigest });
|
||||
}
|
||||
|
||||
function docker(
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
): string {
|
||||
return runDocker({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
args,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function stopLegacyDockerForLocalDeployment(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentLegacyStopDependencies = {},
|
||||
): Readonly<LocalDeploymentLegacyStopResult> {
|
||||
const command = normalizeLocalDeploymentLegacyStopCommand(input);
|
||||
const identity = currentIdentity();
|
||||
verifyActivation(command);
|
||||
const intent = intentRecord(command);
|
||||
claimLocalCutoverInstance(command, identity.uid, intent.recordDigest);
|
||||
const journal = cutoverDirectory(command, identity.uid);
|
||||
const intentPath = path.join(journal, '0001-legacy-stop-requested.json');
|
||||
const commitmentPath = path.join(journal, '0002-legacy-stopped.json');
|
||||
const intentContents = `${JSON.stringify(intent, null, 2)}\n`;
|
||||
preflightPublishedFile(
|
||||
intentPath,
|
||||
intentContents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy stop intent',
|
||||
);
|
||||
publishExactFile(
|
||||
intentPath,
|
||||
intentContents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy stop intent',
|
||||
);
|
||||
if (fs.existsSync(commitmentPath)) {
|
||||
const existing = verifyExistingCommitment(
|
||||
readPrivateLocalCommandFile(commitmentPath),
|
||||
command,
|
||||
intent.recordDigest,
|
||||
);
|
||||
advanceLocalCutoverInstanceHead(
|
||||
command,
|
||||
identity.uid,
|
||||
'legacy_stopped',
|
||||
0,
|
||||
existing.commitmentDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: 'existing' as const,
|
||||
state: 'legacy_stopped' as const,
|
||||
cutoverId: command.request.cutoverId,
|
||||
commitmentDigest: existing.commitmentDigest,
|
||||
});
|
||||
}
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(command.options.dockerSocketPath, identity.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
docker(
|
||||
command,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'update',
|
||||
'--restart',
|
||||
'no',
|
||||
command.request.expectedLegacyContainerId,
|
||||
],
|
||||
30_000,
|
||||
);
|
||||
docker(
|
||||
command,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'stop',
|
||||
'--time',
|
||||
'30',
|
||||
command.request.expectedLegacyContainerId,
|
||||
],
|
||||
45_000,
|
||||
);
|
||||
const stopped = parseStoppedContainer(
|
||||
docker(
|
||||
command,
|
||||
runDocker,
|
||||
['container', 'inspect', command.request.expectedLegacyContainerId],
|
||||
30_000,
|
||||
),
|
||||
command,
|
||||
);
|
||||
const commitment = commitmentRecord(
|
||||
command,
|
||||
intent.recordDigest,
|
||||
stopped.identityDigest,
|
||||
stopped.sourceBindingDigest,
|
||||
);
|
||||
const commitmentContents = `${JSON.stringify(commitment, null, 2)}\n`;
|
||||
preflightPublishedFile(
|
||||
commitmentPath,
|
||||
commitmentContents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy silence commitment',
|
||||
);
|
||||
publishExactFile(
|
||||
commitmentPath,
|
||||
commitmentContents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy silence commitment',
|
||||
);
|
||||
advanceLocalCutoverInstanceHead(
|
||||
command,
|
||||
identity.uid,
|
||||
'legacy_stopped',
|
||||
0,
|
||||
commitment.commitmentDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: 'prepared' as const,
|
||||
state: 'legacy_stopped' as const,
|
||||
cutoverId: command.request.cutoverId,
|
||||
commitmentDigest: commitment.commitmentDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function stopLegacyDockerForLocalDeploymentCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalDeploymentLegacyStopResult> {
|
||||
return stopLegacyDockerForLocalDeployment(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
);
|
||||
}
|
||||
+610
@@ -0,0 +1,610 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../../foundation/docker';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../../foundation/files';
|
||||
import {
|
||||
authorizeResolvedLocalCutoverInstance,
|
||||
localCutoverInstanceDirectory,
|
||||
readLocalCutoverInstanceHead,
|
||||
type LocalCutoverInstanceHead,
|
||||
type LocalCutoverIdentity,
|
||||
} from '../instanceLineage';
|
||||
import {
|
||||
EMPTY_RESOLUTION_DIGEST,
|
||||
normalizeLocalDeploymentCutoverManualCommand,
|
||||
type LocalDeploymentCutoverManualCommand,
|
||||
} from './manualResolutionContract';
|
||||
import { cutoverDigest } from '../targetEvidence';
|
||||
|
||||
const PREPARATION_SCHEMA = 'qinglong3-local-cutover-manual-resolution';
|
||||
const JOURNAL_SCHEMA = 'qinglong3-local-cutover-journal-record';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_JOURNAL_FILES = 64;
|
||||
|
||||
export type LocalDeploymentCutoverObservationState =
|
||||
| 'stopped'
|
||||
| 'running'
|
||||
| 'unknown';
|
||||
|
||||
export interface LocalDeploymentCutoverManualResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentCutoverManualCommand['operation'];
|
||||
readonly status: 'prepared' | 'existing' | 'observed';
|
||||
readonly state:
|
||||
| 'manual_diagnosed'
|
||||
| 'resolution_prepared'
|
||||
| 'resolution_authorized';
|
||||
readonly currentCutoverId: string;
|
||||
readonly nextCutoverId: string;
|
||||
readonly legacyState?: LocalDeploymentCutoverObservationState;
|
||||
readonly targetState?: LocalDeploymentCutoverObservationState;
|
||||
readonly legacyObservationDigest?: string;
|
||||
readonly targetObservationDigest?: string;
|
||||
readonly preparationDigest?: string;
|
||||
readonly instanceHeadDigest?: string;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentCutoverManualDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
}
|
||||
|
||||
interface ContainerObservation {
|
||||
readonly state: LocalDeploymentCutoverObservationState;
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
interface ResolutionPreparation {
|
||||
readonly schema: typeof PREPARATION_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'resolution_prepared';
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly currentCutoverId: string;
|
||||
readonly nextCutoverId: string;
|
||||
readonly currentActivationDigest: string;
|
||||
readonly nextActivationDigest: string;
|
||||
readonly expectedInstanceHeadDigest: string;
|
||||
readonly expectedManualRecordDigest: string;
|
||||
readonly expectedLegacyContainerId: string;
|
||||
readonly expectedTargetContainerId: string;
|
||||
readonly legacyObservationDigest: string;
|
||||
readonly targetObservationDigest: string;
|
||||
readonly requestedAtMs: number;
|
||||
readonly preparationDigest: string;
|
||||
}
|
||||
|
||||
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 currentIdentityFor(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
): Readonly<LocalCutoverIdentity> {
|
||||
return Object.freeze({
|
||||
options: Object.freeze({ deploymentRoot: command.options.deploymentRoot }),
|
||||
request: Object.freeze({
|
||||
cutoverId: command.request.currentCutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
expectedActivationDigest: command.request.currentActivationDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function nextIdentityFor(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
): Readonly<LocalCutoverIdentity> {
|
||||
return Object.freeze({
|
||||
options: Object.freeze({ deploymentRoot: command.options.deploymentRoot }),
|
||||
request: Object.freeze({
|
||||
cutoverId: command.request.nextCutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
expectedActivationDigest: command.request.nextActivationDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function manualHead(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
uid: number,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
head.state !== 'manual_required' ||
|
||||
head.profile !== command.request.profile ||
|
||||
head.cutoverId !== command.request.currentCutoverId ||
|
||||
head.activationDigest !== command.request.currentActivationDigest ||
|
||||
head.headDigest !== command.request.expectedInstanceHeadDigest ||
|
||||
head.sourceRecordDigest !== command.request.expectedManualRecordDigest
|
||||
) {
|
||||
configurationError('manual resolution does not match the instance head');
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
function verifyManualJournalRecord(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
): void {
|
||||
const journal = path.join(
|
||||
command.options.deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
command.request.currentCutoverId,
|
||||
);
|
||||
validatePrivateDirectory(journal, currentIdentity().uid, 'cutoverJournal');
|
||||
const entries = fs.readdirSync(journal, { withFileTypes: true });
|
||||
if (entries.length > MAX_JOURNAL_FILES) {
|
||||
configurationError('cutover journal retention limit is exceeded');
|
||||
}
|
||||
let matched = false;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) {
|
||||
configurationError('cutover journal contains drift');
|
||||
}
|
||||
const value = readPrivateLocalCommandFile(path.join(journal, entry.name));
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(value as Record<string, unknown>).recordDigest !==
|
||||
command.request.expectedManualRecordDigest
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const record = object(value, 'manual-required journal record');
|
||||
const { recordDigest, ...payload } = record;
|
||||
if (
|
||||
record.schema !== JOURNAL_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.state !== 'manual_required' ||
|
||||
record.cutoverId !== command.request.currentCutoverId ||
|
||||
record.profile !== command.request.profile ||
|
||||
record.instanceId !== command.request.instanceId ||
|
||||
record.activationDigest !== command.request.currentActivationDigest ||
|
||||
record.generation !== head.generation ||
|
||||
typeof recordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(recordDigest) ||
|
||||
cutoverDigest(payload) !== recordDigest
|
||||
) {
|
||||
configurationError('manual-required journal record drifted');
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
if (!matched)
|
||||
configurationError('manual-required journal record is unavailable');
|
||||
}
|
||||
|
||||
function parseContainerObservation(
|
||||
output: string,
|
||||
expectedContainerId: string,
|
||||
): Readonly<ContainerObservation> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (error) {
|
||||
configurationError('container inspection is invalid', error);
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
||||
configurationError('container inspection count is invalid');
|
||||
}
|
||||
const container = object(parsed[0], 'container');
|
||||
const state = object(container.State, 'container state');
|
||||
const hostConfig = object(container.HostConfig, 'container host config');
|
||||
const restartPolicy = object(
|
||||
hostConfig.RestartPolicy,
|
||||
'container restart policy',
|
||||
);
|
||||
const config = object(container.Config, 'container config');
|
||||
if (
|
||||
container.Id !== expectedContainerId ||
|
||||
typeof container.Created !== 'string' ||
|
||||
container.Created.length < 1 ||
|
||||
container.Created.length > 128 ||
|
||||
typeof container.Name !== 'string' ||
|
||||
container.Name.length < 2 ||
|
||||
container.Name.length > 256 ||
|
||||
typeof config.Image !== 'string' ||
|
||||
config.Image.length < 1 ||
|
||||
config.Image.length > 512 ||
|
||||
(restartPolicy.Name !== '' && restartPolicy.Name !== 'no')
|
||||
) {
|
||||
configurationError('container inspection identity is invalid');
|
||||
}
|
||||
const stopped =
|
||||
state.Running === false &&
|
||||
state.Restarting === false &&
|
||||
state.Paused === false &&
|
||||
state.Pid === 0 &&
|
||||
(state.Status === 'exited' || state.Status === 'dead');
|
||||
const running =
|
||||
state.Running === true &&
|
||||
state.Restarting === false &&
|
||||
state.Paused === false &&
|
||||
Number.isSafeInteger(state.Pid) &&
|
||||
(state.Pid as number) > 0 &&
|
||||
state.Status === 'running';
|
||||
if (!stopped && !running) {
|
||||
configurationError('container state is ambiguous');
|
||||
}
|
||||
const observationState = stopped ? 'stopped' : 'running';
|
||||
return Object.freeze({
|
||||
state: observationState,
|
||||
digest: cutoverDigest({
|
||||
containerId: container.Id,
|
||||
created: container.Created,
|
||||
image: config.Image,
|
||||
name: container.Name,
|
||||
state: observationState,
|
||||
restartPolicy: restartPolicy.Name,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function inspectContainer(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
containerId: string,
|
||||
): Readonly<ContainerObservation> {
|
||||
try {
|
||||
return parseContainerObservation(
|
||||
runDocker({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
args: ['container', 'inspect', containerId],
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
containerId,
|
||||
);
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
state: 'unknown' as const,
|
||||
digest: cutoverDigest({ containerId, state: 'unknown' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function observations(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
dependencies: LocalDeploymentCutoverManualDependencies,
|
||||
uid: number,
|
||||
): Readonly<{ legacy: ContainerObservation; target: ContainerObservation }> {
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(command.options.dockerSocketPath, uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
return Object.freeze({
|
||||
legacy: inspectContainer(
|
||||
command,
|
||||
runDocker,
|
||||
command.request.expectedLegacyContainerId,
|
||||
),
|
||||
target: inspectContainer(
|
||||
command,
|
||||
runDocker,
|
||||
command.request.expectedTargetContainerId,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function preparationPath(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
): string {
|
||||
return path.join(
|
||||
command.options.deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
command.request.currentCutoverId,
|
||||
`manual-resolution-${cutoverDigest(command.request.nextCutoverId).slice(
|
||||
0,
|
||||
32,
|
||||
)}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function preparationRecord(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
observation: Readonly<{
|
||||
legacy: ContainerObservation;
|
||||
target: ContainerObservation;
|
||||
}>,
|
||||
): Readonly<ResolutionPreparation> {
|
||||
const payload = Object.freeze({
|
||||
schema: PREPARATION_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'resolution_prepared' as const,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
currentCutoverId: command.request.currentCutoverId,
|
||||
nextCutoverId: command.request.nextCutoverId,
|
||||
currentActivationDigest: command.request.currentActivationDigest,
|
||||
nextActivationDigest: command.request.nextActivationDigest,
|
||||
expectedInstanceHeadDigest: command.request.expectedInstanceHeadDigest,
|
||||
expectedManualRecordDigest: command.request.expectedManualRecordDigest,
|
||||
expectedLegacyContainerId: command.request.expectedLegacyContainerId,
|
||||
expectedTargetContainerId: command.request.expectedTargetContainerId,
|
||||
legacyObservationDigest: observation.legacy.digest,
|
||||
targetObservationDigest: observation.target.digest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
preparationDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function parsePreparation(
|
||||
value: unknown,
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
): Readonly<ResolutionPreparation> {
|
||||
const record = object(value, 'manual resolution preparation');
|
||||
exact(
|
||||
record,
|
||||
[
|
||||
'currentActivationDigest',
|
||||
'currentCutoverId',
|
||||
'expectedInstanceHeadDigest',
|
||||
'expectedLegacyContainerId',
|
||||
'expectedManualRecordDigest',
|
||||
'expectedTargetContainerId',
|
||||
'instanceId',
|
||||
'legacyObservationDigest',
|
||||
'nextActivationDigest',
|
||||
'nextCutoverId',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
'targetObservationDigest',
|
||||
],
|
||||
'manual resolution preparation',
|
||||
);
|
||||
const { preparationDigest, ...payload } = record;
|
||||
if (
|
||||
record.schema !== PREPARATION_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.state !== 'resolution_prepared' ||
|
||||
record.profile !== command.request.profile ||
|
||||
record.instanceId !== command.request.instanceId ||
|
||||
record.currentCutoverId !== command.request.currentCutoverId ||
|
||||
record.nextCutoverId !== command.request.nextCutoverId ||
|
||||
record.currentActivationDigest !==
|
||||
command.request.currentActivationDigest ||
|
||||
record.nextActivationDigest !== command.request.nextActivationDigest ||
|
||||
record.expectedInstanceHeadDigest !==
|
||||
command.request.expectedInstanceHeadDigest ||
|
||||
record.expectedManualRecordDigest !==
|
||||
command.request.expectedManualRecordDigest ||
|
||||
record.expectedLegacyContainerId !==
|
||||
command.request.expectedLegacyContainerId ||
|
||||
record.expectedTargetContainerId !==
|
||||
command.request.expectedTargetContainerId ||
|
||||
typeof record.legacyObservationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.legacyObservationDigest) ||
|
||||
typeof record.targetObservationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.targetObservationDigest) ||
|
||||
typeof preparationDigest !== 'string' ||
|
||||
preparationDigest !== command.request.expectedPreparationDigest ||
|
||||
cutoverDigest(payload) !== preparationDigest
|
||||
) {
|
||||
configurationError('manual resolution preparation drifted');
|
||||
}
|
||||
return record as unknown as Readonly<ResolutionPreparation>;
|
||||
}
|
||||
|
||||
function baseResult(command: Readonly<LocalDeploymentCutoverManualCommand>) {
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
currentCutoverId: command.request.currentCutoverId,
|
||||
nextCutoverId: command.request.nextCutoverId,
|
||||
};
|
||||
}
|
||||
|
||||
export function runLocalDeploymentCutoverManualCommand(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentCutoverManualDependencies = {},
|
||||
): Readonly<LocalDeploymentCutoverManualResult> {
|
||||
const command = normalizeLocalDeploymentCutoverManualCommand(input);
|
||||
const identity = currentIdentity();
|
||||
validatePrivateDirectory(
|
||||
localCutoverInstanceDirectory(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
),
|
||||
identity.uid,
|
||||
'cutoverInstanceDirectory',
|
||||
);
|
||||
const currentHead = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
if (
|
||||
command.operation === 'local.deployment.cutover.manual-resolution-commit' &&
|
||||
currentHead.state === 'resolution_authorized' &&
|
||||
currentHead.cutoverId === command.request.nextCutoverId &&
|
||||
currentHead.activationDigest === command.request.nextActivationDigest &&
|
||||
currentHead.previousHeadDigest ===
|
||||
command.request.expectedInstanceHeadDigest &&
|
||||
currentHead.sourceRecordDigest === command.request.expectedPreparationDigest
|
||||
) {
|
||||
return Object.freeze({
|
||||
...baseResult(command),
|
||||
status: 'existing' as const,
|
||||
state: 'resolution_authorized' as const,
|
||||
preparationDigest: command.request.expectedPreparationDigest,
|
||||
instanceHeadDigest: currentHead.headDigest,
|
||||
});
|
||||
}
|
||||
const head = manualHead(command, identity.uid);
|
||||
verifyManualJournalRecord(command, head);
|
||||
const observed = observations(command, dependencies, identity.uid);
|
||||
if (command.operation === 'local.deployment.cutover.manual-diagnose') {
|
||||
return Object.freeze({
|
||||
...baseResult(command),
|
||||
status: 'observed' as const,
|
||||
state: 'manual_diagnosed' as const,
|
||||
legacyState: observed.legacy.state,
|
||||
targetState: observed.target.state,
|
||||
legacyObservationDigest: observed.legacy.digest,
|
||||
targetObservationDigest: observed.target.digest,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
if (
|
||||
observed.legacy.state !== 'stopped' ||
|
||||
observed.target.state !== 'stopped'
|
||||
) {
|
||||
configurationError(
|
||||
'manual resolution requires both legacy and target to be proved stopped',
|
||||
);
|
||||
}
|
||||
if (
|
||||
command.operation === 'local.deployment.cutover.manual-resolution-prepare'
|
||||
) {
|
||||
const preparation = preparationRecord(command, observed);
|
||||
const serialized = `${JSON.stringify(preparation, null, 2)}\n`;
|
||||
const filePath = preparationPath(command);
|
||||
const preparationStagePath = path.join(
|
||||
path.dirname(filePath),
|
||||
`.${path.basename(filePath)}.ql3-deploy-stage`,
|
||||
);
|
||||
if (
|
||||
fs.readdirSync(path.dirname(filePath)).length >= MAX_JOURNAL_FILES &&
|
||||
!fs.existsSync(filePath) &&
|
||||
!fs.existsSync(preparationStagePath)
|
||||
) {
|
||||
configurationError('cutover journal retention limit is reached');
|
||||
}
|
||||
preflightPublishedFile(
|
||||
filePath,
|
||||
serialized,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'manual resolution preparation',
|
||||
);
|
||||
const status = publishExactFile(
|
||||
filePath,
|
||||
serialized,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'manual resolution preparation',
|
||||
);
|
||||
return Object.freeze({
|
||||
...baseResult(command),
|
||||
status,
|
||||
state: 'resolution_prepared' as const,
|
||||
legacyState: observed.legacy.state,
|
||||
targetState: observed.target.state,
|
||||
legacyObservationDigest: observed.legacy.digest,
|
||||
targetObservationDigest: observed.target.digest,
|
||||
preparationDigest: preparation.preparationDigest,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
const preparation = parsePreparation(
|
||||
readPrivateLocalCommandFile(preparationPath(command)),
|
||||
command,
|
||||
);
|
||||
if (
|
||||
observed.legacy.digest !== preparation.legacyObservationDigest ||
|
||||
observed.target.digest !== preparation.targetObservationDigest
|
||||
) {
|
||||
configurationError(
|
||||
'manual resolution container evidence drifted after prepare',
|
||||
);
|
||||
}
|
||||
const nextHead = authorizeResolvedLocalCutoverInstance(
|
||||
currentIdentityFor(command),
|
||||
nextIdentityFor(command),
|
||||
identity.uid,
|
||||
command.request.expectedInstanceHeadDigest,
|
||||
preparation.preparationDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
...baseResult(command),
|
||||
status: 'prepared' as const,
|
||||
state: 'resolution_authorized' as const,
|
||||
legacyState: observed.legacy.state,
|
||||
targetState: observed.target.state,
|
||||
legacyObservationDigest: observed.legacy.digest,
|
||||
targetObservationDigest: observed.target.digest,
|
||||
preparationDigest: preparation.preparationDigest,
|
||||
instanceHeadDigest: nextHead.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalDeploymentCutoverManualCommandFile(
|
||||
filePath: string,
|
||||
expectedOperation?: LocalDeploymentCutoverManualCommand['operation'],
|
||||
): Readonly<LocalDeploymentCutoverManualResult> {
|
||||
const input = readPrivateLocalCommandFile(filePath);
|
||||
if (
|
||||
expectedOperation !== undefined &&
|
||||
(!input ||
|
||||
typeof input !== 'object' ||
|
||||
Array.isArray(input) ||
|
||||
(input as Record<string, unknown>).operation !== expectedOperation)
|
||||
) {
|
||||
configurationError(
|
||||
'manual cutover command does not match the CLI operation',
|
||||
);
|
||||
}
|
||||
return runLocalDeploymentCutoverManualCommand(input);
|
||||
}
|
||||
|
||||
export { EMPTY_RESOLUTION_DIGEST };
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../../foundation/contract';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/;
|
||||
export const EMPTY_RESOLUTION_DIGEST = '0'.repeat(64);
|
||||
|
||||
export type LocalDeploymentCutoverManualOperation =
|
||||
| 'local.deployment.cutover.manual-diagnose'
|
||||
| 'local.deployment.cutover.manual-resolution-prepare'
|
||||
| 'local.deployment.cutover.manual-resolution-commit';
|
||||
|
||||
export interface LocalDeploymentCutoverManualCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentCutoverManualOperation;
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
dockerExecutable: string;
|
||||
dockerSocketPath: string;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
profile: 'edge' | 'standalone';
|
||||
instanceId: string;
|
||||
currentCutoverId: string;
|
||||
nextCutoverId: string;
|
||||
currentActivationDigest: string;
|
||||
nextActivationDigest: string;
|
||||
expectedInstanceHeadDigest: string;
|
||||
expectedManualRecordDigest: string;
|
||||
expectedLegacyContainerId: string;
|
||||
expectedTargetContainerId: string;
|
||||
expectedPreparationDigest: string;
|
||||
requestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${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])
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${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
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} must be a supervisor-safe normalized absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function trustedExecutable(value: unknown, uid: number): string {
|
||||
const filePath = safeAbsolutePath(value, 'dockerExecutable');
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable is unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
(stat.uid !== 0 && stat.uid !== uid) ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(stat.mode & 0o111) === 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable must be a canonical trusted executable',
|
||||
);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function normalizeLocalDeploymentCutoverManualCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentCutoverManualCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
(command.operation !== 'local.deployment.cutover.manual-diagnose' &&
|
||||
command.operation !==
|
||||
'local.deployment.cutover.manual-resolution-prepare' &&
|
||||
command.operation !== 'local.deployment.cutover.manual-resolution-commit')
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'schemaVersion or operation is invalid',
|
||||
);
|
||||
}
|
||||
const identity = currentIdentity();
|
||||
const options = object(command.options, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'deploymentRoot',
|
||||
'dockerExecutable',
|
||||
'dockerSocketPath',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'allowRootService does not match the current identity',
|
||||
);
|
||||
}
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'currentActivationDigest',
|
||||
'currentCutoverId',
|
||||
'expectedInstanceHeadDigest',
|
||||
'expectedLegacyContainerId',
|
||||
'expectedManualRecordDigest',
|
||||
'expectedPreparationDigest',
|
||||
'expectedTargetContainerId',
|
||||
'instanceId',
|
||||
'nextActivationDigest',
|
||||
'nextCutoverId',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
(request.profile !== 'edge' && request.profile !== 'standalone') ||
|
||||
typeof request.instanceId !== 'string' ||
|
||||
!INSTANCE_ID_PATTERN.test(request.instanceId) ||
|
||||
typeof request.currentCutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(request.currentCutoverId) ||
|
||||
typeof request.nextCutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(request.nextCutoverId) ||
|
||||
request.nextCutoverId === request.currentCutoverId ||
|
||||
typeof request.currentActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.currentActivationDigest) ||
|
||||
typeof request.nextActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.nextActivationDigest) ||
|
||||
typeof request.expectedInstanceHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedInstanceHeadDigest) ||
|
||||
typeof request.expectedManualRecordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedManualRecordDigest) ||
|
||||
typeof request.expectedLegacyContainerId !== 'string' ||
|
||||
!CONTAINER_ID_PATTERN.test(request.expectedLegacyContainerId) ||
|
||||
typeof request.expectedTargetContainerId !== 'string' ||
|
||||
!CONTAINER_ID_PATTERN.test(request.expectedTargetContainerId) ||
|
||||
request.expectedTargetContainerId === request.expectedLegacyContainerId ||
|
||||
typeof request.expectedPreparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedPreparationDigest) ||
|
||||
(command.operation ===
|
||||
'local.deployment.cutover.manual-resolution-commit') ===
|
||||
(request.expectedPreparationDigest === EMPTY_RESOLUTION_DIGEST) ||
|
||||
!Number.isSafeInteger(request.requestedAtMs) ||
|
||||
(request.requestedAtMs as number) < 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'manual cutover request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
dockerExecutable: trustedExecutable(
|
||||
options.dockerExecutable,
|
||||
identity.uid,
|
||||
),
|
||||
dockerSocketPath: safeAbsolutePath(
|
||||
options.dockerSocketPath,
|
||||
'dockerSocketPath',
|
||||
),
|
||||
allowRootService: options.allowRootService,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
profile: request.profile,
|
||||
instanceId: request.instanceId,
|
||||
currentCutoverId: request.currentCutoverId,
|
||||
nextCutoverId: request.nextCutoverId,
|
||||
currentActivationDigest: request.currentActivationDigest,
|
||||
nextActivationDigest: request.nextActivationDigest,
|
||||
expectedInstanceHeadDigest: request.expectedInstanceHeadDigest,
|
||||
expectedManualRecordDigest: request.expectedManualRecordDigest,
|
||||
expectedLegacyContainerId: request.expectedLegacyContainerId,
|
||||
expectedTargetContainerId: request.expectedTargetContainerId,
|
||||
expectedPreparationDigest: request.expectedPreparationDigest,
|
||||
requestedAtMs: request.requestedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../../foundation/docker';
|
||||
import { validatePrivateDirectory } from '../../foundation/files';
|
||||
import {
|
||||
legacyCommitmentPath,
|
||||
parseStoppedLegacyEvidence,
|
||||
parseTargetContainerEvidence,
|
||||
readLegacySilenceEvidence,
|
||||
readTargetApplicationBinding,
|
||||
readTargetStartupReceipt,
|
||||
verifyTargetRunActivation,
|
||||
type LegacySilenceEvidence,
|
||||
type TargetApplicationBinding,
|
||||
type TargetContainerEvidence,
|
||||
} from '../targetEvidence';
|
||||
import {
|
||||
normalizeLocalDeploymentTargetRunCommand,
|
||||
type LocalDeploymentTargetRunCommand,
|
||||
type LocalDeploymentTargetRunResult,
|
||||
} from './targetRunContract';
|
||||
import {
|
||||
publishTargetRunJournalRecord as publishRecord,
|
||||
readTargetRunJournalRecord as readRecord,
|
||||
targetRunJournalRecord as journalRecord,
|
||||
targetRunManualEvidence as manualEvidence,
|
||||
targetRunPhasePath as phasePath,
|
||||
targetRunSequence as sequence,
|
||||
verifyTargetRunManualEvidence as verifyManualEvidence,
|
||||
type TargetRunJournalRecord,
|
||||
type TargetRunManualReason as ManualReason,
|
||||
} from './targetRunJournal';
|
||||
import {
|
||||
targetActiveEvidence as activeEvidence,
|
||||
targetRequestEvidence as requestEvidence,
|
||||
verifyTargetActiveEvidence as verifyActiveEvidence,
|
||||
verifyTargetRequestEvidence as verifyRequestEvidence,
|
||||
} from './targetRunRecordEvidence';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
assertLocalCutoverTargetHead,
|
||||
} from '../instanceLineage';
|
||||
|
||||
export interface LocalDeploymentTargetRunDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
readonly now?: () => number;
|
||||
readonly wait?: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
interface RunContext {
|
||||
readonly command: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly journal: string;
|
||||
readonly commitment: Readonly<LegacySilenceEvidence>;
|
||||
readonly application: Readonly<TargetApplicationBinding>;
|
||||
readonly uid: number;
|
||||
}
|
||||
|
||||
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 result(
|
||||
context: Readonly<RunContext>,
|
||||
status: 'prepared' | 'existing',
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
): Readonly<LocalDeploymentTargetRunResult> {
|
||||
advanceLocalCutoverInstanceHead(
|
||||
context.command,
|
||||
context.uid,
|
||||
record.state as 'target_active' | 'manual_required',
|
||||
record.generation,
|
||||
record.recordDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: context.command.operation,
|
||||
status,
|
||||
state: record.state as 'target_active' | 'manual_required',
|
||||
cutoverId: context.command.request.cutoverId,
|
||||
generation: context.command.request.generation,
|
||||
recordDigest: record.recordDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function publishManual(
|
||||
context: Readonly<RunContext>,
|
||||
filePath: string,
|
||||
recordSequence: number,
|
||||
previousRecordDigest: string,
|
||||
reason: ManualReason,
|
||||
): Readonly<LocalDeploymentTargetRunResult> {
|
||||
const record = journalRecord(
|
||||
context.command,
|
||||
recordSequence,
|
||||
'manual_required',
|
||||
previousRecordDigest,
|
||||
manualEvidence(reason),
|
||||
);
|
||||
const status = publishRecord(
|
||||
context,
|
||||
filePath,
|
||||
record,
|
||||
'target cutover manual resolution',
|
||||
);
|
||||
return result(context, status, record);
|
||||
}
|
||||
|
||||
function docker(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
args: readonly string[],
|
||||
timeoutMs = 30_000,
|
||||
): string {
|
||||
return runDocker({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
args,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
function inspectContainer(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
containerId: string,
|
||||
): string {
|
||||
return docker(command, runDocker, ['container', 'inspect', containerId]);
|
||||
}
|
||||
|
||||
function readPriorActive(
|
||||
context: Readonly<RunContext>,
|
||||
): Readonly<{ startupReceiptDigest: string; recordDigest: string }> {
|
||||
const generation = context.command.request.generation - 1;
|
||||
const request = readRecord(
|
||||
phasePath(context.journal, generation, 'request'),
|
||||
context,
|
||||
{
|
||||
sequence: sequence(generation, 'request'),
|
||||
generation,
|
||||
states: [
|
||||
generation === 1
|
||||
? 'target_start_requested'
|
||||
: 'target_restart_requested',
|
||||
],
|
||||
},
|
||||
);
|
||||
const requestEvidence = verifyRequestEvidence(
|
||||
{
|
||||
...context,
|
||||
command: Object.freeze({
|
||||
...context.command,
|
||||
operation:
|
||||
generation === 1
|
||||
? ('local.deployment.cutover.target-start' as const)
|
||||
: ('local.deployment.cutover.target-restart' as const),
|
||||
request: Object.freeze({ ...context.command.request, generation }),
|
||||
}),
|
||||
},
|
||||
request,
|
||||
);
|
||||
const active = readRecord(
|
||||
phasePath(context.journal, generation, 'outcome'),
|
||||
context,
|
||||
{
|
||||
sequence: sequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_active'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
},
|
||||
);
|
||||
const startupReceiptDigest = verifyActiveEvidence(
|
||||
{
|
||||
...context,
|
||||
command: Object.freeze({
|
||||
...context.command,
|
||||
operation:
|
||||
generation === 1
|
||||
? ('local.deployment.cutover.target-start' as const)
|
||||
: ('local.deployment.cutover.target-restart' as const),
|
||||
request: Object.freeze({ ...context.command.request, generation }),
|
||||
}),
|
||||
},
|
||||
active,
|
||||
requestEvidence,
|
||||
);
|
||||
return Object.freeze({
|
||||
startupReceiptDigest,
|
||||
recordDigest: active.recordDigest,
|
||||
});
|
||||
}
|
||||
|
||||
async function observeActiveTarget(
|
||||
context: Readonly<RunContext>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
request: ReturnType<typeof verifyRequestEvidence>,
|
||||
now: () => number,
|
||||
wait: (milliseconds: number) => Promise<void>,
|
||||
): Promise<
|
||||
| Readonly<{
|
||||
target: Readonly<TargetContainerEvidence>;
|
||||
startupReceiptDigest: string;
|
||||
}>
|
||||
| undefined
|
||||
> {
|
||||
const timeoutMs =
|
||||
context.command.request.profile === 'edge' ? 30_000 : 60_000;
|
||||
const maximumAttempts =
|
||||
context.command.request.profile === 'edge' ? 120 : 240;
|
||||
const deadline = now() + timeoutMs;
|
||||
for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
|
||||
try {
|
||||
const target = parseTargetContainerEvidence(
|
||||
inspectContainer(
|
||||
context.command,
|
||||
runDocker,
|
||||
context.command.request.expectedTargetContainerId,
|
||||
),
|
||||
context.command,
|
||||
context.application,
|
||||
'active',
|
||||
);
|
||||
const receipt = readTargetStartupReceipt(context.command);
|
||||
if (
|
||||
target.identityDigest === request.targetContainerIdentityDigest &&
|
||||
target.applicationBindingDigest ===
|
||||
request.targetApplicationBindingDigest &&
|
||||
receipt !== null &&
|
||||
receipt.digest !== request.previousStartupReceiptDigest
|
||||
) {
|
||||
return Object.freeze({
|
||||
target,
|
||||
startupReceiptDigest: receipt.digest,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// The bounded inspection-only window resolves all unknown start results.
|
||||
}
|
||||
if (now() >= deadline) return undefined;
|
||||
await wait(250);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function replayCurrentTerminal(
|
||||
context: Readonly<RunContext>,
|
||||
previousRecordDigest: string,
|
||||
): Readonly<LocalDeploymentTargetRunResult> | undefined {
|
||||
const generation = context.command.request.generation;
|
||||
const requestPath = phasePath(context.journal, generation, 'request');
|
||||
if (!fs.existsSync(requestPath)) return undefined;
|
||||
const request = readRecord(requestPath, context, {
|
||||
sequence: sequence(generation, 'request'),
|
||||
generation,
|
||||
states: [
|
||||
generation === 1 ? 'target_start_requested' : 'target_restart_requested',
|
||||
'manual_required',
|
||||
],
|
||||
previousRecordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
if (request.state === 'manual_required') {
|
||||
verifyManualEvidence(request);
|
||||
return result(context, 'existing', request);
|
||||
}
|
||||
const requestBinding = verifyRequestEvidence(context, request);
|
||||
const outcomePath = phasePath(context.journal, generation, 'outcome');
|
||||
if (!fs.existsSync(outcomePath)) return undefined;
|
||||
const outcome = readRecord(outcomePath, context, {
|
||||
sequence: sequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_active', 'manual_required'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
if (outcome.state === 'manual_required') verifyManualEvidence(outcome);
|
||||
else verifyActiveEvidence(context, outcome, requestBinding);
|
||||
return result(context, 'existing', outcome);
|
||||
}
|
||||
|
||||
function restartRecheckPrefix(
|
||||
context: Readonly<RunContext>,
|
||||
previousActiveDigest: string,
|
||||
):
|
||||
| Readonly<{
|
||||
reverified: Readonly<TargetRunJournalRecord>;
|
||||
terminal?: Readonly<LocalDeploymentTargetRunResult>;
|
||||
}>
|
||||
| undefined {
|
||||
const generation = context.command.request.generation;
|
||||
const recheckPath = phasePath(context.journal, generation, 'recheck');
|
||||
if (!fs.existsSync(recheckPath)) return undefined;
|
||||
const recheck = readRecord(recheckPath, context, {
|
||||
sequence: sequence(generation, 'recheck'),
|
||||
generation,
|
||||
states: ['legacy_recheck_requested'],
|
||||
previousRecordDigest: previousActiveDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
const evidence = object(recheck.evidence, 'legacy recheck evidence');
|
||||
exact(
|
||||
evidence,
|
||||
['legacyCommitmentDigest', 'legacyContainerId'],
|
||||
'legacy recheck evidence',
|
||||
);
|
||||
if (
|
||||
evidence.legacyCommitmentDigest !== context.commitment.commitmentDigest ||
|
||||
evidence.legacyContainerId !==
|
||||
context.command.request.expectedLegacyContainerId
|
||||
) {
|
||||
configurationError('legacy recheck evidence drifted');
|
||||
}
|
||||
const verifiedPath = phasePath(context.journal, generation, 'verified');
|
||||
if (!fs.existsSync(verifiedPath)) return undefined;
|
||||
const verified = readRecord(verifiedPath, context, {
|
||||
sequence: sequence(generation, 'verified'),
|
||||
generation,
|
||||
states: ['legacy_reverified', 'manual_required'],
|
||||
previousRecordDigest: recheck.recordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
if (verified.state === 'manual_required') {
|
||||
verifyManualEvidence(verified);
|
||||
return Object.freeze({
|
||||
reverified: verified,
|
||||
terminal: result(context, 'existing', verified),
|
||||
});
|
||||
}
|
||||
const verifiedEvidence = object(
|
||||
verified.evidence,
|
||||
'legacy reverified evidence',
|
||||
);
|
||||
exact(
|
||||
verifiedEvidence,
|
||||
[
|
||||
'legacyCommitmentDigest',
|
||||
'legacyContainerIdentityDigest',
|
||||
'legacySourceBindingDigest',
|
||||
],
|
||||
'legacy reverified evidence',
|
||||
);
|
||||
if (
|
||||
verifiedEvidence.legacyCommitmentDigest !==
|
||||
context.commitment.commitmentDigest ||
|
||||
verifiedEvidence.legacyContainerIdentityDigest !==
|
||||
context.commitment.legacyContainerIdentityDigest ||
|
||||
verifiedEvidence.legacySourceBindingDigest !==
|
||||
context.commitment.legacySourceBindingDigest
|
||||
) {
|
||||
configurationError('legacy reverified evidence drifted');
|
||||
}
|
||||
return Object.freeze({ reverified: verified });
|
||||
}
|
||||
|
||||
async function runWithStartBarrier(
|
||||
context: Readonly<RunContext>,
|
||||
previousRecordDigest: string,
|
||||
previousStartupReceiptDigest: string | null,
|
||||
dependencies: Required<
|
||||
Pick<LocalDeploymentTargetRunDependencies, 'runDocker' | 'now' | 'wait'>
|
||||
>,
|
||||
): Promise<Readonly<LocalDeploymentTargetRunResult>> {
|
||||
const generation = context.command.request.generation;
|
||||
const requestPath = phasePath(context.journal, generation, 'request');
|
||||
let request: Readonly<TargetRunJournalRecord>;
|
||||
let requestStatus: 'prepared' | 'existing';
|
||||
if (fs.existsSync(requestPath)) {
|
||||
request = readRecord(requestPath, context, {
|
||||
sequence: sequence(generation, 'request'),
|
||||
generation,
|
||||
states: [
|
||||
generation === 1
|
||||
? 'target_start_requested'
|
||||
: 'target_restart_requested',
|
||||
],
|
||||
previousRecordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
requestStatus = 'existing';
|
||||
} else {
|
||||
let target: Readonly<TargetContainerEvidence>;
|
||||
try {
|
||||
target = parseTargetContainerEvidence(
|
||||
inspectContainer(
|
||||
context.command,
|
||||
dependencies.runDocker,
|
||||
context.command.request.expectedTargetContainerId,
|
||||
),
|
||||
context.command,
|
||||
context.application,
|
||||
'stopped',
|
||||
);
|
||||
const receipt = readTargetStartupReceipt(context.command);
|
||||
if (
|
||||
(generation === 1 && receipt !== null) ||
|
||||
(generation > 1 && receipt?.digest !== previousStartupReceiptDigest)
|
||||
) {
|
||||
configurationError('target startup receipt preflight is invalid');
|
||||
}
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
requestPath,
|
||||
sequence(generation, 'request'),
|
||||
previousRecordDigest,
|
||||
'target_preflight_unproved',
|
||||
);
|
||||
}
|
||||
request = journalRecord(
|
||||
context.command,
|
||||
sequence(generation, 'request'),
|
||||
generation === 1 ? 'target_start_requested' : 'target_restart_requested',
|
||||
previousRecordDigest,
|
||||
requestEvidence(context, target, previousStartupReceiptDigest),
|
||||
);
|
||||
requestStatus = publishRecord(
|
||||
context,
|
||||
requestPath,
|
||||
request,
|
||||
'target start barrier',
|
||||
);
|
||||
}
|
||||
const requestBinding = verifyRequestEvidence(context, request);
|
||||
const outcomePath = phasePath(context.journal, generation, 'outcome');
|
||||
if (fs.existsSync(outcomePath)) {
|
||||
const existing = replayCurrentTerminal(context, previousRecordDigest);
|
||||
if (existing === undefined) configurationError('target outcome drifted');
|
||||
return existing;
|
||||
}
|
||||
if (requestStatus === 'prepared') {
|
||||
try {
|
||||
const output = docker(
|
||||
context.command,
|
||||
dependencies.runDocker,
|
||||
[
|
||||
'container',
|
||||
'start',
|
||||
context.command.request.expectedTargetContainerId,
|
||||
],
|
||||
45_000,
|
||||
).trim();
|
||||
if (output !== context.command.request.expectedTargetContainerId) {
|
||||
configurationError('target start response identity is invalid');
|
||||
}
|
||||
} catch {
|
||||
// The durable barrier forbids retry; inspection below is authoritative.
|
||||
}
|
||||
}
|
||||
const observed = await observeActiveTarget(
|
||||
context,
|
||||
dependencies.runDocker,
|
||||
requestBinding,
|
||||
dependencies.now,
|
||||
dependencies.wait,
|
||||
);
|
||||
if (observed === undefined) {
|
||||
return publishManual(
|
||||
context,
|
||||
outcomePath,
|
||||
sequence(generation, 'outcome'),
|
||||
request.recordDigest,
|
||||
generation === 1
|
||||
? 'target_start_result_unproved'
|
||||
: 'target_restart_result_unproved',
|
||||
);
|
||||
}
|
||||
const active = journalRecord(
|
||||
context.command,
|
||||
sequence(generation, 'outcome'),
|
||||
'target_active',
|
||||
request.recordDigest,
|
||||
activeEvidence(context, observed.target, observed.startupReceiptDigest),
|
||||
);
|
||||
publishRecord(context, outcomePath, active, 'target active commitment');
|
||||
return result(context, 'prepared', active);
|
||||
}
|
||||
|
||||
export async function runLocalDeploymentDockerTarget(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentTargetRunDependencies = {},
|
||||
): Promise<Readonly<LocalDeploymentTargetRunResult>> {
|
||||
const command = normalizeLocalDeploymentTargetRunCommand(input);
|
||||
const identity = currentIdentity();
|
||||
const serviceRoot = path.join(command.options.deploymentRoot, 'service');
|
||||
const journal = path.dirname(legacyCommitmentPath(command));
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(serviceRoot, identity.uid, 'serviceDescriptorRoot');
|
||||
validatePrivateDirectory(journal, identity.uid, 'cutoverJournal');
|
||||
verifyTargetRunActivation(command);
|
||||
const commitment = readLegacySilenceEvidence(command);
|
||||
const application = readTargetApplicationBinding(command);
|
||||
const context = Object.freeze({
|
||||
command,
|
||||
journal,
|
||||
commitment,
|
||||
application,
|
||||
uid: identity.uid,
|
||||
});
|
||||
const instanceHead = assertLocalCutoverTargetHead(command, identity.uid);
|
||||
if (
|
||||
instanceHead.state === 'manual_required' &&
|
||||
instanceHead.generation !== command.request.generation
|
||||
) {
|
||||
configurationError('manual-required instance lineage is terminal');
|
||||
}
|
||||
|
||||
let previousRecordDigest = commitment.commitmentDigest;
|
||||
let previousStartupReceiptDigest: string | null = null;
|
||||
let canReplayCurrent = command.request.generation === 1;
|
||||
if (command.request.generation > 1) {
|
||||
const prior = readPriorActive(context);
|
||||
previousRecordDigest = prior.recordDigest;
|
||||
previousStartupReceiptDigest = prior.startupReceiptDigest;
|
||||
const prefix = restartRecheckPrefix(context, previousRecordDigest);
|
||||
if (prefix?.terminal !== undefined) return prefix.terminal;
|
||||
if (prefix !== undefined) {
|
||||
previousRecordDigest = prefix.reverified.recordDigest;
|
||||
canReplayCurrent = true;
|
||||
}
|
||||
}
|
||||
const replay = canReplayCurrent
|
||||
? replayCurrentTerminal(context, previousRecordDigest)
|
||||
: undefined;
|
||||
if (replay !== undefined) return replay;
|
||||
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(command.options.dockerSocketPath, identity.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const wait =
|
||||
dependencies.wait ??
|
||||
((milliseconds: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
||||
|
||||
if (command.request.generation > 1) {
|
||||
const recheckPath = phasePath(
|
||||
journal,
|
||||
command.request.generation,
|
||||
'recheck',
|
||||
);
|
||||
let recheck: Readonly<TargetRunJournalRecord>;
|
||||
if (fs.existsSync(recheckPath)) {
|
||||
recheck = readRecord(recheckPath, context, {
|
||||
sequence: sequence(command.request.generation, 'recheck'),
|
||||
generation: command.request.generation,
|
||||
states: ['legacy_recheck_requested'],
|
||||
previousRecordDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
});
|
||||
} else {
|
||||
recheck = journalRecord(
|
||||
command,
|
||||
sequence(command.request.generation, 'recheck'),
|
||||
'legacy_recheck_requested',
|
||||
previousRecordDigest,
|
||||
Object.freeze({
|
||||
legacyCommitmentDigest: commitment.commitmentDigest,
|
||||
legacyContainerId: command.request.expectedLegacyContainerId,
|
||||
}),
|
||||
);
|
||||
publishRecord(context, recheckPath, recheck, 'legacy recheck request');
|
||||
}
|
||||
const verifiedPath = phasePath(
|
||||
journal,
|
||||
command.request.generation,
|
||||
'verified',
|
||||
);
|
||||
let verified: Readonly<TargetRunJournalRecord>;
|
||||
if (fs.existsSync(verifiedPath)) {
|
||||
verified = readRecord(verifiedPath, context, {
|
||||
sequence: sequence(command.request.generation, 'verified'),
|
||||
generation: command.request.generation,
|
||||
states: ['legacy_reverified', 'manual_required'],
|
||||
previousRecordDigest: recheck.recordDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
});
|
||||
if (verified.state === 'manual_required') {
|
||||
verifyManualEvidence(verified);
|
||||
return result(context, 'existing', verified);
|
||||
}
|
||||
} else {
|
||||
let legacy;
|
||||
try {
|
||||
legacy = parseStoppedLegacyEvidence(
|
||||
inspectContainer(
|
||||
command,
|
||||
runDocker,
|
||||
command.request.expectedLegacyContainerId,
|
||||
),
|
||||
command,
|
||||
);
|
||||
if (
|
||||
legacy.identityDigest !== commitment.legacyContainerIdentityDigest ||
|
||||
legacy.sourceBindingDigest !== commitment.legacySourceBindingDigest
|
||||
) {
|
||||
configurationError('legacy silence evidence changed');
|
||||
}
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
verifiedPath,
|
||||
sequence(command.request.generation, 'verified'),
|
||||
recheck.recordDigest,
|
||||
'legacy_silence_unproved',
|
||||
);
|
||||
}
|
||||
verified = journalRecord(
|
||||
command,
|
||||
sequence(command.request.generation, 'verified'),
|
||||
'legacy_reverified',
|
||||
recheck.recordDigest,
|
||||
Object.freeze({
|
||||
legacyCommitmentDigest: commitment.commitmentDigest,
|
||||
legacyContainerIdentityDigest: legacy.identityDigest,
|
||||
legacySourceBindingDigest: legacy.sourceBindingDigest,
|
||||
}),
|
||||
);
|
||||
publishRecord(
|
||||
context,
|
||||
verifiedPath,
|
||||
verified,
|
||||
'legacy reverified commitment',
|
||||
);
|
||||
}
|
||||
previousRecordDigest = verified.recordDigest;
|
||||
}
|
||||
|
||||
return runWithStartBarrier(
|
||||
context,
|
||||
previousRecordDigest,
|
||||
previousStartupReceiptDigest,
|
||||
{ runDocker, now, wait },
|
||||
);
|
||||
}
|
||||
|
||||
export function runLocalDeploymentDockerTargetCommandFile(
|
||||
filePath: string,
|
||||
): Promise<Readonly<LocalDeploymentTargetRunResult>> {
|
||||
return runLocalDeploymentDockerTarget(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
type LocalDeploymentProfile,
|
||||
} from '../../foundation/contract';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const IMAGE_DIGEST_PATTERN =
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/;
|
||||
const MAX_TARGET_GENERATION = 15;
|
||||
|
||||
export type LocalDeploymentTargetRunOperation =
|
||||
| 'local.deployment.cutover.target-start'
|
||||
| 'local.deployment.cutover.target-restart';
|
||||
|
||||
export interface LocalDeploymentTargetRunCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentTargetRunOperation;
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
dockerExecutable: string;
|
||||
dockerSocketPath: string;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
cutoverId: string;
|
||||
profile: LocalDeploymentProfile;
|
||||
instanceId: string;
|
||||
activationPath: string;
|
||||
legacySourcePath: string;
|
||||
targetDatabasePath: string;
|
||||
recoveryPath: string;
|
||||
manifestPath: string;
|
||||
expectedLegacyDatabasePath: string;
|
||||
expectedActivationDigest: string;
|
||||
expectedLegacyCommitmentDigest: string;
|
||||
expectedLegacyContainerId: string;
|
||||
expectedTargetContainerId: string;
|
||||
expectedTargetImage: string;
|
||||
applicationConfigPath: string;
|
||||
expectedTargetApplicationConfigPath: string;
|
||||
expectedTargetCommitmentPath: string;
|
||||
generation: number;
|
||||
requestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentTargetRunResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentTargetRunOperation;
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'target_active' | 'manual_required';
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly recordDigest: string;
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${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])
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${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
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} must be a supervisor-safe normalized absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function trustedExecutable(value: unknown, uid: number): string {
|
||||
const filePath = safeAbsolutePath(value, 'dockerExecutable');
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable is unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
(stat.uid !== 0 && stat.uid !== uid) ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(stat.mode & 0o111) === 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable must be a canonical trusted executable',
|
||||
);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, minimum: number): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function normalizeLocalDeploymentTargetRunCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentTargetRunCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
(command.operation !== 'local.deployment.cutover.target-start' &&
|
||||
command.operation !== 'local.deployment.cutover.target-restart')
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'schemaVersion or operation is invalid',
|
||||
);
|
||||
}
|
||||
const identity = currentIdentity();
|
||||
const options = object(command.options, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'deploymentRoot',
|
||||
'dockerExecutable',
|
||||
'dockerSocketPath',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'allowRootService does not match the current identity',
|
||||
);
|
||||
}
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'activationPath',
|
||||
'applicationConfigPath',
|
||||
'cutoverId',
|
||||
'expectedActivationDigest',
|
||||
'expectedLegacyCommitmentDigest',
|
||||
'expectedLegacyContainerId',
|
||||
'expectedLegacyDatabasePath',
|
||||
'expectedTargetApplicationConfigPath',
|
||||
'expectedTargetCommitmentPath',
|
||||
'expectedTargetContainerId',
|
||||
'expectedTargetImage',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacySourcePath',
|
||||
'manifestPath',
|
||||
'profile',
|
||||
'recoveryPath',
|
||||
'requestedAtMs',
|
||||
'targetDatabasePath',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const generation = integer(request.generation, 'generation', 1);
|
||||
if (
|
||||
typeof request.cutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(request.cutoverId) ||
|
||||
(request.profile !== 'edge' && request.profile !== 'standalone') ||
|
||||
typeof request.instanceId !== 'string' ||
|
||||
!INSTANCE_ID_PATTERN.test(request.instanceId) ||
|
||||
typeof request.expectedActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedActivationDigest) ||
|
||||
typeof request.expectedLegacyCommitmentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedLegacyCommitmentDigest) ||
|
||||
typeof request.expectedLegacyContainerId !== 'string' ||
|
||||
!CONTAINER_ID_PATTERN.test(request.expectedLegacyContainerId) ||
|
||||
typeof request.expectedTargetContainerId !== 'string' ||
|
||||
!CONTAINER_ID_PATTERN.test(request.expectedTargetContainerId) ||
|
||||
request.expectedTargetContainerId === request.expectedLegacyContainerId ||
|
||||
typeof request.expectedTargetImage !== 'string' ||
|
||||
!IMAGE_DIGEST_PATTERN.test(request.expectedTargetImage) ||
|
||||
generation > MAX_TARGET_GENERATION ||
|
||||
(command.operation === 'local.deployment.cutover.target-start' &&
|
||||
generation !== 1) ||
|
||||
(command.operation === 'local.deployment.cutover.target-restart' &&
|
||||
generation < 2)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'target run request identity is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
new Set([
|
||||
request.activationPath,
|
||||
request.legacySourcePath,
|
||||
request.targetDatabasePath,
|
||||
request.recoveryPath,
|
||||
request.manifestPath,
|
||||
request.applicationConfigPath,
|
||||
]).size !== 6
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'target run authority paths must be distinct',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
dockerExecutable: trustedExecutable(
|
||||
options.dockerExecutable,
|
||||
identity.uid,
|
||||
),
|
||||
dockerSocketPath: safeAbsolutePath(
|
||||
options.dockerSocketPath,
|
||||
'dockerSocketPath',
|
||||
),
|
||||
allowRootService: options.allowRootService,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
cutoverId: request.cutoverId,
|
||||
profile: request.profile,
|
||||
instanceId: request.instanceId,
|
||||
activationPath: safeAbsolutePath(
|
||||
request.activationPath,
|
||||
'activationPath',
|
||||
),
|
||||
legacySourcePath: safeAbsolutePath(
|
||||
request.legacySourcePath,
|
||||
'legacySourcePath',
|
||||
),
|
||||
targetDatabasePath: safeAbsolutePath(
|
||||
request.targetDatabasePath,
|
||||
'targetDatabasePath',
|
||||
),
|
||||
recoveryPath: safeAbsolutePath(request.recoveryPath, 'recoveryPath'),
|
||||
manifestPath: safeAbsolutePath(request.manifestPath, 'manifestPath'),
|
||||
expectedLegacyDatabasePath: safeAbsolutePath(
|
||||
request.expectedLegacyDatabasePath,
|
||||
'expectedLegacyDatabasePath',
|
||||
),
|
||||
expectedActivationDigest: request.expectedActivationDigest,
|
||||
expectedLegacyCommitmentDigest: request.expectedLegacyCommitmentDigest,
|
||||
expectedLegacyContainerId: request.expectedLegacyContainerId,
|
||||
expectedTargetContainerId: request.expectedTargetContainerId,
|
||||
expectedTargetImage: request.expectedTargetImage,
|
||||
applicationConfigPath: safeAbsolutePath(
|
||||
request.applicationConfigPath,
|
||||
'applicationConfigPath',
|
||||
),
|
||||
expectedTargetApplicationConfigPath: safeAbsolutePath(
|
||||
request.expectedTargetApplicationConfigPath,
|
||||
'expectedTargetApplicationConfigPath',
|
||||
),
|
||||
expectedTargetCommitmentPath: safeAbsolutePath(
|
||||
request.expectedTargetCommitmentPath,
|
||||
'expectedTargetCommitmentPath',
|
||||
),
|
||||
generation,
|
||||
requestedAtMs: integer(request.requestedAtMs, 'requestedAtMs', 0),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/contract';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
} from '../../foundation/files';
|
||||
import { cutoverDigest } from '../targetEvidence';
|
||||
import type { LocalDeploymentTargetRunCommand } from './targetRunContract';
|
||||
|
||||
const JOURNAL_SCHEMA = 'qinglong3-local-cutover-journal-record';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export type TargetRunJournalState =
|
||||
| 'legacy_recheck_requested'
|
||||
| 'legacy_reverified'
|
||||
| 'target_start_requested'
|
||||
| 'target_restart_requested'
|
||||
| 'target_stop_requested'
|
||||
| 'target_active'
|
||||
| 'target_stopped'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
| 'manual_required';
|
||||
|
||||
export type TargetRunManualReason =
|
||||
| 'legacy_silence_unproved'
|
||||
| 'target_preflight_unproved'
|
||||
| 'target_start_result_unproved'
|
||||
| 'target_restart_result_unproved'
|
||||
| 'target_stop_preflight_unproved'
|
||||
| 'target_stop_result_unproved'
|
||||
| 'legacy_restart_preflight_unproved'
|
||||
| 'legacy_restart_result_unproved';
|
||||
|
||||
export interface TargetRunJournalRecord {
|
||||
readonly schema: typeof JOURNAL_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly sequence: number;
|
||||
readonly state: TargetRunJournalState;
|
||||
readonly cutoverId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly activationDigest: string;
|
||||
readonly generation: number;
|
||||
readonly previousRecordDigest: string;
|
||||
readonly requestedAtMs: number;
|
||||
readonly evidence: Readonly<Record<string, unknown>>;
|
||||
readonly recordDigest: string;
|
||||
}
|
||||
|
||||
export interface TargetRunJournalContext {
|
||||
readonly command: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly uid: number;
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
export function targetRunJournalRecord(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
sequence: number,
|
||||
state: TargetRunJournalState,
|
||||
previousRecordDigest: string,
|
||||
evidence: Readonly<Record<string, unknown>>,
|
||||
): Readonly<TargetRunJournalRecord> {
|
||||
const payload = Object.freeze({
|
||||
schema: JOURNAL_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
sequence,
|
||||
state,
|
||||
cutoverId: command.request.cutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
activationDigest: command.request.expectedActivationDigest,
|
||||
generation: command.request.generation,
|
||||
previousRecordDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
evidence,
|
||||
});
|
||||
return Object.freeze({ ...payload, recordDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
function parseTargetRunJournalRecord(
|
||||
value: unknown,
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
expected: Readonly<{
|
||||
sequence: number;
|
||||
generation: number;
|
||||
states: readonly TargetRunJournalState[];
|
||||
previousRecordDigest?: string;
|
||||
requestedAtMs?: number;
|
||||
}>,
|
||||
): Readonly<TargetRunJournalRecord> {
|
||||
const record = object(value, 'target run journal record');
|
||||
exact(
|
||||
record,
|
||||
[
|
||||
'activationDigest',
|
||||
'cutoverId',
|
||||
'evidence',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'previousRecordDigest',
|
||||
'profile',
|
||||
'recordDigest',
|
||||
'requestedAtMs',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'sequence',
|
||||
'state',
|
||||
],
|
||||
'target run journal record',
|
||||
);
|
||||
const { recordDigest, ...payload } = record;
|
||||
if (
|
||||
record.schema !== JOURNAL_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.sequence !== expected.sequence ||
|
||||
!expected.states.includes(record.state as TargetRunJournalState) ||
|
||||
record.cutoverId !== command.request.cutoverId ||
|
||||
record.profile !== command.request.profile ||
|
||||
record.instanceId !== command.request.instanceId ||
|
||||
record.activationDigest !== command.request.expectedActivationDigest ||
|
||||
record.generation !== expected.generation ||
|
||||
(expected.previousRecordDigest !== undefined &&
|
||||
record.previousRecordDigest !== expected.previousRecordDigest) ||
|
||||
(expected.requestedAtMs !== undefined &&
|
||||
record.requestedAtMs !== expected.requestedAtMs) ||
|
||||
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('target run journal record drifted');
|
||||
}
|
||||
object(record.evidence, 'target run journal evidence');
|
||||
return record as unknown as Readonly<TargetRunJournalRecord>;
|
||||
}
|
||||
|
||||
export function publishTargetRunJournalRecord(
|
||||
context: Readonly<TargetRunJournalContext>,
|
||||
filePath: string,
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
label: string,
|
||||
): 'prepared' | 'existing' {
|
||||
const contents = `${JSON.stringify(record, null, 2)}\n`;
|
||||
preflightPublishedFile(filePath, contents, 0o600, context.uid, label);
|
||||
return publishExactFile(filePath, contents, 0o600, context.uid, label);
|
||||
}
|
||||
|
||||
export function readTargetRunJournalRecord(
|
||||
filePath: string,
|
||||
context: Readonly<TargetRunJournalContext>,
|
||||
expected: Parameters<typeof parseTargetRunJournalRecord>[2],
|
||||
): Readonly<TargetRunJournalRecord> {
|
||||
return parseTargetRunJournalRecord(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
context.command,
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
export function targetRunSequence(
|
||||
generation: number,
|
||||
phase: 'recheck' | 'verified' | 'request' | 'outcome',
|
||||
): number {
|
||||
if (generation === 1) return phase === 'request' ? 3 : 4;
|
||||
const base = generation * 4;
|
||||
if (phase === 'recheck') return base - 3;
|
||||
if (phase === 'verified') return base - 2;
|
||||
if (phase === 'request') return base - 1;
|
||||
return base;
|
||||
}
|
||||
|
||||
export function targetRunPhasePath(
|
||||
journal: string,
|
||||
generation: number,
|
||||
phase: 'recheck' | 'verified' | 'request' | 'outcome',
|
||||
): string {
|
||||
const number = String(targetRunSequence(generation, phase)).padStart(4, '0');
|
||||
const label =
|
||||
generation === 1
|
||||
? phase === 'request'
|
||||
? 'target-start-decision'
|
||||
: 'target-start-outcome'
|
||||
: phase === 'recheck'
|
||||
? 'legacy-recheck-decision'
|
||||
: phase === 'verified'
|
||||
? 'legacy-recheck-outcome'
|
||||
: phase === 'request'
|
||||
? 'target-restart-decision'
|
||||
: 'target-restart-outcome';
|
||||
return path.join(journal, `${number}-${label}.json`);
|
||||
}
|
||||
|
||||
export function targetStopSequence(
|
||||
generation: number,
|
||||
phase: 'request' | 'outcome',
|
||||
): number {
|
||||
return generation * 4 + (phase === 'request' ? 1 : 2);
|
||||
}
|
||||
|
||||
export function targetStopPhasePath(
|
||||
journal: string,
|
||||
generation: number,
|
||||
phase: 'request' | 'outcome',
|
||||
): string {
|
||||
const number = String(targetStopSequence(generation, phase)).padStart(4, '0');
|
||||
return path.join(
|
||||
journal,
|
||||
`${number}-${
|
||||
phase === 'request' ? 'target-stop-decision' : 'target-stop-outcome'
|
||||
}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function legacyRollbackSequence(
|
||||
generation: number,
|
||||
phase: 'request' | 'outcome',
|
||||
): number {
|
||||
return generation * 4 + (phase === 'request' ? 3 : 4);
|
||||
}
|
||||
|
||||
export function legacyRollbackPhasePath(
|
||||
journal: string,
|
||||
generation: number,
|
||||
phase: 'request' | 'outcome',
|
||||
): string {
|
||||
const number = String(legacyRollbackSequence(generation, phase)).padStart(
|
||||
4,
|
||||
'0',
|
||||
);
|
||||
return path.join(
|
||||
journal,
|
||||
`${number}-${
|
||||
phase === 'request'
|
||||
? 'legacy-rollback-start-decision'
|
||||
: 'legacy-rollback-start-outcome'
|
||||
}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function targetRunManualEvidence(
|
||||
reason: TargetRunManualReason,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
reason,
|
||||
uncertainState:
|
||||
reason === 'legacy_silence_unproved'
|
||||
? 'legacy_silence'
|
||||
: reason === 'legacy_restart_preflight_unproved' ||
|
||||
reason === 'legacy_restart_result_unproved'
|
||||
? 'legacy_activity'
|
||||
: 'target_activity',
|
||||
errorDigest: crypto
|
||||
.createHash('sha256')
|
||||
.update(`qinglong3.local-cutover.${reason}`, 'utf8')
|
||||
.digest('hex'),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTargetRunManualEvidence(
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
): void {
|
||||
const evidence = object(record.evidence, 'manual-required evidence');
|
||||
exact(
|
||||
evidence,
|
||||
['errorDigest', 'reason', 'uncertainState'],
|
||||
'manual-required evidence',
|
||||
);
|
||||
if (
|
||||
(evidence.reason !== 'legacy_silence_unproved' &&
|
||||
evidence.reason !== 'target_preflight_unproved' &&
|
||||
evidence.reason !== 'target_start_result_unproved' &&
|
||||
evidence.reason !== 'target_restart_result_unproved' &&
|
||||
evidence.reason !== 'target_stop_preflight_unproved' &&
|
||||
evidence.reason !== 'target_stop_result_unproved' &&
|
||||
evidence.reason !== 'legacy_restart_preflight_unproved' &&
|
||||
evidence.reason !== 'legacy_restart_result_unproved') ||
|
||||
(evidence.uncertainState !== 'legacy_silence' &&
|
||||
evidence.uncertainState !== 'legacy_activity' &&
|
||||
evidence.uncertainState !== 'target_activity') ||
|
||||
typeof evidence.errorDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.errorDigest)
|
||||
) {
|
||||
configurationError('manual-required evidence drifted');
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/contract';
|
||||
import {
|
||||
cutoverDigest,
|
||||
type LegacySilenceEvidence,
|
||||
type TargetApplicationBinding,
|
||||
type TargetContainerEvidence,
|
||||
} from '../targetEvidence';
|
||||
import type { LocalDeploymentTargetRunCommand } from './targetRunContract';
|
||||
import type { TargetRunJournalRecord } from './targetRunJournal';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface TargetRunRecordEvidenceContext {
|
||||
readonly command: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly commitment: Readonly<LegacySilenceEvidence>;
|
||||
readonly application: Readonly<TargetApplicationBinding>;
|
||||
}
|
||||
|
||||
export interface VerifiedTargetRequestEvidence {
|
||||
readonly targetContainerIdentityDigest: string;
|
||||
readonly targetApplicationBindingDigest: string;
|
||||
readonly previousStartupReceiptDigest: string | null;
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
export function targetRequestEvidence(
|
||||
context: Readonly<TargetRunRecordEvidenceContext>,
|
||||
target: Readonly<TargetContainerEvidence>,
|
||||
previousStartupReceiptDigest: string | null,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
kind:
|
||||
context.command.request.generation === 1
|
||||
? 'target_start'
|
||||
: 'target_restart',
|
||||
legacyCommitmentDigest: context.commitment.commitmentDigest,
|
||||
targetContainerId: context.command.request.expectedTargetContainerId,
|
||||
targetContainerIdentityDigest: target.identityDigest,
|
||||
targetApplicationBindingDigest: target.applicationBindingDigest,
|
||||
targetImageDigest: cutoverDigest(
|
||||
context.command.request.expectedTargetImage,
|
||||
),
|
||||
applicationConfigDigest: context.application.configDigest,
|
||||
previousStartupReceiptDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTargetRequestEvidence(
|
||||
context: Readonly<TargetRunRecordEvidenceContext>,
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
): Readonly<VerifiedTargetRequestEvidence> {
|
||||
const evidence = object(record.evidence, 'target request evidence');
|
||||
exact(
|
||||
evidence,
|
||||
[
|
||||
'applicationConfigDigest',
|
||||
'kind',
|
||||
'legacyCommitmentDigest',
|
||||
'previousStartupReceiptDigest',
|
||||
'targetApplicationBindingDigest',
|
||||
'targetContainerId',
|
||||
'targetContainerIdentityDigest',
|
||||
'targetImageDigest',
|
||||
],
|
||||
'target request evidence',
|
||||
);
|
||||
if (
|
||||
evidence.kind !==
|
||||
(context.command.request.generation === 1
|
||||
? 'target_start'
|
||||
: 'target_restart') ||
|
||||
evidence.legacyCommitmentDigest !== context.commitment.commitmentDigest ||
|
||||
evidence.targetContainerId !==
|
||||
context.command.request.expectedTargetContainerId ||
|
||||
evidence.targetImageDigest !==
|
||||
cutoverDigest(context.command.request.expectedTargetImage) ||
|
||||
evidence.applicationConfigDigest !== context.application.configDigest ||
|
||||
typeof evidence.targetContainerIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.targetContainerIdentityDigest) ||
|
||||
typeof evidence.targetApplicationBindingDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.targetApplicationBindingDigest) ||
|
||||
(evidence.previousStartupReceiptDigest !== null &&
|
||||
(typeof evidence.previousStartupReceiptDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.previousStartupReceiptDigest)))
|
||||
) {
|
||||
configurationError('target request evidence drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
targetContainerIdentityDigest:
|
||||
evidence.targetContainerIdentityDigest as string,
|
||||
targetApplicationBindingDigest:
|
||||
evidence.targetApplicationBindingDigest as string,
|
||||
previousStartupReceiptDigest: evidence.previousStartupReceiptDigest as
|
||||
| string
|
||||
| null,
|
||||
});
|
||||
}
|
||||
|
||||
export function targetActiveEvidence(
|
||||
context: Readonly<TargetRunRecordEvidenceContext>,
|
||||
target: Readonly<TargetContainerEvidence>,
|
||||
startupReceiptDigest: string,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
legacyCommitmentDigest: context.commitment.commitmentDigest,
|
||||
targetContainerIdentityDigest: target.identityDigest,
|
||||
targetApplicationBindingDigest: target.applicationBindingDigest,
|
||||
startupReceiptDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTargetActiveEvidence(
|
||||
context: Readonly<TargetRunRecordEvidenceContext>,
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
request: Readonly<VerifiedTargetRequestEvidence>,
|
||||
): string {
|
||||
const evidence = object(record.evidence, 'target active evidence');
|
||||
exact(
|
||||
evidence,
|
||||
[
|
||||
'legacyCommitmentDigest',
|
||||
'startupReceiptDigest',
|
||||
'targetApplicationBindingDigest',
|
||||
'targetContainerIdentityDigest',
|
||||
],
|
||||
'target active evidence',
|
||||
);
|
||||
if (
|
||||
evidence.legacyCommitmentDigest !== context.commitment.commitmentDigest ||
|
||||
evidence.targetContainerIdentityDigest !==
|
||||
request.targetContainerIdentityDigest ||
|
||||
evidence.targetApplicationBindingDigest !==
|
||||
request.targetApplicationBindingDigest ||
|
||||
typeof evidence.startupReceiptDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.startupReceiptDigest) ||
|
||||
evidence.startupReceiptDigest === request.previousStartupReceiptDigest
|
||||
) {
|
||||
configurationError('target active evidence drifted');
|
||||
}
|
||||
return evidence.startupReceiptDigest;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import type { LocalDeploymentTargetReconciliationDisposition } from './targetStopContract';
|
||||
import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract';
|
||||
import { cutoverDigest } from './targetEvidence';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const UNKNOWN_DIGEST = '0'.repeat(64);
|
||||
const HASH_BUFFER_BYTES = 64 * 1024;
|
||||
|
||||
export interface TargetDataReconciliationEvidence {
|
||||
readonly disposition: LocalDeploymentTargetReconciliationDisposition;
|
||||
readonly targetMatchesActivation: boolean | null;
|
||||
readonly sourceMatchesRecovery: boolean | null;
|
||||
readonly targetSidecarsClear: boolean | null;
|
||||
readonly sourceSidecarsClear: boolean | null;
|
||||
readonly targetFileIdentityDigest: string;
|
||||
readonly sourceFileIdentityDigest: string;
|
||||
readonly evidenceDigest: string;
|
||||
}
|
||||
|
||||
interface FileEvidence {
|
||||
readonly sha256: string;
|
||||
readonly identityDigest: string;
|
||||
readonly sidecarsClear: boolean;
|
||||
readonly pathDigest: string;
|
||||
readonly device: string;
|
||||
readonly inode: string;
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new Error('activation must be an object');
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function textDigest(value: string): string {
|
||||
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function fileHash(descriptor: number): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES);
|
||||
try {
|
||||
for (;;) {
|
||||
const count = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null);
|
||||
if (count === 0) return hash.digest('hex');
|
||||
hash.update(buffer.subarray(0, count));
|
||||
}
|
||||
} finally {
|
||||
buffer.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function sameFileStat(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.mode === right.mode &&
|
||||
left.nlink === right.nlink &&
|
||||
left.uid === right.uid &&
|
||||
left.size === right.size &&
|
||||
left.mtimeNs === right.mtimeNs &&
|
||||
left.ctimeNs === right.ctimeNs
|
||||
);
|
||||
}
|
||||
|
||||
function sidecarSnapshot(filePath: string): readonly boolean[] {
|
||||
return Object.freeze(
|
||||
['-wal', '-shm', '-journal'].map((suffix) =>
|
||||
fs.existsSync(`${filePath}${suffix}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function fileEvidence(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
): Readonly<FileEvidence> {
|
||||
const pathStat = fs.lstatSync(filePath, { bigint: true });
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const before = fs.fstatSync(descriptor, { bigint: true });
|
||||
const sidecarsBefore = sidecarSnapshot(filePath);
|
||||
if (
|
||||
!pathStat.isFile() ||
|
||||
pathStat.isSymbolicLink() ||
|
||||
!sameFileStat(pathStat, before) ||
|
||||
before.uid !== BigInt(uid) ||
|
||||
before.nlink !== 1n ||
|
||||
(before.mode & 0o077n) !== 0n ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(Number.MAX_SAFE_INTEGER)
|
||||
) {
|
||||
throw new Error(`${label} identity is invalid`);
|
||||
}
|
||||
const sha256 = fileHash(descriptor);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
const sidecarsAfter = sidecarSnapshot(filePath);
|
||||
if (
|
||||
!sameFileStat(before, after) ||
|
||||
sidecarsBefore.some((value, index) => value !== sidecarsAfter[index])
|
||||
) {
|
||||
throw new Error(`${label} changed while evidence was collected`);
|
||||
}
|
||||
const sidecarsClear = sidecarsAfter.every((value) => !value);
|
||||
const pathDigest = textDigest(filePath);
|
||||
return Object.freeze({
|
||||
sha256,
|
||||
sidecarsClear,
|
||||
pathDigest,
|
||||
device: after.dev.toString(),
|
||||
inode: after.ino.toString(),
|
||||
identityDigest: cutoverDigest({
|
||||
pathDigest,
|
||||
device: after.dev.toString(),
|
||||
inode: after.ino.toString(),
|
||||
bytes: after.size.toString(),
|
||||
modifiedAtNs: after.mtimeNs.toString(),
|
||||
sha256,
|
||||
sidecarsClear,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function evidence(
|
||||
payload: Omit<TargetDataReconciliationEvidence, 'evidenceDigest'>,
|
||||
): Readonly<TargetDataReconciliationEvidence> {
|
||||
return Object.freeze({ ...payload, evidenceDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
export function readTargetDataReconciliationEvidence(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
uid: number,
|
||||
): Readonly<TargetDataReconciliationEvidence> {
|
||||
try {
|
||||
const activation = object(
|
||||
readPrivateLocalCommandFile(command.request.activationPath),
|
||||
);
|
||||
const { activationDigest, ...payload } = activation;
|
||||
if (
|
||||
activation.schemaVersion !== 1 ||
|
||||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
|
||||
activation.state !== 'prepared' ||
|
||||
activation.profile !== command.request.profile ||
|
||||
activation.sourcePathDigest !==
|
||||
textDigest(command.request.legacySourcePath) ||
|
||||
activation.targetPathDigest !==
|
||||
textDigest(command.request.targetDatabasePath) ||
|
||||
activationDigest !== command.request.expectedActivationDigest ||
|
||||
typeof activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activationDigest) ||
|
||||
typeof activation.targetSha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activation.targetSha256) ||
|
||||
typeof activation.recoverySha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activation.recoverySha256) ||
|
||||
typeof activation.targetDevice !== 'string' ||
|
||||
typeof activation.targetInode !== 'string' ||
|
||||
cutoverDigest(payload) !== activationDigest
|
||||
) {
|
||||
throw new Error('activation identity drifted');
|
||||
}
|
||||
const target = fileEvidence(
|
||||
command.request.targetDatabasePath,
|
||||
uid,
|
||||
'target database',
|
||||
);
|
||||
const source = fileEvidence(
|
||||
command.request.legacySourcePath,
|
||||
uid,
|
||||
'legacy source database',
|
||||
);
|
||||
if (
|
||||
target.pathDigest !== activation.targetPathDigest ||
|
||||
target.device !== activation.targetDevice ||
|
||||
target.inode !== activation.targetInode
|
||||
) {
|
||||
throw new Error('target database stable identity drifted');
|
||||
}
|
||||
const targetMatchesActivation = target.sha256 === activation.targetSha256;
|
||||
const sourceMatchesRecovery = source.sha256 === activation.recoverySha256;
|
||||
const disposition =
|
||||
!targetMatchesActivation || !target.sidecarsClear
|
||||
? ('reconciliation_required' as const)
|
||||
: sourceMatchesRecovery && source.sidecarsClear
|
||||
? ('rollback_candidate' as const)
|
||||
: ('manual_review' as const);
|
||||
return evidence({
|
||||
disposition,
|
||||
targetMatchesActivation,
|
||||
sourceMatchesRecovery,
|
||||
targetSidecarsClear: target.sidecarsClear,
|
||||
sourceSidecarsClear: source.sidecarsClear,
|
||||
targetFileIdentityDigest: target.identityDigest,
|
||||
sourceFileIdentityDigest: source.identityDigest,
|
||||
});
|
||||
} catch {
|
||||
return evidence({
|
||||
disposition: 'manual_review',
|
||||
targetMatchesActivation: null,
|
||||
sourceMatchesRecovery: null,
|
||||
targetSidecarsClear: null,
|
||||
sourceSidecarsClear: null,
|
||||
targetFileIdentityDigest: UNKNOWN_DIGEST,
|
||||
sourceFileIdentityDigest: UNKNOWN_DIGEST,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyTargetDataReconciliationEvidence(
|
||||
value: unknown,
|
||||
): Readonly<TargetDataReconciliationEvidence> {
|
||||
const candidate = object(value);
|
||||
const keys = Object.keys(candidate).sort();
|
||||
const expected = [
|
||||
'disposition',
|
||||
'evidenceDigest',
|
||||
'sourceFileIdentityDigest',
|
||||
'sourceMatchesRecovery',
|
||||
'sourceSidecarsClear',
|
||||
'targetFileIdentityDigest',
|
||||
'targetMatchesActivation',
|
||||
'targetSidecarsClear',
|
||||
].sort();
|
||||
const { evidenceDigest, ...payload } = candidate;
|
||||
if (
|
||||
JSON.stringify(keys) !== JSON.stringify(expected) ||
|
||||
(candidate.disposition !== 'rollback_candidate' &&
|
||||
candidate.disposition !== 'reconciliation_required' &&
|
||||
candidate.disposition !== 'manual_review') ||
|
||||
(candidate.targetMatchesActivation !== null &&
|
||||
typeof candidate.targetMatchesActivation !== 'boolean') ||
|
||||
(candidate.sourceMatchesRecovery !== null &&
|
||||
typeof candidate.sourceMatchesRecovery !== 'boolean') ||
|
||||
(candidate.targetSidecarsClear !== null &&
|
||||
typeof candidate.targetSidecarsClear !== 'boolean') ||
|
||||
(candidate.sourceSidecarsClear !== null &&
|
||||
typeof candidate.sourceSidecarsClear !== 'boolean') ||
|
||||
typeof candidate.targetFileIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(candidate.targetFileIdentityDigest) ||
|
||||
typeof candidate.sourceFileIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(candidate.sourceFileIdentityDigest) ||
|
||||
typeof evidenceDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidenceDigest) ||
|
||||
cutoverDigest(payload) !== evidenceDigest
|
||||
) {
|
||||
throw new Error('target data reconciliation evidence drifted');
|
||||
}
|
||||
return candidate as unknown as Readonly<TargetDataReconciliationEvidence>;
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const BOOT_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const START_TICKS_PATTERN = /^[1-9][0-9]{0,19}$/;
|
||||
const NODE_VERSION_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||
|
||||
export interface LegacySilenceEvidence {
|
||||
readonly commitmentDigest: string;
|
||||
readonly legacyContainerIdentityDigest: string;
|
||||
readonly legacySourceBindingDigest: string;
|
||||
}
|
||||
|
||||
export interface TargetApplicationBinding {
|
||||
readonly configDigest: string;
|
||||
readonly targetActivationPath: string;
|
||||
readonly targetLegacySourcePath: string;
|
||||
readonly targetDatabasePath: string;
|
||||
readonly targetRecoveryPath: string;
|
||||
readonly targetManifestPath: string;
|
||||
}
|
||||
|
||||
export interface TargetContainerEvidence {
|
||||
readonly identityDigest: string;
|
||||
readonly applicationBindingDigest: string;
|
||||
}
|
||||
|
||||
export interface TargetStartupReceiptEvidence {
|
||||
readonly digest: string;
|
||||
readonly bootId: string;
|
||||
readonly activeBootAgeMs: number;
|
||||
readonly processId: number;
|
||||
readonly processStartTicks: string;
|
||||
readonly nodeExecutable: string;
|
||||
}
|
||||
|
||||
export interface TargetStartupReceiptCommandIdentity {
|
||||
readonly request: Readonly<{
|
||||
applicationConfigPath: string;
|
||||
instanceId: string;
|
||||
profile: 'edge' | 'standalone';
|
||||
}>;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
export function cutoverDigest(value: unknown): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function textDigest(value: string): string {
|
||||
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
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 endpointDigest(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): string {
|
||||
return cutoverDigest({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
});
|
||||
}
|
||||
|
||||
export function legacyCommitmentPath(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): string {
|
||||
return path.join(
|
||||
command.options.deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
command.request.cutoverId,
|
||||
'0002-legacy-stopped.json',
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyTargetRunActivation(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): void {
|
||||
let sourceStat: fs.Stats;
|
||||
try {
|
||||
sourceStat = fs.lstatSync(command.request.legacySourcePath);
|
||||
} catch (error) {
|
||||
configurationError('legacy source is unavailable', error);
|
||||
}
|
||||
if (
|
||||
!sourceStat.isFile() ||
|
||||
sourceStat.isSymbolicLink() ||
|
||||
fs.realpathSync(command.request.legacySourcePath) !==
|
||||
command.request.legacySourcePath
|
||||
) {
|
||||
configurationError('legacy source must be a canonical regular file');
|
||||
}
|
||||
const activation = object(
|
||||
readPrivateLocalCommandFile(command.request.activationPath),
|
||||
'activation',
|
||||
);
|
||||
exact(
|
||||
activation,
|
||||
[
|
||||
'activationDigest',
|
||||
'adoptionManifestDigest',
|
||||
'createdAtMs',
|
||||
'kind',
|
||||
'planDigest',
|
||||
'profile',
|
||||
'recoverySha256',
|
||||
'schemaVersion',
|
||||
'sourcePathDigest',
|
||||
'state',
|
||||
'targetDevice',
|
||||
'targetInode',
|
||||
'targetPathDigest',
|
||||
'targetSha256',
|
||||
],
|
||||
'activation',
|
||||
);
|
||||
const { activationDigest, ...payload } = activation;
|
||||
if (
|
||||
activation.schemaVersion !== 1 ||
|
||||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
|
||||
activation.state !== 'prepared' ||
|
||||
activation.profile !== command.request.profile ||
|
||||
activation.sourcePathDigest !==
|
||||
textDigest(command.request.legacySourcePath) ||
|
||||
activationDigest !== command.request.expectedActivationDigest ||
|
||||
typeof activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activationDigest) ||
|
||||
cutoverDigest(payload) !== activationDigest
|
||||
) {
|
||||
configurationError('activation does not match the target run request');
|
||||
}
|
||||
}
|
||||
|
||||
export function readLegacySilenceEvidence(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): Readonly<LegacySilenceEvidence> {
|
||||
const commitment = object(
|
||||
readPrivateLocalCommandFile(legacyCommitmentPath(command)),
|
||||
'legacy silence commitment',
|
||||
);
|
||||
exact(
|
||||
commitment,
|
||||
[
|
||||
'activationDigest',
|
||||
'commitmentDigest',
|
||||
'controller',
|
||||
'cutoverId',
|
||||
'instanceId',
|
||||
'kind',
|
||||
'observedAtMs',
|
||||
'previousRecordDigest',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'legacy silence commitment',
|
||||
);
|
||||
const controller = object(commitment.controller, 'commitment controller');
|
||||
exact(
|
||||
controller,
|
||||
[
|
||||
'endpointDigest',
|
||||
'kind',
|
||||
'legacyContainerId',
|
||||
'legacyContainerIdentityDigest',
|
||||
'legacySourceBindingDigest',
|
||||
],
|
||||
'commitment controller',
|
||||
);
|
||||
const { commitmentDigest, ...payload } = commitment;
|
||||
if (
|
||||
commitment.schemaVersion !== 1 ||
|
||||
commitment.kind !== 'qinglong3-local-legacy-silence-commitment' ||
|
||||
commitment.state !== 'legacy_stopped' ||
|
||||
commitment.cutoverId !== command.request.cutoverId ||
|
||||
commitment.profile !== command.request.profile ||
|
||||
commitment.instanceId !== command.request.instanceId ||
|
||||
commitment.activationDigest !== command.request.expectedActivationDigest ||
|
||||
commitmentDigest !== command.request.expectedLegacyCommitmentDigest ||
|
||||
typeof commitmentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(commitmentDigest) ||
|
||||
controller.kind !== 'docker' ||
|
||||
controller.endpointDigest !== endpointDigest(command) ||
|
||||
controller.legacyContainerId !==
|
||||
command.request.expectedLegacyContainerId ||
|
||||
typeof controller.legacyContainerIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(controller.legacyContainerIdentityDigest) ||
|
||||
typeof controller.legacySourceBindingDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(controller.legacySourceBindingDigest) ||
|
||||
cutoverDigest(payload) !== commitmentDigest
|
||||
) {
|
||||
configurationError('legacy silence commitment does not match target run');
|
||||
}
|
||||
return Object.freeze({
|
||||
commitmentDigest,
|
||||
legacyContainerIdentityDigest:
|
||||
controller.legacyContainerIdentityDigest as string,
|
||||
legacySourceBindingDigest: controller.legacySourceBindingDigest as string,
|
||||
});
|
||||
}
|
||||
|
||||
export function readTargetApplicationBinding(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): Readonly<TargetApplicationBinding> {
|
||||
const config = object(
|
||||
readPrivateLocalCommandFile(command.request.applicationConfigPath),
|
||||
'target application configuration',
|
||||
);
|
||||
const storage = object(config.storage, 'target storage configuration');
|
||||
const cutover = object(config.cutover, 'target cutover configuration');
|
||||
if (
|
||||
config.schema !== 'qinglong/local-application-process@v3' ||
|
||||
config.profile !== command.request.profile ||
|
||||
config.instanceId !== command.request.instanceId ||
|
||||
storage.mode !== 'adopted' ||
|
||||
storage.expectedActivationDigest !==
|
||||
command.request.expectedActivationDigest ||
|
||||
typeof storage.sourcePath !== 'string' ||
|
||||
!path.isAbsolute(storage.sourcePath) ||
|
||||
path.normalize(storage.sourcePath) !== storage.sourcePath ||
|
||||
typeof storage.activationPath !== 'string' ||
|
||||
!path.isAbsolute(storage.activationPath) ||
|
||||
path.normalize(storage.activationPath) !== storage.activationPath ||
|
||||
typeof storage.targetPath !== 'string' ||
|
||||
!path.isAbsolute(storage.targetPath) ||
|
||||
path.normalize(storage.targetPath) !== storage.targetPath ||
|
||||
typeof storage.recoveryPath !== 'string' ||
|
||||
!path.isAbsolute(storage.recoveryPath) ||
|
||||
path.normalize(storage.recoveryPath) !== storage.recoveryPath ||
|
||||
typeof storage.manifestPath !== 'string' ||
|
||||
!path.isAbsolute(storage.manifestPath) ||
|
||||
path.normalize(storage.manifestPath) !== storage.manifestPath ||
|
||||
cutover.cutoverId !== command.request.cutoverId ||
|
||||
cutover.commitmentPath !== command.request.expectedTargetCommitmentPath ||
|
||||
cutover.expectedCommitmentDigest !==
|
||||
command.request.expectedLegacyCommitmentDigest
|
||||
) {
|
||||
configurationError('target application configuration binding is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
configDigest: cutoverDigest(config),
|
||||
targetActivationPath: storage.activationPath,
|
||||
targetLegacySourcePath: storage.sourcePath,
|
||||
targetDatabasePath: storage.targetPath,
|
||||
targetRecoveryPath: storage.recoveryPath,
|
||||
targetManifestPath: storage.manifestPath,
|
||||
});
|
||||
}
|
||||
|
||||
interface DockerMount {
|
||||
readonly source: string;
|
||||
readonly destination: string;
|
||||
readonly readWrite: boolean;
|
||||
}
|
||||
|
||||
function validMount(value: unknown): DockerMount | undefined {
|
||||
const mount = object(value, 'target container mount');
|
||||
if (
|
||||
mount.Type !== 'bind' ||
|
||||
typeof mount.Source !== 'string' ||
|
||||
typeof mount.Destination !== 'string' ||
|
||||
typeof mount.RW !== 'boolean' ||
|
||||
!path.isAbsolute(mount.Source) ||
|
||||
!path.isAbsolute(mount.Destination) ||
|
||||
path.normalize(mount.Source) !== mount.Source ||
|
||||
path.normalize(mount.Destination) !== mount.Destination
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
source: mount.Source,
|
||||
destination: mount.Destination,
|
||||
readWrite: mount.RW,
|
||||
});
|
||||
}
|
||||
|
||||
function mappedMount(
|
||||
mounts: readonly DockerMount[],
|
||||
hostPath: string,
|
||||
targetPath: string,
|
||||
label: string,
|
||||
): DockerMount {
|
||||
const matches = mounts.filter((mount) => {
|
||||
const relative = path.relative(mount.source, hostPath);
|
||||
return (
|
||||
!relative.startsWith('..') &&
|
||||
!path.isAbsolute(relative) &&
|
||||
path.join(mount.destination, relative) === targetPath
|
||||
);
|
||||
});
|
||||
if (matches.length !== 1 || matches[0]?.readWrite !== true) {
|
||||
configurationError(`${label} must have one read-write bind mapping`);
|
||||
}
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
function parsedContainer(
|
||||
output: string,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (error) {
|
||||
configurationError(`${label} inspection is invalid`, error);
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
||||
configurationError(`${label} inspection count is invalid`);
|
||||
}
|
||||
return object(parsed[0], label);
|
||||
}
|
||||
|
||||
export function parseStoppedLegacyEvidence(
|
||||
output: string,
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): Readonly<{
|
||||
identityDigest: string;
|
||||
sourceBindingDigest: string;
|
||||
}> {
|
||||
const container = parsedContainer(output, 'legacy container');
|
||||
const state = object(container.State, 'legacy container state');
|
||||
const hostConfig = object(
|
||||
container.HostConfig,
|
||||
'legacy container host config',
|
||||
);
|
||||
const restartPolicy = object(
|
||||
hostConfig.RestartPolicy,
|
||||
'legacy container restart policy',
|
||||
);
|
||||
const config = object(container.Config, 'legacy container config');
|
||||
if (
|
||||
container.Id !== command.request.expectedLegacyContainerId ||
|
||||
state.Running !== false ||
|
||||
state.Restarting !== false ||
|
||||
state.Paused !== false ||
|
||||
state.Pid !== 0 ||
|
||||
(state.Status !== 'exited' && state.Status !== 'dead') ||
|
||||
(restartPolicy.Name !== '' && restartPolicy.Name !== 'no') ||
|
||||
typeof container.Created !== 'string' ||
|
||||
typeof container.Name !== 'string' ||
|
||||
typeof config.Image !== 'string' ||
|
||||
!Array.isArray(container.Mounts)
|
||||
) {
|
||||
configurationError('legacy container silence cannot be reverified');
|
||||
}
|
||||
const mounts = container.Mounts.flatMap((value) => {
|
||||
const candidate = validMount(value);
|
||||
return candidate === undefined ? [] : [candidate];
|
||||
});
|
||||
const sourceMount = mappedMount(
|
||||
mounts,
|
||||
command.request.legacySourcePath,
|
||||
command.request.expectedLegacyDatabasePath,
|
||||
'legacy source',
|
||||
);
|
||||
return Object.freeze({
|
||||
identityDigest: cutoverDigest({
|
||||
containerId: container.Id,
|
||||
created: container.Created,
|
||||
image: config.Image,
|
||||
name: container.Name,
|
||||
}),
|
||||
sourceBindingDigest: cutoverDigest({
|
||||
sourcePathDigest: textDigest(command.request.legacySourcePath),
|
||||
databasePathDigest: textDigest(
|
||||
command.request.expectedLegacyDatabasePath,
|
||||
),
|
||||
mountSourceDigest: textDigest(sourceMount.source),
|
||||
mountDestinationDigest: textDigest(sourceMount.destination),
|
||||
readWrite: sourceMount.readWrite,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseActiveLegacyEvidence(
|
||||
output: string,
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): Readonly<{
|
||||
identityDigest: string;
|
||||
sourceBindingDigest: string;
|
||||
}> {
|
||||
const container = parsedContainer(output, 'legacy container');
|
||||
const state = object(container.State, 'legacy container state');
|
||||
const hostConfig = object(
|
||||
container.HostConfig,
|
||||
'legacy container host config',
|
||||
);
|
||||
const restartPolicy = object(
|
||||
hostConfig.RestartPolicy,
|
||||
'legacy container restart policy',
|
||||
);
|
||||
const config = object(container.Config, 'legacy container config');
|
||||
if (
|
||||
container.Id !== command.request.expectedLegacyContainerId ||
|
||||
state.Running !== true ||
|
||||
state.Restarting !== false ||
|
||||
state.Paused !== false ||
|
||||
!Number.isSafeInteger(state.Pid) ||
|
||||
(state.Pid as number) < 1 ||
|
||||
state.Status !== 'running' ||
|
||||
(restartPolicy.Name !== '' && restartPolicy.Name !== 'no') ||
|
||||
typeof container.Created !== 'string' ||
|
||||
typeof container.Name !== 'string' ||
|
||||
typeof config.Image !== 'string' ||
|
||||
!Array.isArray(container.Mounts)
|
||||
) {
|
||||
configurationError('legacy container running state cannot be proved');
|
||||
}
|
||||
const mounts = container.Mounts.flatMap((value) => {
|
||||
const candidate = validMount(value);
|
||||
return candidate === undefined ? [] : [candidate];
|
||||
});
|
||||
const sourceMount = mappedMount(
|
||||
mounts,
|
||||
command.request.legacySourcePath,
|
||||
command.request.expectedLegacyDatabasePath,
|
||||
'legacy source',
|
||||
);
|
||||
return Object.freeze({
|
||||
identityDigest: cutoverDigest({
|
||||
containerId: container.Id,
|
||||
created: container.Created,
|
||||
image: config.Image,
|
||||
name: container.Name,
|
||||
}),
|
||||
sourceBindingDigest: cutoverDigest({
|
||||
sourcePathDigest: textDigest(command.request.legacySourcePath),
|
||||
databasePathDigest: textDigest(
|
||||
command.request.expectedLegacyDatabasePath,
|
||||
),
|
||||
mountSourceDigest: textDigest(sourceMount.source),
|
||||
mountDestinationDigest: textDigest(sourceMount.destination),
|
||||
readWrite: sourceMount.readWrite,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseTargetContainerEvidence(
|
||||
output: string,
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
application: Readonly<TargetApplicationBinding>,
|
||||
expectedState: 'stopped' | 'active',
|
||||
): Readonly<TargetContainerEvidence> {
|
||||
const container = parsedContainer(output, 'target container');
|
||||
const state = object(container.State, 'target container state');
|
||||
const hostConfig = object(
|
||||
container.HostConfig,
|
||||
'target container host config',
|
||||
);
|
||||
const restartPolicy = object(
|
||||
hostConfig.RestartPolicy,
|
||||
'target container restart policy',
|
||||
);
|
||||
const config = object(container.Config, 'target container config');
|
||||
const stopped =
|
||||
state.Running === false &&
|
||||
state.Restarting === false &&
|
||||
state.Paused === false &&
|
||||
state.Pid === 0 &&
|
||||
(state.Status === 'created' ||
|
||||
state.Status === 'exited' ||
|
||||
state.Status === 'dead');
|
||||
const active =
|
||||
state.Running === true &&
|
||||
state.Restarting === false &&
|
||||
state.Paused === false &&
|
||||
Number.isSafeInteger(state.Pid) &&
|
||||
(state.Pid as number) > 0 &&
|
||||
state.Status === 'running';
|
||||
if (
|
||||
container.Id !== command.request.expectedTargetContainerId ||
|
||||
(expectedState === 'stopped' ? !stopped : !active) ||
|
||||
(restartPolicy.Name !== '' && restartPolicy.Name !== 'no') ||
|
||||
hostConfig.ReadonlyRootfs !== true ||
|
||||
hostConfig.Privileged === true ||
|
||||
!Array.isArray(hostConfig.SecurityOpt) ||
|
||||
!hostConfig.SecurityOpt.includes('no-new-privileges') ||
|
||||
config.Image !== command.request.expectedTargetImage ||
|
||||
JSON.stringify(config.Cmd) !==
|
||||
JSON.stringify([
|
||||
'--config',
|
||||
command.request.expectedTargetApplicationConfigPath,
|
||||
]) ||
|
||||
typeof container.Created !== 'string' ||
|
||||
typeof container.Name !== 'string' ||
|
||||
!Array.isArray(container.Mounts)
|
||||
) {
|
||||
configurationError(`target container ${expectedState} evidence is invalid`);
|
||||
}
|
||||
const mounts = container.Mounts.flatMap((value) => {
|
||||
const candidate = validMount(value);
|
||||
return candidate === undefined ? [] : [candidate];
|
||||
});
|
||||
const commitmentMount = mappedMount(
|
||||
mounts,
|
||||
legacyCommitmentPath(command),
|
||||
command.request.expectedTargetCommitmentPath,
|
||||
'target commitment',
|
||||
);
|
||||
const configMount = mappedMount(
|
||||
mounts,
|
||||
command.request.applicationConfigPath,
|
||||
command.request.expectedTargetApplicationConfigPath,
|
||||
'target application configuration',
|
||||
);
|
||||
const activationMount = mappedMount(
|
||||
mounts,
|
||||
command.request.activationPath,
|
||||
application.targetActivationPath,
|
||||
'target activation',
|
||||
);
|
||||
const sourceMount = mappedMount(
|
||||
mounts,
|
||||
command.request.legacySourcePath,
|
||||
application.targetLegacySourcePath,
|
||||
'target legacy source',
|
||||
);
|
||||
const databaseMount = mappedMount(
|
||||
mounts,
|
||||
command.request.targetDatabasePath,
|
||||
application.targetDatabasePath,
|
||||
'target database',
|
||||
);
|
||||
const recoveryMount = mappedMount(
|
||||
mounts,
|
||||
command.request.recoveryPath,
|
||||
application.targetRecoveryPath,
|
||||
'target recovery database',
|
||||
);
|
||||
const manifestMount = mappedMount(
|
||||
mounts,
|
||||
command.request.manifestPath,
|
||||
application.targetManifestPath,
|
||||
'target adoption manifest',
|
||||
);
|
||||
return Object.freeze({
|
||||
identityDigest: cutoverDigest({
|
||||
containerId: container.Id,
|
||||
created: container.Created,
|
||||
image: config.Image,
|
||||
name: container.Name,
|
||||
}),
|
||||
applicationBindingDigest: cutoverDigest({
|
||||
configDigest: application.configDigest,
|
||||
configMount,
|
||||
commitmentMount,
|
||||
activationMount,
|
||||
sourceMount,
|
||||
databaseMount,
|
||||
recoveryMount,
|
||||
manifestMount,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function readTargetStartupReceipt(
|
||||
command: Readonly<TargetStartupReceiptCommandIdentity>,
|
||||
): Readonly<TargetStartupReceiptEvidence> | null {
|
||||
const receiptPath = `${command.request.applicationConfigPath}.active.json`;
|
||||
if (!fs.existsSync(receiptPath)) return null;
|
||||
const receipt = object(
|
||||
readPrivateLocalCommandFile(receiptPath),
|
||||
'target startup receipt',
|
||||
);
|
||||
exact(
|
||||
receipt,
|
||||
[
|
||||
'activeBootAgeMs',
|
||||
'aiStatus',
|
||||
'bootId',
|
||||
'instanceId',
|
||||
'nodeExecutable',
|
||||
'nodeVersion',
|
||||
'processId',
|
||||
'processStartTicks',
|
||||
'profile',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'sha256',
|
||||
],
|
||||
'target startup receipt',
|
||||
);
|
||||
const { sha256, ...payload } = receipt;
|
||||
const receiptDigest = crypto
|
||||
.createHash('sha256')
|
||||
.update('qinglong.local-application-startup-receipt.v1\0', 'utf8')
|
||||
.update(JSON.stringify(payload), 'utf8')
|
||||
.digest('hex');
|
||||
if (
|
||||
receipt.schemaVersion !== 1 ||
|
||||
receipt.schema !== 'qinglong/local-application-startup-receipt@v1' ||
|
||||
receipt.instanceId !== command.request.instanceId ||
|
||||
receipt.profile !== command.request.profile ||
|
||||
(receipt.aiStatus !== 'deployment_excluded' &&
|
||||
receipt.aiStatus !== 'schema_absent' &&
|
||||
receipt.aiStatus !== 'inactive' &&
|
||||
receipt.aiStatus !== 'active') ||
|
||||
typeof receipt.bootId !== 'string' ||
|
||||
!BOOT_ID_PATTERN.test(receipt.bootId) ||
|
||||
!Number.isSafeInteger(receipt.activeBootAgeMs) ||
|
||||
(receipt.activeBootAgeMs as number) < 0 ||
|
||||
!Number.isSafeInteger(receipt.processId) ||
|
||||
(receipt.processId as number) < 1 ||
|
||||
typeof receipt.processStartTicks !== 'string' ||
|
||||
!START_TICKS_PATTERN.test(receipt.processStartTicks) ||
|
||||
typeof receipt.nodeExecutable !== 'string' ||
|
||||
!path.isAbsolute(receipt.nodeExecutable) ||
|
||||
path.normalize(receipt.nodeExecutable) !== receipt.nodeExecutable ||
|
||||
typeof receipt.nodeVersion !== 'string' ||
|
||||
!NODE_VERSION_PATTERN.test(receipt.nodeVersion) ||
|
||||
typeof sha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(sha256) ||
|
||||
receiptDigest !== sha256
|
||||
) {
|
||||
configurationError('target startup receipt is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
digest: receiptDigest,
|
||||
bootId: receipt.bootId,
|
||||
activeBootAgeMs: receipt.activeBootAgeMs as number,
|
||||
processId: receipt.processId as number,
|
||||
processStartTicks: receipt.processStartTicks,
|
||||
nodeExecutable: receipt.nodeExecutable,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../foundation/docker';
|
||||
import { validatePrivateDirectory } from '../foundation/files';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
readLocalCutoverInstanceHead,
|
||||
} from './instanceLineage';
|
||||
import { readTargetDataReconciliationEvidence } from './targetDataEvidence';
|
||||
import {
|
||||
legacyCommitmentPath,
|
||||
parseTargetContainerEvidence,
|
||||
readLegacySilenceEvidence,
|
||||
readTargetApplicationBinding,
|
||||
type LegacySilenceEvidence,
|
||||
type TargetApplicationBinding,
|
||||
} from './targetEvidence';
|
||||
import {
|
||||
targetStopRunCommand,
|
||||
normalizeLocalDeploymentTargetStopCommand,
|
||||
type LocalDeploymentTargetReconciliationDisposition,
|
||||
type LocalDeploymentTargetStopCommand,
|
||||
type LocalDeploymentTargetStopResult,
|
||||
} from './targetStopContract';
|
||||
import {
|
||||
publishTargetRunJournalRecord,
|
||||
readTargetRunJournalRecord,
|
||||
targetRunJournalRecord,
|
||||
targetRunManualEvidence,
|
||||
targetRunPhasePath,
|
||||
targetRunSequence,
|
||||
targetStopPhasePath,
|
||||
targetStopSequence,
|
||||
verifyTargetRunManualEvidence,
|
||||
type TargetRunJournalRecord,
|
||||
type TargetRunManualReason,
|
||||
} from './target-run/targetRunJournal';
|
||||
import {
|
||||
targetStoppedEvidence,
|
||||
targetStopRequestEvidence,
|
||||
verifyTargetStoppedEvidence,
|
||||
verifyTargetStopRequestEvidence,
|
||||
type TargetStopActiveEvidence,
|
||||
} from './targetStopRecordEvidence';
|
||||
import {
|
||||
verifyTargetActiveEvidence,
|
||||
verifyTargetRequestEvidence,
|
||||
} from './target-run/targetRunRecordEvidence';
|
||||
import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract';
|
||||
|
||||
export interface LocalDeploymentTargetStopDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
readonly afterBarrier?: () => void;
|
||||
}
|
||||
|
||||
interface StopContext {
|
||||
readonly stopCommand: Readonly<LocalDeploymentTargetStopCommand>;
|
||||
readonly command: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly journal: string;
|
||||
readonly commitment: Readonly<LegacySilenceEvidence>;
|
||||
readonly application: Readonly<TargetApplicationBinding>;
|
||||
readonly uid: number;
|
||||
}
|
||||
|
||||
interface PriorActive extends TargetStopActiveEvidence {
|
||||
readonly record: Readonly<TargetRunJournalRecord>;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(message);
|
||||
}
|
||||
|
||||
function priorActive(context: Readonly<StopContext>): Readonly<PriorActive> {
|
||||
const generation = context.command.request.generation;
|
||||
const request = readTargetRunJournalRecord(
|
||||
targetRunPhasePath(context.journal, generation, 'request'),
|
||||
context,
|
||||
{
|
||||
sequence: targetRunSequence(generation, 'request'),
|
||||
generation,
|
||||
states: [
|
||||
generation === 1
|
||||
? 'target_start_requested'
|
||||
: 'target_restart_requested',
|
||||
],
|
||||
},
|
||||
);
|
||||
const requestEvidence = verifyTargetRequestEvidence(context, request);
|
||||
const active = readTargetRunJournalRecord(
|
||||
targetRunPhasePath(context.journal, generation, 'outcome'),
|
||||
context,
|
||||
{
|
||||
sequence: targetRunSequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_active'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
},
|
||||
);
|
||||
const startupReceiptDigest = verifyTargetActiveEvidence(
|
||||
context,
|
||||
active,
|
||||
requestEvidence,
|
||||
);
|
||||
return Object.freeze({
|
||||
record: active,
|
||||
activeRecordDigest: active.recordDigest,
|
||||
targetContainerIdentityDigest:
|
||||
requestEvidence.targetContainerIdentityDigest,
|
||||
targetApplicationBindingDigest:
|
||||
requestEvidence.targetApplicationBindingDigest,
|
||||
startupReceiptDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function result(
|
||||
context: Readonly<StopContext>,
|
||||
status: 'prepared' | 'existing',
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
reconciliation: LocalDeploymentTargetReconciliationDisposition,
|
||||
): Readonly<LocalDeploymentTargetStopResult> {
|
||||
const head = advanceLocalCutoverInstanceHead(
|
||||
context.command,
|
||||
context.uid,
|
||||
record.state as 'target_stopped' | 'manual_required',
|
||||
record.generation,
|
||||
record.recordDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: context.stopCommand.operation,
|
||||
status,
|
||||
state: record.state as 'target_stopped' | 'manual_required',
|
||||
cutoverId: context.command.request.cutoverId,
|
||||
generation: context.command.request.generation,
|
||||
reconciliation,
|
||||
recordDigest: record.recordDigest,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function publishManual(
|
||||
context: Readonly<StopContext>,
|
||||
filePath: string,
|
||||
recordSequence: number,
|
||||
previousRecordDigest: string,
|
||||
reason: TargetRunManualReason,
|
||||
): Readonly<LocalDeploymentTargetStopResult> {
|
||||
const record = targetRunJournalRecord(
|
||||
context.command,
|
||||
recordSequence,
|
||||
'manual_required',
|
||||
previousRecordDigest,
|
||||
targetRunManualEvidence(reason),
|
||||
);
|
||||
const status = publishTargetRunJournalRecord(
|
||||
context,
|
||||
filePath,
|
||||
record,
|
||||
'target stop manual resolution',
|
||||
);
|
||||
return result(context, status, record, 'manual_review');
|
||||
}
|
||||
|
||||
function replayTerminal(
|
||||
context: Readonly<StopContext>,
|
||||
active: Readonly<PriorActive>,
|
||||
): Readonly<LocalDeploymentTargetStopResult> | undefined {
|
||||
const generation = context.command.request.generation;
|
||||
const requestPath = targetStopPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'request',
|
||||
);
|
||||
if (!fs.existsSync(requestPath)) return undefined;
|
||||
const request = readTargetRunJournalRecord(requestPath, context, {
|
||||
sequence: targetStopSequence(generation, 'request'),
|
||||
generation,
|
||||
states: ['target_stop_requested', 'manual_required'],
|
||||
previousRecordDigest: active.record.recordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
if (request.state === 'manual_required') {
|
||||
verifyTargetRunManualEvidence(request);
|
||||
return result(context, 'existing', request, 'manual_review');
|
||||
}
|
||||
verifyTargetStopRequestEvidence(request, active);
|
||||
const outcomePath = targetStopPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'outcome',
|
||||
);
|
||||
if (!fs.existsSync(outcomePath)) return undefined;
|
||||
const outcome = readTargetRunJournalRecord(outcomePath, context, {
|
||||
sequence: targetStopSequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_stopped', 'manual_required'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
if (outcome.state === 'manual_required') {
|
||||
verifyTargetRunManualEvidence(outcome);
|
||||
return result(context, 'existing', outcome, 'manual_review');
|
||||
}
|
||||
return result(
|
||||
context,
|
||||
'existing',
|
||||
outcome,
|
||||
verifyTargetStoppedEvidence(outcome, active).disposition,
|
||||
);
|
||||
}
|
||||
|
||||
function docker(
|
||||
context: Readonly<StopContext>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
): string {
|
||||
return runDocker({
|
||||
executable: context.command.options.dockerExecutable,
|
||||
socketPath: context.command.options.dockerSocketPath,
|
||||
args,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function stopLocalDeploymentDockerTarget(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentTargetStopDependencies = {},
|
||||
): Readonly<LocalDeploymentTargetStopResult> {
|
||||
const stopCommand = normalizeLocalDeploymentTargetStopCommand(input);
|
||||
const command = targetStopRunCommand(stopCommand);
|
||||
const identity = currentIdentity();
|
||||
const serviceRoot = path.join(command.options.deploymentRoot, 'service');
|
||||
const journal = path.dirname(legacyCommitmentPath(command));
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(serviceRoot, identity.uid, 'serviceDescriptorRoot');
|
||||
validatePrivateDirectory(journal, identity.uid, 'cutoverJournal');
|
||||
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_active' &&
|
||||
head.state !== 'target_stopped' &&
|
||||
head.state !== 'manual_required')
|
||||
) {
|
||||
configurationError('target stop is not bound to the instance lineage head');
|
||||
}
|
||||
const commitment = readLegacySilenceEvidence(command);
|
||||
const application = readTargetApplicationBinding(command);
|
||||
const context = Object.freeze({
|
||||
stopCommand,
|
||||
command,
|
||||
journal,
|
||||
commitment,
|
||||
application,
|
||||
uid: identity.uid,
|
||||
});
|
||||
const active = priorActive(context);
|
||||
const replay = replayTerminal(context, active);
|
||||
if (replay !== undefined) return replay;
|
||||
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(command.options.dockerSocketPath, identity.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
const generation = command.request.generation;
|
||||
const requestPath = targetStopPhasePath(journal, generation, 'request');
|
||||
let request: Readonly<TargetRunJournalRecord>;
|
||||
if (fs.existsSync(requestPath)) {
|
||||
request = readTargetRunJournalRecord(requestPath, context, {
|
||||
sequence: targetStopSequence(generation, 'request'),
|
||||
generation,
|
||||
states: ['target_stop_requested'],
|
||||
previousRecordDigest: active.record.recordDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
});
|
||||
verifyTargetStopRequestEvidence(request, active);
|
||||
} else {
|
||||
try {
|
||||
const target = parseTargetContainerEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
['container', 'inspect', command.request.expectedTargetContainerId],
|
||||
30_000,
|
||||
),
|
||||
command,
|
||||
application,
|
||||
'active',
|
||||
);
|
||||
if (
|
||||
target.identityDigest !== active.targetContainerIdentityDigest ||
|
||||
target.applicationBindingDigest !==
|
||||
active.targetApplicationBindingDigest
|
||||
) {
|
||||
configurationError('active target identity changed before stop');
|
||||
}
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
requestPath,
|
||||
targetStopSequence(generation, 'request'),
|
||||
active.record.recordDigest,
|
||||
'target_stop_preflight_unproved',
|
||||
);
|
||||
}
|
||||
request = targetRunJournalRecord(
|
||||
command,
|
||||
targetStopSequence(generation, 'request'),
|
||||
'target_stop_requested',
|
||||
active.record.recordDigest,
|
||||
targetStopRequestEvidence(active),
|
||||
);
|
||||
publishTargetRunJournalRecord(
|
||||
context,
|
||||
requestPath,
|
||||
request,
|
||||
'target stop barrier',
|
||||
);
|
||||
dependencies.afterBarrier?.();
|
||||
}
|
||||
|
||||
try {
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'update',
|
||||
'--restart',
|
||||
'no',
|
||||
command.request.expectedTargetContainerId,
|
||||
],
|
||||
30_000,
|
||||
);
|
||||
} catch {
|
||||
// Stop is convergent; the exact inspection below is authoritative.
|
||||
}
|
||||
try {
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'stop',
|
||||
'--time',
|
||||
'30',
|
||||
command.request.expectedTargetContainerId,
|
||||
],
|
||||
45_000,
|
||||
);
|
||||
} catch {
|
||||
// A lost stop response is resolved by the exact inspection below.
|
||||
}
|
||||
const outcomePath = targetStopPhasePath(journal, generation, 'outcome');
|
||||
let target;
|
||||
try {
|
||||
target = parseTargetContainerEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
['container', 'inspect', command.request.expectedTargetContainerId],
|
||||
30_000,
|
||||
),
|
||||
command,
|
||||
application,
|
||||
'stopped',
|
||||
);
|
||||
if (
|
||||
target.identityDigest !== active.targetContainerIdentityDigest ||
|
||||
target.applicationBindingDigest !== active.targetApplicationBindingDigest
|
||||
) {
|
||||
configurationError('stopped target identity changed');
|
||||
}
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
outcomePath,
|
||||
targetStopSequence(generation, 'outcome'),
|
||||
request.recordDigest,
|
||||
'target_stop_result_unproved',
|
||||
);
|
||||
}
|
||||
const reconciliation = readTargetDataReconciliationEvidence(
|
||||
command,
|
||||
identity.uid,
|
||||
);
|
||||
const outcome = targetRunJournalRecord(
|
||||
command,
|
||||
targetStopSequence(generation, 'outcome'),
|
||||
'target_stopped',
|
||||
request.recordDigest,
|
||||
targetStoppedEvidence(active, reconciliation),
|
||||
);
|
||||
publishTargetRunJournalRecord(
|
||||
context,
|
||||
outcomePath,
|
||||
outcome,
|
||||
'target stopped commitment',
|
||||
);
|
||||
return result(context, 'prepared', outcome, reconciliation.disposition);
|
||||
}
|
||||
|
||||
export function stopLocalDeploymentDockerTargetCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalDeploymentTargetStopResult> {
|
||||
return stopLocalDeploymentDockerTarget(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import {
|
||||
normalizeLocalDeploymentTargetRunCommand,
|
||||
type LocalDeploymentTargetRunCommand,
|
||||
} from './target-run/targetRunContract';
|
||||
|
||||
export interface LocalDeploymentTargetStopCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.cutover.target-stop';
|
||||
readonly options: LocalDeploymentTargetRunCommand['options'];
|
||||
readonly request: LocalDeploymentTargetRunCommand['request'];
|
||||
}
|
||||
|
||||
export type LocalDeploymentTargetReconciliationDisposition =
|
||||
| 'rollback_candidate'
|
||||
| 'reconciliation_required'
|
||||
| 'manual_review';
|
||||
|
||||
export interface LocalDeploymentTargetStopResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.cutover.target-stop';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'target_stopped' | 'manual_required';
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly reconciliation: LocalDeploymentTargetReconciliationDisposition;
|
||||
readonly recordDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
export function normalizeLocalDeploymentTargetStopCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentTargetStopCommand> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError('command must be an object');
|
||||
}
|
||||
const command = value as Record<string, unknown>;
|
||||
if (command.operation !== 'local.deployment.cutover.target-stop') {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'target stop operation is invalid',
|
||||
);
|
||||
}
|
||||
const request = command.request as Record<string, unknown> | undefined;
|
||||
const syntheticOperation =
|
||||
request?.generation === 1
|
||||
? ('local.deployment.cutover.target-start' as const)
|
||||
: ('local.deployment.cutover.target-restart' as const);
|
||||
const normalized = normalizeLocalDeploymentTargetRunCommand({
|
||||
...command,
|
||||
operation: syntheticOperation,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.cutover.target-stop' as const,
|
||||
options: normalized.options,
|
||||
request: normalized.request,
|
||||
});
|
||||
}
|
||||
|
||||
export function targetStopRunCommand(
|
||||
command: Readonly<LocalDeploymentTargetStopCommand>,
|
||||
): Readonly<LocalDeploymentTargetRunCommand> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation:
|
||||
command.request.generation === 1
|
||||
? ('local.deployment.cutover.target-start' as const)
|
||||
: ('local.deployment.cutover.target-restart' as const),
|
||||
options: command.options,
|
||||
request: command.request,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import {
|
||||
verifyTargetDataReconciliationEvidence,
|
||||
type TargetDataReconciliationEvidence,
|
||||
} from './targetDataEvidence';
|
||||
import type { TargetRunJournalRecord } from './target-run/targetRunJournal';
|
||||
|
||||
export interface TargetStopActiveEvidence {
|
||||
readonly activeRecordDigest: string;
|
||||
readonly targetContainerIdentityDigest: string;
|
||||
readonly targetApplicationBindingDigest: string;
|
||||
readonly startupReceiptDigest: 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`);
|
||||
}
|
||||
}
|
||||
|
||||
export function targetStopRequestEvidence(
|
||||
active: Readonly<TargetStopActiveEvidence>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
activeRecordDigest: active.activeRecordDigest,
|
||||
startupReceiptDigest: active.startupReceiptDigest,
|
||||
targetApplicationBindingDigest: active.targetApplicationBindingDigest,
|
||||
targetContainerIdentityDigest: active.targetContainerIdentityDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTargetStopRequestEvidence(
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
active: Readonly<TargetStopActiveEvidence>,
|
||||
): void {
|
||||
const evidence = object(record.evidence, 'target stop request evidence');
|
||||
exact(
|
||||
evidence,
|
||||
[
|
||||
'activeRecordDigest',
|
||||
'startupReceiptDigest',
|
||||
'targetApplicationBindingDigest',
|
||||
'targetContainerIdentityDigest',
|
||||
],
|
||||
'target stop request evidence',
|
||||
);
|
||||
if (
|
||||
evidence.activeRecordDigest !== active.activeRecordDigest ||
|
||||
evidence.startupReceiptDigest !== active.startupReceiptDigest ||
|
||||
evidence.targetApplicationBindingDigest !==
|
||||
active.targetApplicationBindingDigest ||
|
||||
evidence.targetContainerIdentityDigest !==
|
||||
active.targetContainerIdentityDigest
|
||||
) {
|
||||
configurationError('target stop request evidence drifted');
|
||||
}
|
||||
}
|
||||
|
||||
export function targetStoppedEvidence(
|
||||
active: Readonly<TargetStopActiveEvidence>,
|
||||
reconciliation: Readonly<TargetDataReconciliationEvidence>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
activeRecordDigest: active.activeRecordDigest,
|
||||
startupReceiptDigest: active.startupReceiptDigest,
|
||||
targetApplicationBindingDigest: active.targetApplicationBindingDigest,
|
||||
targetContainerIdentityDigest: active.targetContainerIdentityDigest,
|
||||
reconciliation,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTargetStoppedEvidence(
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
active: Readonly<TargetStopActiveEvidence>,
|
||||
): Readonly<TargetDataReconciliationEvidence> {
|
||||
const evidence = object(record.evidence, 'target stopped evidence');
|
||||
exact(
|
||||
evidence,
|
||||
[
|
||||
'activeRecordDigest',
|
||||
'reconciliation',
|
||||
'startupReceiptDigest',
|
||||
'targetApplicationBindingDigest',
|
||||
'targetContainerIdentityDigest',
|
||||
],
|
||||
'target stopped evidence',
|
||||
);
|
||||
if (
|
||||
evidence.activeRecordDigest !== active.activeRecordDigest ||
|
||||
evidence.startupReceiptDigest !== active.startupReceiptDigest ||
|
||||
evidence.targetApplicationBindingDigest !==
|
||||
active.targetApplicationBindingDigest ||
|
||||
evidence.targetContainerIdentityDigest !==
|
||||
active.targetContainerIdentityDigest
|
||||
) {
|
||||
configurationError('target stopped evidence drifted');
|
||||
}
|
||||
return verifyTargetDataReconciliationEvidence(evidence.reconciliation);
|
||||
}
|
||||
Reference in New Issue
Block a user