feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,981 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import {
createLocalSqliteRolloutBackup,
inspectLocalSqliteRolloutBackup,
LOCAL_SQLITE_WRITE_CONTRACT_VERSION,
openLocalSqliteChangeObserver,
type LocalSqliteRolloutBackupEvidence,
type LocalSqliteRolloutBackupOptions,
} from '@qinglong/local-sqlite/rollout-safety';
import { preflightLocalDeploymentCompose } from './composePreflight';
import { evidenceDigest, inspectCollectedEvidence } from './composeEvidence';
import {
inspectActiveComposeImageSelection,
inspectComposeImageSelectionGeneration,
switchLocalDeploymentComposeRevision,
type ComposeImageSelection,
} from './composeRevision';
import {
currentIdentity,
LocalDeploymentConfigurationError,
normalizeLocalDeploymentComposeApplyCommand,
type LocalDeploymentComposeApplyCommand,
type LocalDeploymentComposeApplyResult,
type LocalDeploymentProfile,
} from '../foundation/contract';
import {
runLocalDeploymentDockerCommand,
validateLocalDeploymentDockerSocket,
type LocalDeploymentDockerRunner,
} from '../foundation/docker';
import {
preflightPublishedFile,
publishExactFile,
syncPublishedDirectory,
validatePrivateDirectory,
} from '../foundation/files';
import {
deploymentPaths,
type LocalDeploymentPaths,
} from '../foundation/render';
const RECEIPT_SCHEMA = 'qinglong/local-compose-rollout-receipt@v2';
const CONTAINER_ID_PATTERN = /^[0-9a-f]{12,64}$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const MAX_RECEIPT_BYTES = 64 * 1024;
const MAX_RETAINED_ROLLOUT_BACKUPS = 8;
export interface LocalDeploymentComposeApplyDependencies {
readonly runDocker?: LocalDeploymentDockerRunner;
readonly validateSocket?: (socketPath: string, uid: number) => void;
readonly now?: () => number;
readonly wait?: (milliseconds: number) => Promise<void>;
readonly createBackup?: typeof createLocalSqliteRolloutBackup;
readonly inspectBackup?: typeof inspectLocalSqliteRolloutBackup;
readonly openChangeObserver?: typeof openLocalSqliteChangeObserver;
}
interface ActiveEvidence {
readonly digest: string;
}
type SqliteWriteObservation = 'unchanged' | 'changed' | 'recovery_unknown';
interface RolloutBackupReceipt {
readonly sha256: string;
readonly bytes: number;
readonly pageCount: number;
readonly pageSize: number;
}
interface RolloutSqliteReceipt {
readonly contractVersion: number;
readonly writeContractVersion: number;
readonly writeObservation: SqliteWriteObservation;
readonly backup: Readonly<RolloutBackupReceipt> | null;
}
interface RolloutReceipt {
readonly schema: typeof RECEIPT_SCHEMA;
readonly commandDigest: string;
readonly rolloutId: string;
readonly attemptedGeneration: number;
readonly recordedAtMs: number;
readonly healthEventDigest: string | null;
readonly sqlite: Readonly<RolloutSqliteReceipt>;
readonly result: Readonly<LocalDeploymentComposeApplyResult>;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
function digest(value: string): string {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
function commandDigest(
command: Readonly<LocalDeploymentComposeApplyCommand>,
): string {
return digest(JSON.stringify(command));
}
function composeArgs(
paths: Readonly<LocalDeploymentPaths>,
args: readonly string[],
): readonly string[] {
return [
'compose',
'--project-directory',
paths.service,
'-f',
path.join(paths.service, 'compose.yaml'),
'-f',
paths.composeSelection,
...args,
];
}
function docker(
command: Readonly<LocalDeploymentComposeApplyCommand>,
runDocker: LocalDeploymentDockerRunner,
args: readonly string[],
timeoutMs = 30_000,
): string {
return runDocker({
executable: command.options.dockerExecutable,
socketPath: command.options.dockerSocketPath,
args,
timeoutMs,
});
}
function applyActiveSelection(
command: Readonly<LocalDeploymentComposeApplyCommand>,
paths: Readonly<LocalDeploymentPaths>,
runDocker: LocalDeploymentDockerRunner,
): void {
docker(
command,
runDocker,
composeArgs(paths, [
'up',
'--detach',
'--force-recreate',
'--no-build',
'--pull',
'never',
'--remove-orphans',
'qinglong3',
]),
60_000,
);
}
function stopFailedSelection(
command: Readonly<LocalDeploymentComposeApplyCommand>,
paths: Readonly<LocalDeploymentPaths>,
runDocker: LocalDeploymentDockerRunner,
): void {
docker(
command,
runDocker,
composeArgs(paths, ['stop', '--timeout', '30', 'qinglong3']),
45_000,
);
}
function parseContainerIdentity(value: string): string | null {
const containerId = value.trim();
if (!CONTAINER_ID_PATTERN.test(containerId)) return null;
return containerId;
}
function inspectRunningContainer(
output: string,
containerId: string,
selection: Readonly<ComposeImageSelection>,
): boolean {
let value: unknown;
try {
value = JSON.parse(output);
} catch {
return false;
}
const inspected = Array.isArray(value) ? value[0] : undefined;
const candidate = inspected as
| {
readonly Id?: unknown;
readonly State?: {
readonly Running?: unknown;
readonly Status?: unknown;
};
readonly Config?: {
readonly Image?: unknown;
readonly Labels?: Readonly<Record<string, unknown>>;
};
readonly HostConfig?: {
readonly ReadonlyRootfs?: unknown;
readonly NetworkMode?: unknown;
readonly Privileged?: unknown;
};
}
| undefined;
return (
(candidate?.Id === containerId ||
(typeof candidate?.Id === 'string' &&
candidate.Id.startsWith(containerId))) &&
candidate?.State?.Running === true &&
candidate.State.Status === 'running' &&
candidate.Config?.Image === selection.image &&
candidate.Config.Labels?.['io.qinglong.deployment.generation'] ===
String(selection.generation) &&
candidate.Config.Labels?.['io.qinglong.deployment.mutation'] ===
selection.mutationId &&
candidate.HostConfig?.ReadonlyRootfs === true &&
candidate.HostConfig.NetworkMode === 'none' &&
candidate.HostConfig.Privileged !== true
);
}
function activeEventEvidence(
logs: string,
profile: LocalDeploymentProfile,
): Readonly<ActiveEvidence> | null {
const lines = logs
.split('\n')
.filter((line) => line.length > 0)
.slice(-256);
for (let index = lines.length - 1; index >= 0; index -= 1) {
let value: unknown;
try {
value = JSON.parse(lines[index]!);
} catch {
continue;
}
const event = value as Readonly<Record<string, unknown>>;
if (
event.schemaVersion === 1 &&
event.component === 'qinglong3-local-application' &&
event.level === 'info' &&
event.event === 'active' &&
event.profile === profile &&
event.aiStatus === 'deployment_excluded' &&
typeof event.instanceId === 'string'
) {
return Object.freeze({ digest: digest(JSON.stringify(event)) });
}
}
return null;
}
async function observeActive(
command: Readonly<LocalDeploymentComposeApplyCommand>,
paths: Readonly<LocalDeploymentPaths>,
selection: Readonly<ComposeImageSelection>,
profile: LocalDeploymentProfile,
dependencies: Required<
Pick<LocalDeploymentComposeApplyDependencies, 'runDocker' | 'now' | 'wait'>
>,
): Promise<Readonly<ActiveEvidence> | null> {
const timeoutMs = profile === 'edge' ? 30_000 : 60_000;
const deadline = dependencies.now() + timeoutMs;
const maxAttempts = profile === 'edge' ? 120 : 240;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const containerId = parseContainerIdentity(
docker(
command,
dependencies.runDocker,
composeArgs(paths, ['ps', '--all', '--quiet', 'qinglong3']),
),
);
if (containerId) {
const running = inspectRunningContainer(
docker(command, dependencies.runDocker, [
'container',
'inspect',
containerId,
]),
containerId,
selection,
);
if (running) {
const evidence = activeEventEvidence(
docker(command, dependencies.runDocker, [
'container',
'logs',
'--tail',
'256',
containerId,
]),
profile,
);
if (evidence) return evidence;
}
}
} catch {
// A starting or failed candidate is handled by the bounded observation
// window and the generation-fenced rollback below.
}
if (dependencies.now() >= deadline) return null;
await dependencies.wait(250);
}
return null;
}
function backupPathFor(
paths: Readonly<LocalDeploymentPaths>,
rolloutId: string,
): string {
return path.join(paths.composeRolloutBackups, `${rolloutId}.sqlite`);
}
function backupOptions(
command: Readonly<LocalDeploymentComposeApplyCommand>,
paths: Readonly<LocalDeploymentPaths>,
profile: LocalDeploymentProfile,
): Readonly<LocalSqliteRolloutBackupOptions> {
return Object.freeze({
databasePath: paths.database,
backupPath: backupPathFor(paths, command.request.rolloutId),
profile,
});
}
function preflightBackupCatalog(
paths: Readonly<LocalDeploymentPaths>,
rolloutId: string,
): void {
const finalName = `${rolloutId}.sqlite`;
const stageName = `.${finalName}.ql3-backup-stage`;
const entries = fs.readdirSync(paths.composeRolloutBackups, {
withFileTypes: true,
});
let retained = 0;
for (const entry of entries) {
if (
entry.isFile() &&
UUID_V4_PATTERN.test(entry.name.replace(/\.sqlite$/, '')) &&
entry.name.endsWith('.sqlite')
) {
retained += 1;
continue;
}
if (entry.isFile() && entry.name === stageName) continue;
configurationError('compose rollout backup catalog contains drift');
}
if (
retained > MAX_RETAINED_ROLLOUT_BACKUPS ||
(retained === MAX_RETAINED_ROLLOUT_BACKUPS &&
!entries.some((entry) => entry.name === finalName))
) {
configurationError('compose rollout backup retention limit is reached');
}
}
function backupReceipt(
evidence: Readonly<LocalSqliteRolloutBackupEvidence> | null,
): Readonly<RolloutBackupReceipt> | null {
if (evidence === null) return null;
return Object.freeze({
sha256: evidence.sha256,
bytes: evidence.bytes,
pageCount: evidence.pageCount,
pageSize: evidence.pageSize,
});
}
function sqliteReceipt(
backup: Readonly<LocalSqliteRolloutBackupEvidence> | null,
writeObservation: SqliteWriteObservation,
): Readonly<RolloutSqliteReceipt> {
return Object.freeze({
contractVersion: LOCAL_SQLITE_WRITE_CONTRACT_VERSION,
writeContractVersion: LOCAL_SQLITE_WRITE_CONTRACT_VERSION,
writeObservation,
backup: backupReceipt(backup),
});
}
async function applyAndObserveCandidate(
command: Readonly<LocalDeploymentComposeApplyCommand>,
paths: Readonly<LocalDeploymentPaths>,
selection: Readonly<ComposeImageSelection>,
profile: LocalDeploymentProfile,
dependencies: Required<
Pick<
LocalDeploymentComposeApplyDependencies,
'runDocker' | 'now' | 'wait' | 'openChangeObserver'
>
>,
): Promise<
Readonly<{
evidence: Readonly<ActiveEvidence> | null;
writeObservation: SqliteWriteObservation;
}>
> {
const observer = dependencies.openChangeObserver({
databasePath: paths.database,
profile,
});
let evidence: Readonly<ActiveEvidence> | null = null;
try {
try {
applyActiveSelection(command, paths, dependencies.runDocker);
evidence = await observeActive(
command,
paths,
selection,
profile,
dependencies,
);
} catch {
evidence = null;
}
let writeObservation: SqliteWriteObservation;
try {
writeObservation = observer.changed() ? 'changed' : 'unchanged';
} catch (error) {
if (evidence) throw error;
writeObservation = 'recovery_unknown';
}
return Object.freeze({ evidence, writeObservation });
} finally {
observer.close();
}
}
function result(
status: LocalDeploymentComposeApplyResult['status'],
attemptedGeneration: number,
activeGeneration: number | null,
profile: LocalDeploymentProfile,
): Readonly<LocalDeploymentComposeApplyResult> {
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.compose.apply' as const,
status,
attemptedGeneration,
activeGeneration,
profile,
health: Object.freeze({
event:
status === 'failed_stopped'
? ('unavailable' as const)
: ('active' as const),
}),
service: Object.freeze({ kind: 'compose' as const }),
});
}
function receiptContents(receipt: Readonly<RolloutReceipt>): string {
return `${JSON.stringify(receipt, null, 2)}\n`;
}
async function readReceipt(
filePath: string,
uid: number,
expectedCommandDigest: string,
rolloutId: string,
expectedGeneration: number,
paths: Readonly<LocalDeploymentPaths>,
inspectBackup: typeof inspectLocalSqliteRolloutBackup,
): Promise<Readonly<LocalDeploymentComposeApplyResult> | null> {
if (!fs.existsSync(filePath)) return null;
const stat = fs.lstatSync(filePath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o600 ||
(stat.nlink !== 1 && stat.nlink !== 2) ||
stat.size < 2 ||
stat.size > MAX_RECEIPT_BYTES
) {
configurationError('compose rollout receipt identity is invalid');
}
const contents = fs.readFileSync(filePath, 'utf8');
let receipt: RolloutReceipt;
try {
receipt = JSON.parse(contents) as RolloutReceipt;
} catch (error) {
configurationError('compose rollout receipt is invalid', error);
}
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) {
configurationError('compose rollout receipt shape is invalid');
}
const receiptKeys = Object.keys(receipt).sort();
const resultKeys = Object.keys(receipt.result ?? {}).sort();
const healthKeys = Object.keys(receipt.result?.health ?? {}).sort();
const serviceKeys = Object.keys(receipt.result?.service ?? {}).sort();
const sqliteKeys = Object.keys(receipt.sqlite ?? {}).sort();
const backupKeys = Object.keys(receipt.sqlite?.backup ?? {}).sort();
const expectedReceiptKeys = [
'attemptedGeneration',
'commandDigest',
'healthEventDigest',
'recordedAtMs',
'result',
'rolloutId',
'schema',
'sqlite',
].sort();
const expectedResultKeys = [
'activeGeneration',
'attemptedGeneration',
'health',
'operation',
'profile',
'schemaVersion',
'service',
'status',
].sort();
const expectedSqliteKeys = [
'backup',
'contractVersion',
'writeContractVersion',
'writeObservation',
].sort();
const expectedBackupKeys = [
'bytes',
'pageCount',
'pageSize',
'sha256',
].sort();
const validStatus =
receipt.result?.status === 'active' ||
receipt.result?.status === 'rolled_back' ||
receipt.result?.status === 'failed_stopped';
const expectedActiveGeneration =
receipt.result?.status === 'active'
? expectedGeneration
: receipt.result?.status === 'rolled_back'
? expectedGeneration + 1
: null;
const canonicalReceipt: RolloutReceipt = {
schema: receipt.schema,
commandDigest: receipt.commandDigest,
rolloutId: receipt.rolloutId,
attemptedGeneration: receipt.attemptedGeneration,
recordedAtMs: receipt.recordedAtMs,
healthEventDigest: receipt.healthEventDigest,
sqlite: {
contractVersion: receipt.sqlite?.contractVersion,
writeContractVersion: receipt.sqlite?.writeContractVersion,
writeObservation: receipt.sqlite?.writeObservation,
backup:
receipt.sqlite?.backup === null
? null
: {
sha256: receipt.sqlite?.backup?.sha256,
bytes: receipt.sqlite?.backup?.bytes,
pageCount: receipt.sqlite?.backup?.pageCount,
pageSize: receipt.sqlite?.backup?.pageSize,
},
} as RolloutSqliteReceipt,
result: {
schemaVersion: receipt.result?.schemaVersion,
operation: receipt.result?.operation,
status: receipt.result?.status,
attemptedGeneration: receipt.result?.attemptedGeneration,
activeGeneration: receipt.result?.activeGeneration,
profile: receipt.result?.profile,
health: {
event: receipt.result?.health?.event,
},
service: {
kind: receipt.result?.service?.kind,
},
} as LocalDeploymentComposeApplyResult,
};
if (
contents !== receiptContents(canonicalReceipt) ||
receiptKeys.length !== expectedReceiptKeys.length ||
receiptKeys.some((key, index) => key !== expectedReceiptKeys[index]) ||
resultKeys.length !== expectedResultKeys.length ||
resultKeys.some((key, index) => key !== expectedResultKeys[index]) ||
healthKeys.length !== 1 ||
healthKeys[0] !== 'event' ||
serviceKeys.length !== 1 ||
serviceKeys[0] !== 'kind' ||
sqliteKeys.length !== expectedSqliteKeys.length ||
sqliteKeys.some((key, index) => key !== expectedSqliteKeys[index]) ||
(receipt.sqlite?.backup === null
? backupKeys.length !== 0
: backupKeys.length !== expectedBackupKeys.length ||
backupKeys.some((key, index) => key !== expectedBackupKeys[index])) ||
receipt.schema !== RECEIPT_SCHEMA ||
receipt.commandDigest !== expectedCommandDigest ||
receipt.rolloutId !== rolloutId ||
receipt.attemptedGeneration !== expectedGeneration ||
!Number.isSafeInteger(receipt.recordedAtMs) ||
receipt.recordedAtMs < 0 ||
(receipt.healthEventDigest !== null &&
(typeof receipt.healthEventDigest !== 'string' ||
!/^[0-9a-f]{64}$/.test(receipt.healthEventDigest))) ||
receipt.sqlite?.contractVersion !== LOCAL_SQLITE_WRITE_CONTRACT_VERSION ||
receipt.sqlite?.writeContractVersion !==
LOCAL_SQLITE_WRITE_CONTRACT_VERSION ||
(receipt.sqlite?.writeObservation !== 'unchanged' &&
receipt.sqlite?.writeObservation !== 'changed' &&
receipt.sqlite?.writeObservation !== 'recovery_unknown') ||
(expectedGeneration === 1
? receipt.sqlite?.backup !== null
: !receipt.sqlite?.backup) ||
(receipt.sqlite?.backup !== null &&
(typeof receipt.sqlite?.backup?.sha256 !== 'string' ||
!/^[0-9a-f]{64}$/.test(receipt.sqlite.backup.sha256) ||
!Number.isSafeInteger(receipt.sqlite.backup.bytes) ||
receipt.sqlite.backup.bytes < 1 ||
!Number.isSafeInteger(receipt.sqlite.backup.pageCount) ||
receipt.sqlite.backup.pageCount < 1 ||
!Number.isSafeInteger(receipt.sqlite.backup.pageSize) ||
receipt.sqlite.backup.pageSize < 512 ||
receipt.sqlite.backup.pageSize > 65_536)) ||
!receipt.result ||
receipt.result.schemaVersion !== 1 ||
receipt.result.operation !== 'local.deployment.compose.apply' ||
!validStatus ||
receipt.result.attemptedGeneration !== expectedGeneration ||
receipt.result.activeGeneration !== expectedActiveGeneration ||
(receipt.result.profile !== 'edge' &&
receipt.result.profile !== 'standalone') ||
receipt.result.health?.event !==
(receipt.result.status === 'failed_stopped' ? 'unavailable' : 'active') ||
receipt.result.service?.kind !== 'compose' ||
(receipt.result.status === 'failed_stopped'
? receipt.healthEventDigest !== null
: receipt.healthEventDigest === null)
) {
configurationError('compose rollout receipt drifted');
}
if (receipt.sqlite.backup !== null) {
const backupPath = backupPathFor(paths, rolloutId);
if (fs.existsSync(backupPath)) {
const inspected = await inspectBackup({
databasePath: paths.database,
backupPath,
profile: receipt.result.profile,
});
if (
inspected.contractVersion !== receipt.sqlite.contractVersion ||
inspected.writeContractVersion !==
receipt.sqlite.writeContractVersion ||
inspected.sha256 !== receipt.sqlite.backup.sha256 ||
inspected.bytes !== receipt.sqlite.backup.bytes ||
inspected.pageCount !== receipt.sqlite.backup.pageCount ||
inspected.pageSize !== receipt.sqlite.backup.pageSize
) {
configurationError('compose rollout backup receipt drifted');
}
} else if (
!inspectCollectedEvidence(
paths,
uid,
LOCAL_SQLITE_WRITE_CONTRACT_VERSION,
{
kind: 'rollout-backup',
artifactId: rolloutId,
sourceReceiptDigest: evidenceDigest(contents),
snapshot: {
contractVersion: receipt.sqlite.contractVersion,
sha256: receipt.sqlite.backup.sha256,
bytes: receipt.sqlite.backup.bytes,
pageCount: receipt.sqlite.backup.pageCount,
pageSize: receipt.sqlite.backup.pageSize,
},
},
)
) {
configurationError('compose rollout backup is unavailable');
}
}
publishExactFile(filePath, contents, 0o600, uid, 'compose rollout receipt');
return Object.freeze({
...receipt.result,
health: Object.freeze({ ...receipt.result.health }),
service: Object.freeze({ ...receipt.result.service }),
});
}
function releaseLock(lockPath: string, intent: string, uid: number): void {
if (!fs.existsSync(lockPath)) return;
preflightPublishedFile(lockPath, intent, 0o600, uid, 'compose rollout lock');
fs.unlinkSync(lockPath);
syncPublishedDirectory(path.dirname(lockPath));
}
function publishReceipt(
filePath: string,
command: Readonly<LocalDeploymentComposeApplyCommand>,
rolloutResult: Readonly<LocalDeploymentComposeApplyResult>,
evidence: Readonly<ActiveEvidence> | null,
sqlite: Readonly<RolloutSqliteReceipt>,
uid: number,
): void {
publishExactFile(
filePath,
receiptContents({
schema: RECEIPT_SCHEMA,
commandDigest: commandDigest(command),
rolloutId: command.request.rolloutId,
attemptedGeneration: command.request.expectedGeneration,
recordedAtMs: command.request.failureRollbackChangedAtMs,
healthEventDigest: evidence?.digest ?? null,
sqlite,
result: rolloutResult,
}),
0o600,
uid,
'compose rollout receipt',
);
}
function applyCommand(
command: Readonly<LocalDeploymentComposeApplyCommand>,
generation: number,
): Readonly<LocalDeploymentComposeApplyCommand> {
return Object.freeze({
...command,
request: Object.freeze({
...command.request,
expectedGeneration: generation,
}),
});
}
export async function applyLocalDeploymentCompose(
input: unknown,
dependencies: LocalDeploymentComposeApplyDependencies = {},
): Promise<Readonly<LocalDeploymentComposeApplyResult>> {
const command = normalizeLocalDeploymentComposeApplyCommand(input);
const identity = currentIdentity();
const paths = deploymentPaths(command.options.deploymentRoot);
validatePrivateDirectory(
command.options.deploymentRoot,
identity.uid,
'deploymentRoot',
);
validatePrivateDirectory(
paths.service,
identity.uid,
'serviceDescriptorRoot',
);
validatePrivateDirectory(
paths.composeRevisions,
identity.uid,
'composeRevisionRoot',
);
validatePrivateDirectory(
paths.composeRollouts,
identity.uid,
'composeRolloutRoot',
);
validatePrivateDirectory(
paths.composeRolloutBackups,
identity.uid,
'composeRolloutBackupRoot',
);
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
configurationError('compose rollout is fenced by an evidence collection');
}
if (fs.existsSync(paths.composeRestoreLock)) {
configurationError('compose rollout is fenced by an in-flight restore');
}
preflightBackupCatalog(paths, command.request.rolloutId);
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)));
const createBackup =
dependencies.createBackup ?? createLocalSqliteRolloutBackup;
const inspectBackup =
dependencies.inspectBackup ?? inspectLocalSqliteRolloutBackup;
const openChangeObserver =
dependencies.openChangeObserver ?? openLocalSqliteChangeObserver;
const receiptPath = path.join(
paths.composeRollouts,
`${command.request.rolloutId}.json`,
);
const intent = `${JSON.stringify(command, null, 2)}\n`;
const replay = await readReceipt(
receiptPath,
identity.uid,
commandDigest(command),
command.request.rolloutId,
command.request.expectedGeneration,
paths,
inspectBackup,
);
if (replay) {
releaseLock(paths.composeRolloutLock, intent, identity.uid);
return replay;
}
publishExactFile(
paths.composeRolloutLock,
intent,
0o600,
identity.uid,
'compose rollout lock',
);
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
releaseLock(paths.composeRolloutLock, intent, identity.uid);
configurationError('compose rollout is fenced by an evidence collection');
}
let selection = inspectActiveComposeImageSelection(
paths.composeSelection,
paths.composeRevisions,
identity.uid,
);
const attempted = inspectComposeImageSelectionGeneration(
paths.composeRevisions,
command.request.expectedGeneration,
identity.uid,
);
if (
selection.generation !== command.request.expectedGeneration &&
!(
selection.generation === command.request.expectedGeneration + 1 &&
selection.previousGeneration === command.request.expectedGeneration &&
selection.rollbackTargetGeneration === attempted.previousGeneration &&
selection.mutationId === command.request.failureRollbackMutationId
)
) {
configurationError(
'active compose generation does not match the rollout recovery state',
);
}
let rolloutBackup: Readonly<LocalSqliteRolloutBackupEvidence> | null = null;
let writeObservation: SqliteWriteObservation = 'recovery_unknown';
if (selection.generation === command.request.expectedGeneration) {
const preflight = await preflightLocalDeploymentCompose(
{
schemaVersion: 1,
operation: 'local.deployment.compose.preflight',
options: command.options,
request: {
expectedGeneration: command.request.expectedGeneration,
},
},
{ runDocker, validateSocket },
);
if (selection.previousGeneration >= 1) {
rolloutBackup = await createBackup(
backupOptions(command, paths, preflight.profile),
);
}
const candidate = await applyAndObserveCandidate(
command,
paths,
selection,
preflight.profile,
{ runDocker, now, wait, openChangeObserver },
);
const evidence = candidate.evidence;
writeObservation = candidate.writeObservation;
if (evidence) {
const active = result(
'active',
command.request.expectedGeneration,
command.request.expectedGeneration,
preflight.profile,
);
publishReceipt(
receiptPath,
command,
active,
evidence,
sqliteReceipt(rolloutBackup, writeObservation),
identity.uid,
);
releaseLock(paths.composeRolloutLock, intent, identity.uid);
return active;
}
if (selection.previousGeneration < 1) {
stopFailedSelection(command, paths, runDocker);
const stopped = result(
'failed_stopped',
command.request.expectedGeneration,
null,
preflight.profile,
);
publishReceipt(
receiptPath,
command,
stopped,
null,
sqliteReceipt(null, writeObservation),
identity.uid,
);
releaseLock(paths.composeRolloutLock, intent, identity.uid);
return stopped;
}
await switchLocalDeploymentComposeRevision(
{
schemaVersion: 1,
operation: 'local.deployment.compose.rollback',
options: {
deploymentRoot: command.options.deploymentRoot,
allowRootService: command.options.allowRootService,
},
request: {
expectedGeneration: selection.generation,
targetGeneration: selection.previousGeneration,
mutationId: command.request.failureRollbackMutationId,
changedAtMs: command.request.failureRollbackChangedAtMs,
},
},
intent,
);
selection = inspectActiveComposeImageSelection(
paths.composeSelection,
paths.composeRevisions,
identity.uid,
);
}
const rollbackCommand = applyCommand(command, selection.generation);
const rollbackPreflight = await preflightLocalDeploymentCompose(
{
schemaVersion: 1,
operation: 'local.deployment.compose.preflight',
options: command.options,
request: { expectedGeneration: selection.generation },
},
{ runDocker, validateSocket },
);
if (command.request.expectedGeneration > 1 && rolloutBackup === null) {
rolloutBackup = await inspectBackup(
backupOptions(command, paths, rollbackPreflight.profile),
);
}
applyActiveSelection(rollbackCommand, paths, runDocker);
const rollbackEvidence = await observeActive(
rollbackCommand,
paths,
selection,
rollbackPreflight.profile,
{ runDocker, now, wait },
);
if (!rollbackEvidence) {
configurationError(
'compose rollback did not produce active health evidence',
);
}
const rolledBack = result(
'rolled_back',
command.request.expectedGeneration,
selection.generation,
rollbackPreflight.profile,
);
publishReceipt(
receiptPath,
command,
rolledBack,
rollbackEvidence,
sqliteReceipt(rolloutBackup, writeObservation),
identity.uid,
);
releaseLock(paths.composeRolloutLock, intent, identity.uid);
return rolledBack;
}
export function applyLocalDeploymentComposeCommandFile(
filePath: string,
): Promise<Readonly<LocalDeploymentComposeApplyResult>> {
return applyLocalDeploymentCompose(readPrivateLocalCommandFile(filePath));
}
@@ -0,0 +1,214 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { LocalDeploymentConfigurationError } from '../foundation/contract';
import { publishExactFile } from '../foundation/files';
import type { LocalDeploymentPaths } from '../foundation/render';
const TOMBSTONE_SCHEMA = 'qinglong/local-compose-collected-evidence@v1';
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const MAX_TOMBSTONE_BYTES = 16 * 1024;
const COMPOSE_EVIDENCE_SQLITE_CONTRACT_MIN_VERSION = 40;
export type ComposeEvidenceKind = 'rollout-backup' | 'restore-safeguard';
export interface ComposeSnapshotEvidence {
readonly contractVersion: number;
readonly sha256: string;
readonly bytes: number;
readonly pageCount: number;
readonly pageSize: number;
}
export interface ComposeCollectedEvidence {
readonly schema: typeof TOMBSTONE_SCHEMA;
readonly kind: ComposeEvidenceKind;
readonly artifactId: string;
readonly collectionId: string;
readonly generation: number;
readonly profile: 'edge' | 'standalone';
readonly sourceReceiptDigest: string;
readonly snapshot: Readonly<ComposeSnapshotEvidence>;
readonly collectedAtMs: number;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
export function evidenceDigest(value: string | Buffer): string {
return crypto.createHash('sha256').update(value).digest('hex');
}
export function collectedEvidencePath(
paths: Readonly<LocalDeploymentPaths>,
kind: ComposeEvidenceKind,
artifactId: string,
): string {
return path.join(
kind === 'rollout-backup'
? paths.composeCollectedRolloutBackups
: paths.composeCollectedRestoreSafeguards,
`${artifactId}.json`,
);
}
export function evidenceStagePath(artifactPath: string): string {
return path.join(
path.dirname(artifactPath),
`.${path.basename(artifactPath)}.ql3-collection-stage`,
);
}
function canonicalContents(
evidence: Readonly<ComposeCollectedEvidence>,
): string {
return `${JSON.stringify(evidence, null, 2)}\n`;
}
function validSnapshot(
value: unknown,
maximumContractVersion: number,
): value is ComposeSnapshotEvidence {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const candidate = value as Partial<ComposeSnapshotEvidence>;
return (
Object.keys(value).sort().join(',') ===
['bytes', 'contractVersion', 'pageCount', 'pageSize', 'sha256']
.sort()
.join(',') &&
Number.isSafeInteger(candidate.contractVersion) &&
(candidate.contractVersion as number) >=
COMPOSE_EVIDENCE_SQLITE_CONTRACT_MIN_VERSION &&
(candidate.contractVersion as number) <= maximumContractVersion &&
typeof candidate.sha256 === 'string' &&
SHA256_PATTERN.test(candidate.sha256) &&
Number.isSafeInteger(candidate.bytes) &&
(candidate.bytes as number) > 0 &&
Number.isSafeInteger(candidate.pageCount) &&
(candidate.pageCount as number) > 0 &&
Number.isSafeInteger(candidate.pageSize) &&
(candidate.pageSize as number) >= 512 &&
(candidate.pageSize as number) <= 65_536
);
}
function exactSnapshot(
expected: Readonly<ComposeSnapshotEvidence>,
actual: Readonly<ComposeSnapshotEvidence>,
): boolean {
return (
expected.contractVersion === actual.contractVersion &&
expected.sha256 === actual.sha256 &&
expected.bytes === actual.bytes &&
expected.pageCount === actual.pageCount &&
expected.pageSize === actual.pageSize
);
}
export function inspectCollectedEvidence(
paths: Readonly<LocalDeploymentPaths>,
uid: number,
maximumContractVersion: number,
expected: Readonly<{
kind: ComposeEvidenceKind;
artifactId: string;
sourceReceiptDigest: string;
snapshot: Readonly<ComposeSnapshotEvidence>;
}>,
): Readonly<ComposeCollectedEvidence> | null {
if (
!Number.isSafeInteger(maximumContractVersion) ||
maximumContractVersion < COMPOSE_EVIDENCE_SQLITE_CONTRACT_MIN_VERSION
) {
configurationError('compose evidence contract boundary is invalid');
}
const filePath = collectedEvidencePath(
paths,
expected.kind,
expected.artifactId,
);
if (!fs.existsSync(filePath)) return null;
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
configurationError('compose collected evidence is unavailable', error);
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o600 ||
stat.nlink !== 1 ||
stat.size < 2 ||
stat.size > MAX_TOMBSTONE_BYTES
) {
configurationError('compose collected evidence identity is invalid');
}
const contents = fs.readFileSync(filePath, 'utf8');
let value: unknown;
try {
value = JSON.parse(contents);
} catch (error) {
configurationError('compose collected evidence is invalid', error);
}
const evidence = value as ComposeCollectedEvidence;
if (
!evidence ||
typeof evidence !== 'object' ||
Array.isArray(evidence) ||
Object.keys(evidence).sort().join(',') !==
[
'artifactId',
'collectedAtMs',
'collectionId',
'generation',
'kind',
'profile',
'schema',
'snapshot',
'sourceReceiptDigest',
]
.sort()
.join(',') ||
evidence.schema !== TOMBSTONE_SCHEMA ||
evidence.kind !== expected.kind ||
evidence.artifactId !== expected.artifactId ||
!UUID_V4_PATTERN.test(evidence.artifactId) ||
!UUID_V4_PATTERN.test(evidence.collectionId) ||
!Number.isSafeInteger(evidence.generation) ||
evidence.generation < 1 ||
(evidence.profile !== 'edge' && evidence.profile !== 'standalone') ||
evidence.sourceReceiptDigest !== expected.sourceReceiptDigest ||
!SHA256_PATTERN.test(evidence.sourceReceiptDigest) ||
!validSnapshot(evidence.snapshot, maximumContractVersion) ||
!exactSnapshot(expected.snapshot, evidence.snapshot) ||
!Number.isSafeInteger(evidence.collectedAtMs) ||
evidence.collectedAtMs < 0 ||
contents !== canonicalContents(evidence)
) {
configurationError('compose collected evidence drifted');
}
return Object.freeze({
...evidence,
snapshot: Object.freeze({ ...evidence.snapshot }),
});
}
export function publishCollectedEvidence(
paths: Readonly<LocalDeploymentPaths>,
uid: number,
evidence: Readonly<ComposeCollectedEvidence>,
): void {
publishExactFile(
collectedEvidencePath(paths, evidence.kind, evidence.artifactId),
canonicalContents(evidence),
0o600,
uid,
'compose collected evidence',
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,475 @@
import fs from 'node:fs';
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import {
inspectLocalSqliteReadinessPath,
LOCAL_SQLITE_CONTRACT_VERSION,
} from '@qinglong/local-sqlite/readiness-inspection';
import {
currentIdentity,
LocalDeploymentConfigurationError,
normalizeLocalDeploymentComposePreflightCommand,
normalizeLocalDeploymentPrepareCommand,
type LocalDeploymentComposePreflightResult,
type LocalDeploymentProfile,
} from '../foundation/contract';
import { inspectActiveComposeImageSelection } from './composeRevision';
import {
runLocalDeploymentDockerCommand,
validateLocalDeploymentDockerSocket,
type LocalDeploymentDockerRunner,
} from '../foundation/docker';
import {
preflightPublishedFile,
validatePrivateDirectory,
} from '../foundation/files';
import {
applicationConfiguration,
composeProjectName,
deploymentPaths,
descriptor,
} from '../foundation/render';
const CONTAINER_ROOT = '/var/lib/qinglong3';
const MAX_DOCKER_OUTPUT_BYTES = 256 * 1024;
const SOURCE_REPOSITORY = 'https://github.com/whyour/qinglong';
const ENTRYPOINT = Object.freeze([
'node',
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
]);
export interface LocalDeploymentComposePreflightDependencies {
readonly runDocker?: LocalDeploymentDockerRunner;
readonly auditSqlite?: typeof inspectLocalSqliteReadinessPath;
readonly validateSocket?: (socketPath: string, uid: number) => void;
}
interface ApplicationIdentity {
readonly instanceId: string;
readonly profile: LocalDeploymentProfile;
readonly busyTimeoutMs?: number;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
function readApplicationIdentity(
filePath: string,
uid: number,
): Readonly<ApplicationIdentity> {
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
configurationError('application configuration is unavailable', error);
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o600 ||
stat.nlink !== 1 ||
stat.size < 2 ||
stat.size > 64 * 1024
) {
configurationError('application configuration identity is invalid');
}
let value: unknown;
try {
value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
configurationError('application configuration is invalid', error);
}
const candidate = value as {
readonly schema?: unknown;
readonly instanceId?: unknown;
readonly profile?: unknown;
readonly storage?: {
readonly mode?: unknown;
readonly databasePath?: unknown;
readonly busyTimeoutMs?: unknown;
};
};
if (
!candidate ||
typeof candidate !== 'object' ||
Array.isArray(candidate) ||
candidate.schema !== 'qinglong/local-application-process@v2' ||
typeof candidate.instanceId !== 'string' ||
(candidate.profile !== 'edge' && candidate.profile !== 'standalone') ||
!candidate.storage ||
candidate.storage.mode !== 'fresh' ||
candidate.storage.databasePath !== `${CONTAINER_ROOT}/qinglong3.sqlite`
) {
configurationError('application configuration is not a fresh Compose v2');
}
const busyTimeoutMs =
candidate.storage.busyTimeoutMs === undefined
? undefined
: candidate.storage.busyTimeoutMs;
if (
busyTimeoutMs !== undefined &&
(!Number.isSafeInteger(busyTimeoutMs) ||
(busyTimeoutMs as number) < 100 ||
(busyTimeoutMs as number) > 30_000)
) {
configurationError('application busyTimeoutMs is invalid');
}
return Object.freeze({
instanceId: candidate.instanceId,
profile: candidate.profile,
...(busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: busyTimeoutMs as number }),
});
}
function parseJson(value: string, label: string): unknown {
if (
Buffer.byteLength(value, 'utf8') < 2 ||
Buffer.byteLength(value, 'utf8') > MAX_DOCKER_OUTPUT_BYTES
) {
configurationError(`${label} output size is invalid`);
}
try {
return JSON.parse(value);
} catch (error) {
configurationError(`${label} output is not JSON`, error);
}
}
function safeLabel(
labels: Readonly<Record<string, unknown>>,
name: string,
): string {
const value = labels[name];
if (typeof value !== 'string' || value.length < 1 || value.length > 128) {
configurationError(`image label ${name} is invalid`);
}
return value;
}
function inspectImage(
output: string,
image: string,
profile: LocalDeploymentProfile,
sqliteContractVersion: number,
): 'amd64' | 'arm64' {
const parsed = parseJson(output, 'Docker image inspect') as unknown[];
if (!Array.isArray(parsed) || parsed.length !== 1) {
configurationError('Docker image inspect result is invalid');
}
const record = parsed[0] as {
readonly Id?: unknown;
readonly RepoDigests?: unknown;
readonly Architecture?: unknown;
readonly Os?: unknown;
readonly Config?: {
readonly User?: unknown;
readonly Entrypoint?: unknown;
readonly Labels?: unknown;
};
};
if (
!record ||
typeof record !== 'object' ||
typeof record.Id !== 'string' ||
!/^sha256:[0-9a-f]{64}$/.test(record.Id) ||
!Array.isArray(record.RepoDigests) ||
!record.RepoDigests.includes(image) ||
(record.Architecture !== 'amd64' && record.Architecture !== 'arm64') ||
record.Os !== 'linux' ||
record.Config?.User !== '65532:65532' ||
JSON.stringify(record.Config?.Entrypoint) !== JSON.stringify(ENTRYPOINT) ||
!record.Config?.Labels ||
typeof record.Config.Labels !== 'object' ||
Array.isArray(record.Config.Labels)
) {
configurationError('local image identity is incompatible');
}
const labels = record.Config.Labels as Readonly<Record<string, unknown>>;
const minimum = safeLabel(labels, 'io.qinglong.local.sqlite-contract-min');
const maximum = safeLabel(labels, 'io.qinglong.local.sqlite-contract-max');
const profiles = safeLabel(labels, 'io.qinglong.profile').split(',');
if (
!/^[1-9][0-9]{0,3}$/.test(minimum) ||
!/^[1-9][0-9]{0,3}$/.test(maximum) ||
Number(minimum) > sqliteContractVersion ||
Number(maximum) < sqliteContractVersion ||
safeLabel(labels, 'io.qinglong.local.sqlite-write-contract') !==
String(sqliteContractVersion) ||
safeLabel(labels, 'io.qinglong.local.application-config') !== '2' ||
safeLabel(labels, 'io.qinglong.local.compose-selection') !== '1' ||
safeLabel(labels, 'io.qinglong.ai') !== 'excluded' ||
!profiles.includes(profile) ||
safeLabel(labels, 'org.opencontainers.image.source') !==
SOURCE_REPOSITORY ||
!/^[0-9a-f]{40}$/.test(
safeLabel(labels, 'org.opencontainers.image.revision'),
) ||
!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(
safeLabel(labels, 'org.opencontainers.image.version'),
)
) {
configurationError('local image compatibility labels are invalid');
}
return record.Architecture;
}
function exactArray(value: unknown, expected: readonly string[]): boolean {
return (
Array.isArray(value) &&
JSON.stringify([...value].sort()) === JSON.stringify([...expected].sort())
);
}
function inspectComposeConfig(
output: string,
expected: Readonly<{
projectName: string;
image: string;
generation: number;
mutationId: string;
deploymentRoot: string;
uid: number;
gid: number;
profile: LocalDeploymentProfile;
}>,
): void {
const config = parseJson(output, 'Docker Compose config') as {
readonly name?: unknown;
readonly services?: unknown;
};
if (
!config ||
typeof config !== 'object' ||
Array.isArray(config) ||
config.name !== expected.projectName ||
!config.services ||
typeof config.services !== 'object' ||
Array.isArray(config.services) ||
Object.keys(config.services).length !== 1
) {
configurationError('Docker Compose project identity is invalid');
}
const service = (config.services as Readonly<Record<string, unknown>>)
.qinglong3 as Readonly<Record<string, unknown>> | undefined;
const expectedMemory =
(expected.profile === 'edge' ? 128 : 256) * 1024 * 1024;
const expectedPids = expected.profile === 'edge' ? 64 : 256;
if (
!service ||
typeof service !== 'object' ||
service.image !== expected.image ||
service.user !== `${expected.uid}:${expected.gid}` ||
service.read_only !== true ||
service.network_mode !== 'none' ||
service.restart !== 'unless-stopped' ||
Number(service.mem_limit) !== expectedMemory ||
Number(service.pids_limit) !== expectedPids ||
service.privileged === true ||
service.build !== undefined ||
service.ports !== undefined ||
service.devices !== undefined ||
service.environment !== undefined ||
!exactArray(service.cap_drop, ['ALL']) ||
!exactArray(service.security_opt, ['no-new-privileges:true']) ||
!exactArray(service.command, [
'--config',
`${CONTAINER_ROOT}/local-application.json`,
])
) {
configurationError('Docker Compose service contract is invalid');
}
const labels = service.labels as
| Readonly<Record<string, unknown>>
| undefined;
if (
!labels ||
Object.keys(labels).length !== 2 ||
labels['io.qinglong.deployment.generation'] !==
String(expected.generation) ||
labels['io.qinglong.deployment.mutation'] !== expected.mutationId
) {
configurationError('Docker Compose deployment labels are invalid');
}
const volumes = service.volumes as readonly unknown[] | undefined;
const volume = volumes?.[0] as Readonly<Record<string, unknown>> | undefined;
const tmpfs = service.tmpfs as readonly unknown[] | undefined;
if (
!Array.isArray(volumes) ||
volumes.length !== 1 ||
!volume ||
volume.type !== 'bind' ||
volume.source !== expected.deploymentRoot ||
volume.target !== CONTAINER_ROOT ||
!Array.isArray(tmpfs) ||
tmpfs.length !== 1 ||
typeof tmpfs[0] !== 'string' ||
!tmpfs[0].startsWith('/tmp:') ||
!tmpfs[0].includes('noexec') ||
!tmpfs[0].includes('nosuid') ||
!tmpfs[0].includes('nodev')
) {
configurationError('Docker Compose filesystem contract is invalid');
}
}
export async function preflightLocalDeploymentCompose(
input: unknown,
dependencies: LocalDeploymentComposePreflightDependencies = {},
): Promise<Readonly<LocalDeploymentComposePreflightResult>> {
const command = normalizeLocalDeploymentComposePreflightCommand(input);
const identity = currentIdentity();
const paths = deploymentPaths(command.options.deploymentRoot);
validatePrivateDirectory(
command.options.deploymentRoot,
identity.uid,
'deploymentRoot',
);
validatePrivateDirectory(
paths.service,
identity.uid,
'serviceDescriptorRoot',
);
validatePrivateDirectory(
paths.composeRevisions,
identity.uid,
'composeRevisionRoot',
);
const validateSocket =
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
validateSocket(command.options.dockerSocketPath, identity.uid);
const selection = inspectActiveComposeImageSelection(
paths.composeSelection,
paths.composeRevisions,
identity.uid,
);
if (selection.generation !== command.request.expectedGeneration) {
configurationError(
'active compose generation does not match expectedGeneration',
);
}
const application = readApplicationIdentity(
paths.applicationConfig,
identity.uid,
);
const syntheticPrepare = normalizeLocalDeploymentPrepareCommand({
schemaVersion: 1,
operation: 'local.deployment.prepare',
options: {
deploymentRoot: command.options.deploymentRoot,
profile: application.profile,
instanceId: application.instanceId,
...(application.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: application.busyTimeoutMs }),
service: {
kind: 'compose',
image: selection.image,
allowRootService: command.options.allowRootService,
},
},
request: {
ownerPepperKeyId: 'preflight',
registerMutationId: '00000000-0000-4000-8000-000000000001',
activateMutationId: '00000000-0000-4000-8000-000000000002',
registeredAtMs: 0,
activatedAtMs: 0,
},
});
preflightPublishedFile(
paths.applicationConfig,
applicationConfiguration(syntheticPrepare, paths),
0o600,
identity.uid,
'application configuration',
);
const expectedDescriptor = descriptor(
syntheticPrepare,
paths.applicationConfig,
identity.uid,
identity.gid,
);
preflightPublishedFile(
path.join(paths.service, expectedDescriptor.fileName),
expectedDescriptor.contents,
expectedDescriptor.mode,
identity.uid,
'service descriptor',
);
const auditSqlite =
dependencies.auditSqlite ?? inspectLocalSqliteReadinessPath;
const sqlite = await auditSqlite({
databasePath: paths.database,
profile: application.profile,
...(application.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: application.busyTimeoutMs }),
});
if (sqlite.contractVersion !== LOCAL_SQLITE_CONTRACT_VERSION) {
configurationError('SQLite contract version drifted');
}
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
const imageOutput = runDocker({
executable: command.options.dockerExecutable,
socketPath: command.options.dockerSocketPath,
args: ['image', 'inspect', selection.image],
});
const architecture = inspectImage(
imageOutput,
selection.image,
application.profile,
sqlite.contractVersion,
);
const composeOutput = runDocker({
executable: command.options.dockerExecutable,
socketPath: command.options.dockerSocketPath,
args: [
'compose',
'--project-directory',
paths.service,
'-f',
path.join(paths.service, 'compose.yaml'),
'-f',
paths.composeSelection,
'config',
'--format',
'json',
],
});
inspectComposeConfig(composeOutput, {
projectName: composeProjectName(application.instanceId),
image: selection.image,
generation: selection.generation,
mutationId: selection.mutationId,
deploymentRoot: command.options.deploymentRoot,
uid: identity.uid,
gid: identity.gid,
profile: application.profile,
});
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.compose.preflight' as const,
status: 'ready' as const,
generation: selection.generation,
profile: application.profile,
sqlite: Object.freeze({
contractVersion: sqlite.contractVersion,
}),
image: Object.freeze({ architecture }),
service: Object.freeze({ kind: 'compose' as const }),
});
}
export function preflightLocalDeploymentComposeCommandFile(
filePath: string,
): Promise<Readonly<LocalDeploymentComposePreflightResult>> {
return preflightLocalDeploymentCompose(readPrivateLocalCommandFile(filePath));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,425 @@
import fs from 'node:fs';
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import {
currentIdentity,
LocalDeploymentConfigurationError,
normalizeLocalDeploymentComposeRevisionCommand,
type LocalDeploymentComposeRevisionCommand,
type LocalDeploymentComposeRevisionResult,
type LocalDeploymentPrepareCommand,
} from '../foundation/contract';
import {
preflightPublishedFile,
publishExactFile,
replaceExactFile,
syncPublishedDirectory,
validatePrivateDirectory,
} from '../foundation/files';
import { deploymentPaths } from '../foundation/render';
const SELECTION_SCHEMA = 'qinglong/local-compose-image-selection@v1';
const IMAGE_PATTERN =
/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
export interface ComposeImageSelection {
readonly generation: number;
readonly previousGeneration: number;
readonly rollbackTargetGeneration: number;
readonly mutationId: string;
readonly changedAtMs: number;
readonly image: string;
}
function selectionContents(selection: Readonly<ComposeImageSelection>): string {
return [
'x-qinglong-image-selection:',
` schema: ${SELECTION_SCHEMA}`,
` generation: ${selection.generation}`,
` previous_generation: ${selection.previousGeneration}`,
` rollback_target_generation: ${selection.rollbackTargetGeneration}`,
` mutation_id: ${selection.mutationId}`,
` changed_at_ms: ${selection.changedAtMs}`,
'services:',
' qinglong3:',
` image: ${selection.image}`,
' labels:',
` io.qinglong.deployment.generation: "${selection.generation}"`,
` io.qinglong.deployment.mutation: "${selection.mutationId}"`,
'',
].join('\n');
}
function parseInteger(value: string | undefined, label: string): number {
if (!value || !/^(0|[1-9][0-9]{0,5})$/.test(value)) {
throw new LocalDeploymentConfigurationError(`${label} is invalid`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed > 100_000) {
throw new LocalDeploymentConfigurationError(`${label} is invalid`);
}
return parsed;
}
function parseSelection(
contents: string,
label: string,
): Readonly<ComposeImageSelection> {
const match =
/^x-qinglong-image-selection:\n schema: qinglong\/local-compose-image-selection@v1\n generation: (0|[1-9][0-9]{0,5})\n previous_generation: (0|[1-9][0-9]{0,5})\n rollback_target_generation: (0|[1-9][0-9]{0,5})\n mutation_id: ([0-9a-f-]+)\n changed_at_ms: ([0-9]+)\nservices:\n qinglong3:\n image: ([^\n]+)\n labels:\n io\.qinglong\.deployment\.generation: "([0-9]+)"\n io\.qinglong\.deployment\.mutation: "([0-9a-f-]+)"\n$/.exec(
contents,
);
if (!match) {
throw new LocalDeploymentConfigurationError(`${label} shape is invalid`);
}
const generation = parseInteger(match[1], `${label} generation`);
const previousGeneration = parseInteger(
match[2],
`${label} previous generation`,
);
const rollbackTargetGeneration = parseInteger(
match[3],
`${label} rollback target generation`,
);
const mutationId = match[4];
const changedAtMs = Number(match[5]);
const image = match[6];
const labelGeneration = Number(match[7]);
const labelMutationId = match[8];
if (
generation < 1 ||
previousGeneration !== generation - 1 ||
rollbackTargetGeneration >= generation ||
!Number.isSafeInteger(changedAtMs) ||
changedAtMs < 0 ||
!mutationId ||
!UUID_V4_PATTERN.test(mutationId) ||
!image ||
!IMAGE_PATTERN.test(image) ||
image.includes('..') ||
image.includes('//') ||
labelGeneration !== generation ||
labelMutationId !== mutationId
) {
throw new LocalDeploymentConfigurationError(`${label} value is invalid`);
}
const selection = Object.freeze({
generation,
previousGeneration,
rollbackTargetGeneration,
mutationId,
changedAtMs,
image,
});
if (selectionContents(selection) !== contents) {
throw new LocalDeploymentConfigurationError(`${label} is not canonical`);
}
return selection;
}
function readSelectionFile(
filePath: string,
uid: number,
label: string,
): Readonly<{ contents: string; selection: Readonly<ComposeImageSelection> }> {
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
throw new LocalDeploymentConfigurationError(`${label} is unavailable`, {
cause: error,
});
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o600 ||
stat.nlink !== 1 ||
stat.size < 2 ||
stat.size > 64 * 1024
) {
throw new LocalDeploymentConfigurationError(`${label} identity is invalid`);
}
const contents = fs.readFileSync(filePath, 'utf8');
return Object.freeze({
contents,
selection: parseSelection(contents, label),
});
}
function revisionPath(root: string, generation: number): string {
return path.join(root, `${generation}.yaml`);
}
function commandIntent(
command: Readonly<LocalDeploymentComposeRevisionCommand>,
): string {
return `${JSON.stringify(command, null, 2)}\n`;
}
function releaseLock(lockPath: string, intent: string, uid: number): void {
preflightPublishedFile(lockPath, intent, 0o600, uid, 'compose revision lock');
fs.unlinkSync(lockPath);
syncPublishedDirectory(path.dirname(lockPath));
}
export function initialComposeImageSelection(
command: Readonly<LocalDeploymentPrepareCommand>,
): string {
if (command.options.service.kind !== 'compose') {
throw new LocalDeploymentConfigurationError(
'initial compose selection requires a compose service',
);
}
return selectionContents({
generation: 1,
previousGeneration: 0,
rollbackTargetGeneration: 0,
mutationId: command.request.activateMutationId,
changedAtMs: command.request.activatedAtMs,
image: command.options.service.image,
});
}
export function preflightActiveComposeImageSelection(
selectionPath: string,
revisionsRoot: string,
initialContents: string,
uid: number,
): 'absent' | 'existing' {
if (!fs.existsSync(selectionPath)) return 'absent';
const active = readSelectionFile(
selectionPath,
uid,
'active compose selection',
);
if (active.selection.generation === 1) {
if (active.contents !== initialContents) {
throw new LocalDeploymentConfigurationError(
'initial compose selection drifted',
);
}
return 'existing';
}
const revision = readSelectionFile(
revisionPath(revisionsRoot, active.selection.generation),
uid,
'active compose revision',
);
if (
revision.selection.generation !== active.selection.generation ||
revision.contents !== active.contents
) {
throw new LocalDeploymentConfigurationError(
'active compose selection is not backed by its immutable revision',
);
}
return 'existing';
}
export function inspectActiveComposeImageSelection(
selectionPath: string,
revisionsRoot: string,
uid: number,
): Readonly<ComposeImageSelection> {
const active = readSelectionFile(
selectionPath,
uid,
'active compose selection',
);
const revision = readSelectionFile(
revisionPath(revisionsRoot, active.selection.generation),
uid,
'active compose revision',
);
if (
revision.selection.generation !== active.selection.generation ||
revision.contents !== active.contents
) {
throw new LocalDeploymentConfigurationError(
'active compose selection is not backed by its immutable revision',
);
}
return active.selection;
}
export function inspectComposeImageSelectionGeneration(
revisionsRoot: string,
generation: number,
uid: number,
): Readonly<ComposeImageSelection> {
const revision = readSelectionFile(
revisionPath(revisionsRoot, generation),
uid,
'compose revision',
);
if (revision.selection.generation !== generation) {
throw new LocalDeploymentConfigurationError(
'compose revision generation drifted',
);
}
return revision.selection;
}
export async function switchLocalDeploymentComposeRevision(
input: unknown,
rolloutLockIntent?: string,
): Promise<Readonly<LocalDeploymentComposeRevisionResult>> {
const command = normalizeLocalDeploymentComposeRevisionCommand(input);
const identity = currentIdentity();
const paths = deploymentPaths(command.options.deploymentRoot);
validatePrivateDirectory(
command.options.deploymentRoot,
identity.uid,
'deploymentRoot',
);
validatePrivateDirectory(
paths.service,
identity.uid,
'serviceDescriptorRoot',
);
validatePrivateDirectory(
paths.composeRevisions,
identity.uid,
'composeRevisionRoot',
);
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
throw new LocalDeploymentConfigurationError(
'compose revision is fenced by an evidence collection',
);
}
if (fs.existsSync(paths.composeRolloutLock)) {
if (rolloutLockIntent === undefined) {
throw new LocalDeploymentConfigurationError(
'compose revision is fenced by an in-flight rollout',
);
}
preflightPublishedFile(
paths.composeRolloutLock,
rolloutLockIntent,
0o600,
identity.uid,
'compose rollout lock',
);
}
const observed = readSelectionFile(
paths.composeSelection,
identity.uid,
'active compose selection',
);
const nextGeneration = command.request.expectedGeneration + 1;
let image: string;
let rollbackTargetGeneration = 0;
if (command.operation === 'local.deployment.compose.upgrade') {
image = command.request.image;
} else {
rollbackTargetGeneration = command.request.targetGeneration;
const target = readSelectionFile(
revisionPath(paths.composeRevisions, command.request.targetGeneration),
identity.uid,
'rollback target compose revision',
);
if (target.selection.generation !== command.request.targetGeneration) {
throw new LocalDeploymentConfigurationError(
'rollback target generation drifted',
);
}
image = target.selection.image;
}
const nextContents = selectionContents({
generation: nextGeneration,
previousGeneration: command.request.expectedGeneration,
rollbackTargetGeneration,
mutationId: command.request.mutationId,
changedAtMs: command.request.changedAtMs,
image,
});
if (command.request.changedAtMs < observed.selection.changedAtMs) {
throw new LocalDeploymentConfigurationError(
'compose revision time precedes the active selection',
);
}
const exactReplay =
observed.selection.generation === nextGeneration &&
observed.contents === nextContents;
if (
!exactReplay &&
observed.selection.generation !== command.request.expectedGeneration
) {
throw new LocalDeploymentConfigurationError(
'active compose generation does not match expectedGeneration',
);
}
const intent = commandIntent(command);
publishExactFile(
paths.composeRevisionLock,
intent,
0o600,
identity.uid,
'compose revision lock',
);
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
releaseLock(paths.composeRevisionLock, intent, identity.uid);
throw new LocalDeploymentConfigurationError(
'compose revision is fenced by an evidence collection',
);
}
const current = readSelectionFile(
paths.composeSelection,
identity.uid,
'active compose selection',
);
if (
current.contents !== observed.contents &&
current.contents !== nextContents
) {
throw new LocalDeploymentConfigurationError(
'active compose selection changed while acquiring revision lock',
);
}
const revisionStatus = publishExactFile(
revisionPath(paths.composeRevisions, nextGeneration),
nextContents,
0o600,
identity.uid,
'compose revision',
);
const switchStatus =
current.contents === nextContents
? ('existing' as const)
: replaceExactFile(
paths.composeSelection,
observed.contents,
nextContents,
0o600,
identity.uid,
'active compose selection',
);
releaseLock(paths.composeRevisionLock, intent, identity.uid);
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status:
revisionStatus === 'existing' && switchStatus === 'existing'
? ('existing' as const)
: ('prepared' as const),
generation: nextGeneration,
service: Object.freeze({ kind: 'compose' as const }),
});
}
export function switchLocalDeploymentComposeRevisionCommandFile(
filePath: string,
): Promise<Readonly<LocalDeploymentComposeRevisionResult>> {
return switchLocalDeploymentComposeRevision(
readPrivateLocalCommandFile(filePath),
);
}
@@ -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),
);
}
@@ -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 };
@@ -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');
}
}
@@ -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);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { LocalDeploymentConfigurationError } from './contract';
const MAX_DOCKER_OUTPUT_BYTES = 256 * 1024;
export interface LocalDeploymentDockerRequest {
readonly executable: string;
readonly socketPath: string;
readonly args: readonly string[];
readonly timeoutMs?: number;
}
export type LocalDeploymentDockerRunner = (
request: Readonly<LocalDeploymentDockerRequest>,
) => string;
export function runLocalDeploymentDockerCommand(
request: Readonly<LocalDeploymentDockerRequest>,
): string {
const configRoot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-docker-command-')),
);
fs.chmodSync(configRoot, 0o700);
try {
const executableDirectory = path.dirname(request.executable);
const result = spawnSync(
request.executable,
[
'--host',
`unix://${request.socketPath}`,
'--config',
configRoot,
...request.args,
],
{
encoding: 'utf8',
maxBuffer: MAX_DOCKER_OUTPUT_BYTES,
timeout: request.timeoutMs ?? 30_000,
killSignal: 'SIGKILL',
env: {
PATH: `${executableDirectory}:/usr/local/bin:/usr/bin:/bin`,
HOME: configRoot,
DOCKER_CONFIG: configRoot,
NO_PROXY: '*',
no_proxy: '*',
},
},
);
if (
result.error ||
result.status !== 0 ||
typeof result.stdout !== 'string' ||
Buffer.byteLength(result.stdout, 'utf8') > MAX_DOCKER_OUTPUT_BYTES
) {
throw new LocalDeploymentConfigurationError(
'Docker command failed closed',
{ cause: result.error },
);
}
return result.stdout;
} finally {
fs.rmSync(configRoot, { recursive: true, force: true });
}
}
export function validateLocalDeploymentDockerSocket(
socketPath: string,
uid: number,
): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(socketPath);
} catch (error) {
throw new LocalDeploymentConfigurationError(
'dockerSocketPath is unavailable',
{ cause: error },
);
}
if (
!stat.isSocket() ||
stat.isSymbolicLink() ||
fs.realpathSync(socketPath) !== socketPath ||
(stat.uid !== 0 && stat.uid !== uid) ||
(stat.mode & 0o002) !== 0
) {
throw new LocalDeploymentConfigurationError(
'dockerSocketPath must be a canonical trusted Unix socket',
);
}
}
@@ -0,0 +1,310 @@
import fs from 'node:fs';
import path from 'node:path';
import { LocalDeploymentConfigurationError } from './contract';
const MAX_PUBLISHED_FILE_BYTES = 64 * 1024;
export function validatePrivateDirectory(
directory: string,
uid: number,
label: string,
): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(directory);
} catch (error) {
throw new LocalDeploymentConfigurationError(`${label} is unavailable`, {
cause: error,
});
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o700 ||
fs.realpathSync(directory) !== directory
) {
throw new LocalDeploymentConfigurationError(
`${label} must be a canonical current-UID 0700 directory`,
);
}
}
export function ensurePrivateDirectory(
directory: string,
uid: number,
label: string,
): 'prepared' | 'existing' {
let created = false;
try {
fs.mkdirSync(directory, { mode: 0o700 });
created = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw new LocalDeploymentConfigurationError(
`${label} cannot be created`,
{ cause: error },
);
}
}
validatePrivateDirectory(directory, uid, label);
return created ? 'prepared' : 'existing';
}
function boundedBytes(contents: string, label: string): Buffer {
const bytes = Buffer.from(contents, 'utf8');
if (bytes.byteLength < 2 || bytes.byteLength > MAX_PUBLISHED_FILE_BYTES) {
throw new LocalDeploymentConfigurationError(`${label} has an invalid size`);
}
return bytes;
}
function fileStat(
filePath: string,
bytes: Buffer,
mode: number,
uid: number,
allowedLinks: readonly number[],
label: string,
): fs.Stats {
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
throw new LocalDeploymentConfigurationError(`${label} is unavailable`, {
cause: error,
});
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== mode ||
!allowedLinks.includes(stat.nlink) ||
stat.size !== bytes.byteLength
) {
throw new LocalDeploymentConfigurationError(`${label} identity is invalid`);
}
const actual = fs.readFileSync(filePath);
if (!bytes.equals(actual)) {
throw new LocalDeploymentConfigurationError(`${label} content drifted`);
}
return stat;
}
function stagePathFor(targetPath: string): string {
return path.join(
path.dirname(targetPath),
`.${path.basename(targetPath)}.ql3-deploy-stage`,
);
}
export function preflightPublishedFile(
targetPath: string,
contents: string,
mode: number,
uid: number,
label: string,
): void {
const bytes = boundedBytes(contents, label);
const stagePath = stagePathFor(targetPath);
const targetExists = fs.existsSync(targetPath);
const stageExists = fs.existsSync(stagePath);
const targetStat = targetExists
? fileStat(targetPath, bytes, mode, uid, [1, 2], label)
: null;
const stageStat = stageExists
? fileStat(stagePath, bytes, mode, uid, [1, 2], `${label} stage`)
: null;
if (
(targetStat?.nlink === 2 || stageStat?.nlink === 2) &&
(!targetStat ||
!stageStat ||
targetStat.dev !== stageStat.dev ||
targetStat.ino !== stageStat.ino)
) {
throw new LocalDeploymentConfigurationError(
`${label} stage identity drifted`,
);
}
}
function fsyncDirectory(directory: string): void {
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
try {
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function writeStage(
stagePath: string,
bytes: Buffer,
mode: number,
uid: number,
label: string,
): void {
let descriptor: number | undefined;
let created = false;
try {
descriptor = fs.openSync(
stagePath,
fs.constants.O_WRONLY |
fs.constants.O_CREAT |
fs.constants.O_EXCL |
fs.constants.O_NOFOLLOW,
mode,
);
created = true;
fs.fchmodSync(descriptor, mode);
const stat = fs.fstatSync(descriptor);
if (!stat.isFile() || stat.uid !== uid || stat.nlink !== 1) {
throw new LocalDeploymentConfigurationError(
`${label} stage identity is invalid`,
);
}
let offset = 0;
while (offset < bytes.byteLength) {
const written = fs.writeSync(
descriptor,
bytes,
offset,
bytes.byteLength - offset,
);
if (written < 1) {
throw new LocalDeploymentConfigurationError(
`${label} stage write stalled`,
);
}
offset += written;
}
fs.fsyncSync(descriptor);
} catch (error) {
if (created) {
try {
fs.unlinkSync(stagePath);
} catch {
// A failed cleanup leaves a deterministic fail-closed stage.
}
}
if (error instanceof LocalDeploymentConfigurationError) throw error;
throw new LocalDeploymentConfigurationError(
`${label} stage cannot be written`,
{ cause: error },
);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function publishExactFile(
targetPath: string,
contents: string,
mode: number,
uid: number,
label: string,
): 'prepared' | 'existing' {
const bytes = boundedBytes(contents, label);
const directory = path.dirname(targetPath);
const stagePath = stagePathFor(targetPath);
preflightPublishedFile(targetPath, contents, mode, uid, label);
const existed = fs.existsSync(targetPath);
if (!fs.existsSync(stagePath) && !existed) {
writeStage(stagePath, bytes, mode, uid, label);
}
if (!fs.existsSync(targetPath)) {
try {
fs.linkSync(stagePath, targetPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw new LocalDeploymentConfigurationError(
`${label} cannot be published`,
{ cause: error },
);
}
}
fsyncDirectory(directory);
}
fileStat(targetPath, bytes, mode, uid, [1, 2], label);
if (fs.existsSync(stagePath)) {
const targetStat = fs.lstatSync(targetPath);
const stageStat = fileStat(
stagePath,
bytes,
mode,
uid,
[1, 2],
`${label} stage`,
);
if (
stageStat.nlink === 2 &&
(targetStat.dev !== stageStat.dev || targetStat.ino !== stageStat.ino)
) {
throw new LocalDeploymentConfigurationError(
`${label} stage identity drifted`,
);
}
fs.unlinkSync(stagePath);
fsyncDirectory(directory);
}
fileStat(targetPath, bytes, mode, uid, [1], label);
return existed ? 'existing' : 'prepared';
}
export function replaceExactFile(
targetPath: string,
expectedContents: string,
nextContents: string,
mode: number,
uid: number,
label: string,
): 'prepared' | 'existing' {
const expectedBytes = boundedBytes(expectedContents, `${label} expected`);
const nextBytes = boundedBytes(nextContents, `${label} next`);
if (expectedBytes.equals(nextBytes)) {
throw new LocalDeploymentConfigurationError(
`${label} replacement must change content`,
);
}
const directory = path.dirname(targetPath);
const stagePath = stagePathFor(targetPath);
let targetBytes: Buffer;
try {
targetBytes = fs.readFileSync(targetPath);
} catch (error) {
throw new LocalDeploymentConfigurationError(`${label} is unavailable`, {
cause: error,
});
}
if (targetBytes.equals(nextBytes)) {
fileStat(targetPath, nextBytes, mode, uid, [1], label);
if (fs.existsSync(stagePath)) {
fileStat(stagePath, nextBytes, mode, uid, [1], `${label} stage`);
fs.unlinkSync(stagePath);
fsyncDirectory(directory);
}
return 'existing';
}
if (!targetBytes.equals(expectedBytes)) {
throw new LocalDeploymentConfigurationError(
`${label} content does not match the expected revision`,
);
}
fileStat(targetPath, expectedBytes, mode, uid, [1], label);
if (fs.existsSync(stagePath)) {
fileStat(stagePath, nextBytes, mode, uid, [1], `${label} stage`);
} else {
writeStage(stagePath, nextBytes, mode, uid, label);
}
fileStat(targetPath, expectedBytes, mode, uid, [1], label);
fs.renameSync(stagePath, targetPath);
fsyncDirectory(directory);
fileStat(targetPath, nextBytes, mode, uid, [1], label);
return 'prepared';
}
export function syncPublishedDirectory(directory: string): void {
fsyncDirectory(directory);
}
@@ -0,0 +1,316 @@
import crypto from 'node:crypto';
import path from 'node:path';
import type { LocalSetupCommand } from '../../lifecycle/localSetup';
import type {
LocalDeploymentPrepareCommand,
LocalDeploymentProcessService,
} from './contract';
const CONTAINER_ROOT = '/var/lib/qinglong3';
export function composeProjectName(instanceId: string): string {
const slug = instanceId.replaceAll('.', '-').slice(0, 32);
const suffix = crypto
.createHash('sha256')
.update('qinglong:local-compose-project:v1\0', 'utf8')
.update(instanceId, 'utf8')
.digest('hex')
.slice(0, 12);
return `ql3-${slug}-${suffix}`;
}
export interface LocalDeploymentPaths {
readonly database: string;
readonly ownerPepperKeyring: string;
readonly ownerPepperBackup: string;
readonly localSecretKeyring: string;
readonly receipts: string;
readonly artifacts: string;
readonly pluginStaging: string;
readonly pluginActivation: string;
readonly service: string;
readonly applicationConfig: string;
readonly composeSelection: string;
readonly composeRevisions: string;
readonly composeRevisionLock: string;
readonly composeRollouts: string;
readonly composeRolloutLock: string;
readonly composeRolloutBackups: string;
readonly composeRestores: string;
readonly composeRestoreLock: string;
readonly composeRestoreSafeguards: string;
readonly composeEvidenceCollections: string;
readonly composeEvidenceCollectionLock: string;
readonly composeCollectedEvidence: string;
readonly composeCollectedRolloutBackups: string;
readonly composeCollectedRestoreSafeguards: string;
}
export function deploymentPaths(root: string): Readonly<LocalDeploymentPaths> {
return Object.freeze({
database: path.join(root, 'qinglong3.sqlite'),
ownerPepperKeyring: path.join(root, 'owner-peppers'),
ownerPepperBackup: path.join(root, 'owner-pepper-backup'),
localSecretKeyring: path.join(root, 'local-secret-keyring.json'),
receipts: path.join(root, 'receipts'),
artifacts: path.join(root, 'artifacts'),
pluginStaging: path.join(root, 'plugin-staging'),
pluginActivation: path.join(root, 'plugin-activation'),
service: path.join(root, 'service'),
applicationConfig: path.join(root, 'local-application.json'),
composeSelection: path.join(root, 'service', 'compose.image.yaml'),
composeRevisions: path.join(root, 'service', 'revisions'),
composeRevisionLock: path.join(root, 'service', '.compose-revision.lock'),
composeRollouts: path.join(root, 'service', 'rollouts'),
composeRolloutLock: path.join(root, 'service', '.compose-rollout.lock'),
composeRolloutBackups: path.join(root, 'service', 'rollout-backups'),
composeRestores: path.join(root, 'service', 'restores'),
composeRestoreLock: path.join(root, 'service', '.compose-restore.lock'),
composeRestoreSafeguards: path.join(root, 'service', 'restore-safeguards'),
composeEvidenceCollections: path.join(
root,
'service',
'evidence-collections',
),
composeEvidenceCollectionLock: path.join(
root,
'service',
'.compose-evidence-collection.lock',
),
composeCollectedEvidence: path.join(root, 'service', 'collected-evidence'),
composeCollectedRolloutBackups: path.join(
root,
'service',
'collected-evidence',
'rollout-backups',
),
composeCollectedRestoreSafeguards: path.join(
root,
'service',
'collected-evidence',
'restore-safeguards',
),
});
}
export function applicationConfiguration(
command: Readonly<LocalDeploymentPrepareCommand>,
paths: Readonly<LocalDeploymentPaths>,
): string {
const runtimeRoot =
command.options.service.kind === 'compose'
? CONTAINER_ROOT
: command.options.deploymentRoot;
const pageSize = command.options.profile === 'edge' ? 4 : 16;
return `${JSON.stringify(
{
schema: 'qinglong/local-application-process@v2',
instanceId: command.options.instanceId,
profile: command.options.profile,
storage: {
mode: 'fresh',
databasePath: path.join(runtimeRoot, path.basename(paths.database)),
...(command.options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: command.options.busyTimeoutMs }),
},
runtime: {
receiptRoot: path.join(runtimeRoot, path.basename(paths.receipts)),
artifactRoot: path.join(runtimeRoot, path.basename(paths.artifacts)),
secretKeyringPath: path.join(
runtimeRoot,
path.basename(paths.localSecretKeyring),
),
},
pluginPackages: {
stagingRoot: path.join(runtimeRoot, path.basename(paths.pluginStaging)),
activationRoot: path.join(
runtimeRoot,
path.basename(paths.pluginActivation),
),
recoverySource: {
mode: 'disabled',
},
pageSize,
maxPages: pageSize,
taskPublicationPageSize: pageSize,
taskPublicationMaxPages: pageSize,
},
ai: {
deployment: 'excluded',
},
},
null,
2,
)}\n`;
}
function systemdDescriptor(
command: Readonly<LocalDeploymentPrepareCommand>,
configPath: string,
uid: number,
gid: number,
): string {
const service = command.options.service as LocalDeploymentProcessService;
const edge = command.options.profile === 'edge';
return [
'[Unit]',
'Description=QingLong 3.0 local automation runtime',
'After=local-fs.target',
'',
'[Service]',
'Type=simple',
`User=${uid}`,
`Group=${gid}`,
`WorkingDirectory=${command.options.deploymentRoot}`,
`ExecStart=${service.nodeExecutable} ${service.applicationEntrypoint} --config ${configPath}`,
'Environment=NODE_ENV=production',
'UMask=0077',
'KillSignal=SIGTERM',
'TimeoutStopSec=30s',
'Restart=on-failure',
'RestartSec=5s',
'RestartPreventExitStatus=64',
'NoNewPrivileges=yes',
'PrivateTmp=yes',
'ProtectSystem=strict',
`ReadWritePaths=${command.options.deploymentRoot}`,
'ProtectKernelTunables=yes',
'ProtectKernelModules=yes',
'ProtectControlGroups=yes',
'RestrictSUIDSGID=yes',
'LockPersonality=yes',
`LimitNOFILE=${edge ? 1024 : 4096}`,
`TasksMax=${edge ? 64 : 256}`,
`MemoryMax=${edge ? '128M' : '256M'}`,
'',
'[Install]',
'WantedBy=multi-user.target',
'',
].join('\n');
}
function openrcDescriptor(
command: Readonly<LocalDeploymentPrepareCommand>,
configPath: string,
uid: number,
gid: number,
): string {
const service = command.options.service as LocalDeploymentProcessService;
const edge = command.options.profile === 'edge';
return [
'#!/sbin/openrc-run',
'',
'name="qinglong3"',
'description="QingLong 3.0 local automation runtime"',
`command="${service.nodeExecutable}"`,
`command_args="${service.applicationEntrypoint} --config ${configPath}"`,
`command_user="${uid}:${gid}"`,
`directory="${command.options.deploymentRoot}"`,
'supervisor="supervise-daemon"',
'respawn_delay=5',
'respawn_max=5',
'respawn_period=60',
'retry="TERM/30/KILL/5"',
'umask=0077',
`rc_ulimit="-n ${edge ? 1024 : 4096}"`,
'',
'depend() {',
' need localmount',
' after bootmisc',
'}',
'',
].join('\n');
}
function composeDescriptor(
command: Readonly<LocalDeploymentPrepareCommand>,
uid: number,
gid: number,
): string {
const edge = command.options.profile === 'edge';
return [
`name: ${composeProjectName(command.options.instanceId)}`,
'',
'services:',
' qinglong3:',
` user: "${uid}:${gid}"`,
' read_only: true',
' init: true',
' network_mode: none',
' command:',
' - --config',
` - ${CONTAINER_ROOT}/local-application.json`,
' volumes:',
' - type: bind',
` source: ${command.options.deploymentRoot}`,
` target: ${CONTAINER_ROOT}`,
' tmpfs:',
' - /tmp:rw,noexec,nosuid,nodev,size=16m',
' cap_drop:',
' - ALL',
' security_opt:',
' - no-new-privileges:true',
' restart: unless-stopped',
' stop_grace_period: 30s',
` mem_limit: ${edge ? '128m' : '256m'}`,
` pids_limit: ${edge ? 64 : 256}`,
'',
].join('\n');
}
export function descriptor(
command: Readonly<LocalDeploymentPrepareCommand>,
configPath: string,
uid: number,
gid: number,
): Readonly<{ fileName: string; contents: string; mode: number }> {
if (command.options.service.kind === 'systemd') {
return Object.freeze({
fileName: 'qinglong3.service',
contents: systemdDescriptor(command, configPath, uid, gid),
mode: 0o600,
});
}
if (command.options.service.kind === 'openrc') {
return Object.freeze({
fileName: 'qinglong3.openrc',
contents: openrcDescriptor(command, configPath, uid, gid),
mode: 0o700,
});
}
return Object.freeze({
fileName: 'compose.yaml',
contents: composeDescriptor(command, uid, gid),
mode: 0o600,
});
}
export function setupCommand(
command: Readonly<LocalDeploymentPrepareCommand>,
paths: Readonly<LocalDeploymentPaths>,
): Readonly<LocalSetupCommand> {
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.setup.prepare' as const,
options: Object.freeze({
deploymentRoot: command.options.deploymentRoot,
databasePath: paths.database,
profile: command.options.profile,
ownerPepperKeyringDirectory: paths.ownerPepperKeyring,
ownerPepperBackupDirectory: paths.ownerPepperBackup,
ownerPepperKeyId: command.request.ownerPepperKeyId,
localSecretKeyringPath: paths.localSecretKeyring,
...(command.options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: command.options.busyTimeoutMs }),
}),
request: Object.freeze({
registerMutationId: command.request.registerMutationId,
activateMutationId: command.request.activateMutationId,
registeredAtMs: command.request.registeredAtMs,
activatedAtMs: command.request.activatedAtMs,
}),
});
}
@@ -0,0 +1,369 @@
// Deployment composition lives with its concrete deployment capabilities.
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import {
currentIdentity,
normalizeLocalDeploymentPrepareCommand,
type LocalDeploymentPrepareResult,
} from './foundation/contract';
import {
initialComposeImageSelection,
preflightActiveComposeImageSelection,
switchLocalDeploymentComposeRevision,
switchLocalDeploymentComposeRevisionCommandFile,
} from './compose/composeRevision';
import {
preflightLocalDeploymentCompose,
preflightLocalDeploymentComposeCommandFile,
} from './compose/composePreflight';
import {
applyLocalDeploymentCompose,
applyLocalDeploymentComposeCommandFile,
} from './compose/composeApply';
import {
restoreLocalDeploymentCompose,
restoreLocalDeploymentComposeCommitCommandFile,
restoreLocalDeploymentComposeCommandFile,
restoreLocalDeploymentComposePrepareCommandFile,
} from './compose/composeRestore';
import {
collectLocalDeploymentComposeEvidence,
collectLocalDeploymentComposeEvidenceCommitCommandFile,
collectLocalDeploymentComposeEvidencePrepareCommandFile,
} from './compose/composeEvidenceCollection';
import {
ensurePrivateDirectory,
preflightPublishedFile,
publishExactFile,
} from './foundation/files';
import {
applicationConfiguration,
deploymentPaths,
descriptor,
setupCommand,
} from './foundation/render';
import { executeLocalSetup } from '../lifecycle/localSetup';
import {
inspectLocalDeploymentStatus,
inspectLocalDeploymentStatusCommandFile,
} from './localDeploymentStatus';
import {
stopLegacyDockerForLocalDeployment,
stopLegacyDockerForLocalDeploymentCommandFile,
} from './cutover/legacyStop';
import {
runLocalDeploymentDockerTarget,
runLocalDeploymentDockerTargetCommandFile,
} from './cutover/target-run/targetRun';
import {
runLocalDeploymentCutoverManualCommand,
runLocalDeploymentCutoverManualCommandFile,
} from './cutover/manual-resolution/manualResolution';
import {
stopLocalDeploymentDockerTarget,
stopLocalDeploymentDockerTargetCommandFile,
} from './cutover/targetStop';
import {
runLocalDeploymentLegacyRollback,
runLocalDeploymentLegacyRollbackCommandFile,
} from './cutover/legacyRollback';
import {
consumeLocalServiceManagerOutcome,
consumeLocalServiceManagerOutcomeCommandFile,
prepareLocalServiceManagerIntent,
prepareLocalServiceManagerIntentCommandFile,
} from './service-manager/serviceManagerIntent';
import {
consumeLocalServiceManagerCutoverOutcome,
consumeLocalServiceManagerCutoverOutcomeCommandFile,
} from './service-manager/serviceCutoverConsumer';
export {
LocalDeploymentConfigurationError,
normalizeLocalDeploymentComposeApplyCommand,
normalizeLocalDeploymentComposeEvidenceCollectionCommand,
normalizeLocalDeploymentComposePreflightCommand,
normalizeLocalDeploymentComposeRestoreCommand,
normalizeLocalDeploymentComposeRevisionCommand,
normalizeLocalDeploymentPrepareCommand,
normalizeLocalDeploymentStatusCommand,
type LocalDeploymentComposeApplyCommand,
type LocalDeploymentComposeApplyResult,
type LocalDeploymentComposeEvidenceCollectionCommand,
type LocalDeploymentComposeEvidenceCollectionCommitCommand,
type LocalDeploymentComposeEvidenceCollectionPrepareCommand,
type LocalDeploymentComposeEvidenceCollectionResult,
type LocalDeploymentComposePreflightCommand,
type LocalDeploymentComposePreflightResult,
type LocalDeploymentComposeRestoreCommand,
type LocalDeploymentComposeRestoreCommitCommand,
type LocalDeploymentComposeRestorePrepareCommand,
type LocalDeploymentComposeRestoreResult,
type LocalDeploymentComposeRevisionCommand,
type LocalDeploymentComposeRevisionResult,
type LocalDeploymentPrepareCommand,
type LocalDeploymentPrepareResult,
type LocalDeploymentStatusCommand,
type LocalDeploymentStatusResult,
} from './foundation/contract';
export {
normalizeLocalDeploymentLegacyStopCommand,
type LocalDeploymentLegacyStopCommand,
type LocalDeploymentLegacyStopResult,
} from './cutover/contract';
export {
normalizeLocalDeploymentTargetRunCommand,
type LocalDeploymentTargetRunCommand,
type LocalDeploymentTargetRunOperation,
type LocalDeploymentTargetRunResult,
} from './cutover/target-run/targetRunContract';
export {
normalizeLocalDeploymentTargetStopCommand,
type LocalDeploymentTargetReconciliationDisposition,
type LocalDeploymentTargetStopCommand,
type LocalDeploymentTargetStopResult,
} from './cutover/targetStopContract';
export { type LocalDeploymentTargetStopDependencies } from './cutover/targetStop';
export {
EMPTY_ROLLBACK_PREPARATION_DIGEST,
normalizeLocalDeploymentLegacyRollbackCommand,
type LocalDeploymentLegacyRollbackCommand,
type LocalDeploymentLegacyRollbackOperation,
type LocalDeploymentLegacyRollbackResult,
} from './cutover/legacyRollbackContract';
export { type LocalDeploymentLegacyRollbackDependencies } from './cutover/legacyRollback';
export {
EMPTY_RESOLUTION_DIGEST,
normalizeLocalDeploymentCutoverManualCommand,
type LocalDeploymentCutoverManualCommand,
type LocalDeploymentCutoverManualOperation,
} from './cutover/manual-resolution/manualResolutionContract';
export {
type LocalDeploymentCutoverManualDependencies,
type LocalDeploymentCutoverManualResult,
type LocalDeploymentCutoverObservationState,
} from './cutover/manual-resolution/manualResolution';
export {
applyLocalDeploymentCompose,
applyLocalDeploymentComposeCommandFile,
collectLocalDeploymentComposeEvidence,
collectLocalDeploymentComposeEvidenceCommitCommandFile,
collectLocalDeploymentComposeEvidencePrepareCommandFile,
inspectLocalDeploymentStatus,
inspectLocalDeploymentStatusCommandFile,
preflightLocalDeploymentCompose,
preflightLocalDeploymentComposeCommandFile,
restoreLocalDeploymentCompose,
restoreLocalDeploymentComposeCommitCommandFile,
restoreLocalDeploymentComposeCommandFile,
restoreLocalDeploymentComposePrepareCommandFile,
runLocalDeploymentCutoverManualCommand,
runLocalDeploymentCutoverManualCommandFile,
runLocalDeploymentLegacyRollback,
runLocalDeploymentLegacyRollbackCommandFile,
runLocalDeploymentDockerTarget,
runLocalDeploymentDockerTargetCommandFile,
switchLocalDeploymentComposeRevision,
switchLocalDeploymentComposeRevisionCommandFile,
stopLegacyDockerForLocalDeployment,
stopLegacyDockerForLocalDeploymentCommandFile,
stopLocalDeploymentDockerTarget,
stopLocalDeploymentDockerTargetCommandFile,
consumeLocalServiceManagerOutcome,
consumeLocalServiceManagerOutcomeCommandFile,
consumeLocalServiceManagerCutoverOutcome,
consumeLocalServiceManagerCutoverOutcomeCommandFile,
prepareLocalServiceManagerIntent,
prepareLocalServiceManagerIntentCommandFile,
};
export {
localServiceManagerIntentDigest,
normalizeLocalServiceBridgeCommand,
normalizeLocalServiceManagerIntent,
type LocalServiceBridgeCommand,
type LocalServiceManagerIntent,
} from './service-manager/serviceBridgeContract';
export {
normalizeLocalServiceManagerOutcome,
type LocalServiceManagerOutcome,
} from './service-manager/serviceOutcomeContract';
export {
type LocalServiceManagerIntentPrepareCommand,
type LocalServiceManagerIntentPrepareResult,
type LocalServiceManagerOutcomeConsumeCommand,
type LocalServiceManagerOutcomeConsumeResult,
} from './service-manager/serviceManagerIntent';
export {
type LocalServiceManagerCutoverConsumeCommand,
type LocalServiceManagerCutoverConsumeResult,
type LocalServiceManagerCutoverDependencies,
} from './service-manager/serviceCutoverConsumer';
export {
type LocalServiceManagerCutoverEvidence,
type LocalServiceManagerCutoverRecord,
type LocalServiceManagerCutoverState,
} from './service-manager/serviceCutoverJournal';
export async function prepareLocalDeployment(
input: unknown,
): Promise<Readonly<LocalDeploymentPrepareResult>> {
const command = normalizeLocalDeploymentPrepareCommand(input);
const identity = currentIdentity();
const paths = deploymentPaths(command.options.deploymentRoot);
const directories = [
[command.options.deploymentRoot, 'deploymentRoot'],
[paths.ownerPepperKeyring, 'ownerPepperKeyringDirectory'],
[paths.ownerPepperBackup, 'ownerPepperBackupDirectory'],
[paths.receipts, 'receiptRoot'],
[paths.artifacts, 'artifactRoot'],
[paths.pluginStaging, 'pluginStagingRoot'],
[paths.pluginActivation, 'pluginActivationRoot'],
[paths.service, 'serviceDescriptorRoot'],
...(command.options.service.kind === 'compose'
? ([
[paths.composeRevisions, 'composeRevisionRoot'],
[paths.composeRollouts, 'composeRolloutRoot'],
[paths.composeRolloutBackups, 'composeRolloutBackupRoot'],
[paths.composeRestores, 'composeRestoreRoot'],
[paths.composeRestoreSafeguards, 'composeRestoreSafeguardRoot'],
[paths.composeEvidenceCollections, 'composeEvidenceCollectionRoot'],
[paths.composeCollectedEvidence, 'composeCollectedEvidenceRoot'],
[
paths.composeCollectedRolloutBackups,
'composeCollectedRolloutBackupRoot',
],
[
paths.composeCollectedRestoreSafeguards,
'composeCollectedRestoreSafeguardRoot',
],
] as const)
: []),
] as const;
const directoryStatuses = directories.map(([directory, label]) =>
ensurePrivateDirectory(directory, identity.uid, label),
);
const applicationConfig = applicationConfiguration(command, paths);
const serviceDescriptor = descriptor(
command,
paths.applicationConfig,
identity.uid,
identity.gid,
);
const descriptorPath = path.join(paths.service, serviceDescriptor.fileName);
const composeSelection =
command.options.service.kind === 'compose'
? initialComposeImageSelection(command)
: undefined;
preflightPublishedFile(
paths.applicationConfig,
applicationConfig,
0o600,
identity.uid,
'application configuration',
);
preflightPublishedFile(
descriptorPath,
serviceDescriptor.contents,
serviceDescriptor.mode,
identity.uid,
'service descriptor',
);
if (composeSelection !== undefined) {
preflightPublishedFile(
path.join(paths.composeRevisions, '1.yaml'),
composeSelection,
0o600,
identity.uid,
'initial compose revision',
);
preflightActiveComposeImageSelection(
paths.composeSelection,
paths.composeRevisions,
composeSelection,
identity.uid,
);
}
const setup = await executeLocalSetup(setupCommand(command, paths));
const applicationStatus = publishExactFile(
paths.applicationConfig,
applicationConfig,
0o600,
identity.uid,
'application configuration',
);
const serviceStatus = publishExactFile(
descriptorPath,
serviceDescriptor.contents,
serviceDescriptor.mode,
identity.uid,
'service descriptor',
);
const composeRevisionStatus =
composeSelection === undefined
? 'existing'
: publishExactFile(
path.join(paths.composeRevisions, '1.yaml'),
composeSelection,
0o600,
identity.uid,
'initial compose revision',
);
const composeSelectionStatus =
composeSelection === undefined
? 'existing'
: preflightActiveComposeImageSelection(
paths.composeSelection,
paths.composeRevisions,
composeSelection,
identity.uid,
) === 'existing'
? 'existing'
: publishExactFile(
paths.composeSelection,
composeSelection,
0o600,
identity.uid,
'active compose selection',
);
const createdDirectories = directoryStatuses.filter(
(status) => status === 'prepared',
).length;
const prepared =
createdDirectories > 0 ||
setup.status === 'prepared' ||
applicationStatus === 'prepared' ||
serviceStatus === 'prepared';
const deploymentPrepared =
prepared ||
composeSelectionStatus === 'prepared' ||
composeRevisionStatus === 'prepared';
return Object.freeze({
schemaVersion: 1 as const,
status: deploymentPrepared ? ('prepared' as const) : ('existing' as const),
profile: command.options.profile,
service: Object.freeze({
kind: command.options.service.kind,
status: serviceStatus,
}),
applicationConfiguration: Object.freeze({
schema: 'qinglong/local-application-process@v2' as const,
status: applicationStatus,
}),
directories: Object.freeze({
created: createdDirectories,
existing: directoryStatuses.length - createdDirectories,
}),
setup,
});
}
export function prepareLocalDeploymentCommandFile(
filePath: string,
): Promise<Readonly<LocalDeploymentPrepareResult>> {
return prepareLocalDeployment(readPrivateLocalCommandFile(filePath));
}
@@ -0,0 +1,142 @@
#!/usr/bin/env node
// Keep the deployment binary beside its composition authority.
import {
applyLocalDeploymentComposeCommandFile,
collectLocalDeploymentComposeEvidenceCommitCommandFile,
collectLocalDeploymentComposeEvidencePrepareCommandFile,
consumeLocalServiceManagerOutcomeCommandFile,
consumeLocalServiceManagerCutoverOutcomeCommandFile,
inspectLocalDeploymentStatusCommandFile,
preflightLocalDeploymentComposeCommandFile,
prepareLocalServiceManagerIntentCommandFile,
prepareLocalDeploymentCommandFile,
restoreLocalDeploymentComposeCommitCommandFile,
restoreLocalDeploymentComposePrepareCommandFile,
runLocalDeploymentCutoverManualCommandFile,
runLocalDeploymentLegacyRollbackCommandFile,
runLocalDeploymentDockerTargetCommandFile,
stopLocalDeploymentDockerTargetCommandFile,
stopLegacyDockerForLocalDeploymentCommandFile,
switchLocalDeploymentComposeRevisionCommandFile,
} from './localDeployment';
const USAGE =
'Usage: ql3-local-deploy <prepare|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
async function main(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
process.stdout.write(`${USAGE}\n`);
return;
}
if (
argv.length !== 3 ||
(argv[0] !== 'prepare' &&
argv[0] !== 'status' &&
argv[0] !== 'service-intent-prepare' &&
argv[0] !== 'service-outcome-consume' &&
argv[0] !== 'service-cutover-consume' &&
argv[0] !== 'cutover-legacy-stop' &&
argv[0] !== 'cutover-target-start' &&
argv[0] !== 'cutover-target-restart' &&
argv[0] !== 'cutover-target-stop' &&
argv[0] !== 'cutover-legacy-rollback-prepare' &&
argv[0] !== 'cutover-legacy-rollback-commit' &&
argv[0] !== 'cutover-manual-diagnose' &&
argv[0] !== 'cutover-manual-resolution-prepare' &&
argv[0] !== 'cutover-manual-resolution-commit' &&
argv[0] !== 'compose-revision' &&
argv[0] !== 'compose-preflight' &&
argv[0] !== 'compose-apply' &&
argv[0] !== 'compose-restore-prepare' &&
argv[0] !== 'compose-restore-commit' &&
argv[0] !== 'compose-evidence-collect-prepare' &&
argv[0] !== 'compose-evidence-collect-commit') ||
argv[1] !== '--command-file'
) {
process.stderr.write(
`${JSON.stringify({
code: 'QL3_LOCAL_DEPLOYMENT_CLI_USAGE_INVALID',
message: USAGE,
})}\n`,
);
process.exitCode = 64;
return;
}
try {
const output = await (argv[0] === 'prepare'
? prepareLocalDeploymentCommandFile(argv[2]!)
: argv[0] === 'status'
? inspectLocalDeploymentStatusCommandFile(argv[2]!)
: argv[0] === 'service-intent-prepare'
? prepareLocalServiceManagerIntentCommandFile(argv[2]!)
: argv[0] === 'service-outcome-consume'
? consumeLocalServiceManagerOutcomeCommandFile(argv[2]!)
: argv[0] === 'service-cutover-consume'
? consumeLocalServiceManagerCutoverOutcomeCommandFile(argv[2]!)
: argv[0] === 'cutover-legacy-stop'
? stopLegacyDockerForLocalDeploymentCommandFile(argv[2]!)
: argv[0] === 'cutover-target-start' ||
argv[0] === 'cutover-target-restart'
? runLocalDeploymentDockerTargetCommandFile(argv[2]!)
: argv[0] === 'cutover-target-stop'
? stopLocalDeploymentDockerTargetCommandFile(argv[2]!)
: argv[0] === 'cutover-legacy-rollback-prepare' ||
argv[0] === 'cutover-legacy-rollback-commit'
? runLocalDeploymentLegacyRollbackCommandFile(
argv[2]!,
argv[0] === 'cutover-legacy-rollback-prepare'
? 'local.deployment.cutover.legacy-rollback-prepare'
: 'local.deployment.cutover.legacy-rollback-commit',
)
: argv[0] === 'cutover-manual-diagnose' ||
argv[0] === 'cutover-manual-resolution-prepare' ||
argv[0] === 'cutover-manual-resolution-commit'
? runLocalDeploymentCutoverManualCommandFile(
argv[2]!,
argv[0] === 'cutover-manual-diagnose'
? 'local.deployment.cutover.manual-diagnose'
: argv[0] === 'cutover-manual-resolution-prepare'
? 'local.deployment.cutover.manual-resolution-prepare'
: 'local.deployment.cutover.manual-resolution-commit',
)
: argv[0] === 'compose-revision'
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
: argv[0] === 'compose-preflight'
? preflightLocalDeploymentComposeCommandFile(argv[2]!)
: argv[0] === 'compose-apply'
? applyLocalDeploymentComposeCommandFile(argv[2]!)
: argv[0] === 'compose-restore-prepare'
? restoreLocalDeploymentComposePrepareCommandFile(argv[2]!)
: argv[0] === 'compose-restore-commit'
? restoreLocalDeploymentComposeCommitCommandFile(argv[2]!)
: argv[0] === 'compose-evidence-collect-prepare'
? collectLocalDeploymentComposeEvidencePrepareCommandFile(argv[2]!)
: collectLocalDeploymentComposeEvidenceCommitCommandFile(argv[2]!));
process.stdout.write(`${JSON.stringify(output)}\n`);
if (
argv[0] === 'compose-apply' &&
'status' in output &&
output.status !== 'active'
) {
process.exitCode = 2;
}
} catch (error) {
const candidate = error as {
readonly code?: unknown;
readonly name?: unknown;
};
process.stderr.write(
`${JSON.stringify({
code:
typeof candidate.code === 'string'
? candidate.code
: 'QL3_LOCAL_DEPLOYMENT_FAILED',
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
})}\n`,
);
process.exitCode = 1;
}
}
void main(process.argv.slice(2));
@@ -0,0 +1,237 @@
import fs from 'node:fs';
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { inspectActiveComposeImageSelection } from './compose/composeRevision';
import {
currentIdentity,
LocalDeploymentConfigurationError,
normalizeLocalDeploymentStatusCommand,
type LocalDeploymentProfile,
type LocalDeploymentServiceKind,
type LocalDeploymentStatusResult,
} from './foundation/contract';
import { validatePrivateDirectory } from './foundation/files';
import {
deploymentPaths,
type LocalDeploymentPaths,
} from './foundation/render';
const MAX_OBSERVED_FILE_BYTES = 64 * 1024;
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
function boundedPrivateFile(
filePath: string,
uid: number,
mode: number,
label: string,
): string {
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
configurationError(`${label} is unavailable`, error);
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== mode ||
stat.nlink !== 1 ||
stat.size < 2 ||
stat.size > MAX_OBSERVED_FILE_BYTES
) {
configurationError(`${label} identity is invalid`);
}
return fs.readFileSync(filePath, 'utf8');
}
function applicationProfile(
paths: Readonly<LocalDeploymentPaths>,
uid: number,
): LocalDeploymentProfile {
const contents = boundedPrivateFile(
paths.applicationConfig,
uid,
0o600,
'application configuration',
);
let value: unknown;
try {
value = JSON.parse(contents);
} catch (error) {
configurationError('application configuration is invalid', error);
}
const candidate = value as {
readonly schema?: unknown;
readonly profile?: unknown;
};
if (
candidate?.schema !== 'qinglong/local-application-process@v2' ||
(candidate.profile !== 'edge' && candidate.profile !== 'standalone')
) {
configurationError('application configuration binding drifted');
}
return candidate.profile;
}
function serviceKind(
paths: Readonly<LocalDeploymentPaths>,
uid: number,
): LocalDeploymentServiceKind {
const candidates = [
{
kind: 'systemd' as const,
fileName: 'qinglong3.service',
mode: 0o600,
markers: [
'[Unit]\n',
'Description=QingLong 3.0 local automation runtime',
],
},
{
kind: 'openrc' as const,
fileName: 'qinglong3.openrc',
mode: 0o700,
markers: [
'#!/sbin/openrc-run\n',
'description="QingLong 3.0 local automation runtime"',
],
},
{
kind: 'compose' as const,
fileName: 'compose.yaml',
mode: 0o600,
markers: ['name: ql3-', '\nservices:\n qinglong3:\n'],
},
].filter((candidate) =>
fs.existsSync(path.join(paths.service, candidate.fileName)),
);
if (candidates.length !== 1) {
configurationError('service descriptor selection drifted');
}
const selected = candidates[0]!;
const contents = boundedPrivateFile(
path.join(paths.service, selected.fileName),
uid,
selected.mode,
'service descriptor',
);
if (selected.markers.some((marker) => !contents.includes(marker))) {
configurationError('service descriptor binding drifted');
}
return selected.kind;
}
function fence(
filePath: string,
uid: number,
label: string,
): 'idle' | 'in_flight' {
if (!fs.existsSync(filePath)) return 'idle';
boundedPrivateFile(filePath, uid, 0o600, label);
return 'in_flight';
}
export function inspectLocalDeploymentStatus(
input: unknown,
): Readonly<LocalDeploymentStatusResult> {
const command = normalizeLocalDeploymentStatusCommand(input);
const identity = currentIdentity();
const paths = deploymentPaths(command.options.deploymentRoot);
validatePrivateDirectory(
command.options.deploymentRoot,
identity.uid,
'deploymentRoot',
);
validatePrivateDirectory(
paths.service,
identity.uid,
'serviceDescriptorRoot',
);
const profile = applicationProfile(paths, identity.uid);
const kind = serviceKind(paths, identity.uid);
const common = {
schemaVersion: 1 as const,
operation: 'local.deployment.status' as const,
status: 'observed' as const,
observation: 'durable' as const,
profile,
applicationConfiguration: Object.freeze({
schema: 'qinglong/local-application-process@v2' as const,
state: 'present' as const,
}),
runtime: Object.freeze({ health: 'unobserved' as const }),
};
if (kind !== 'compose') {
return Object.freeze({
...common,
service: Object.freeze({
kind,
descriptor: 'present' as const,
}),
});
}
validatePrivateDirectory(
paths.composeRevisions,
identity.uid,
'composeRevisionRoot',
);
const selection = inspectActiveComposeImageSelection(
paths.composeSelection,
paths.composeRevisions,
identity.uid,
);
const fences = Object.freeze({
revision: fence(
paths.composeRevisionLock,
identity.uid,
'compose revision lock',
),
rollout: fence(
paths.composeRolloutLock,
identity.uid,
'compose rollout lock',
),
restore: fence(
paths.composeRestoreLock,
identity.uid,
'compose restore lock',
),
evidenceCollection: fence(
paths.composeEvidenceCollectionLock,
identity.uid,
'compose evidence collection lock',
),
});
const recoveryRequired = Object.values(fences).some(
(state) => state === 'in_flight',
);
return Object.freeze({
...common,
service: Object.freeze({
kind: 'compose' as const,
descriptor: 'present' as const,
generation: selection.generation,
rollbackTargetGeneration:
selection.rollbackTargetGeneration === 0
? null
: selection.rollbackTargetGeneration,
transition: recoveryRequired
? ('recovery_required' as const)
: ('stable' as const),
fences,
}),
});
}
export function inspectLocalDeploymentStatusCommandFile(
filePath: string,
): Readonly<LocalDeploymentStatusResult> {
return inspectLocalDeploymentStatus(readPrivateLocalCommandFile(filePath));
}
@@ -0,0 +1,825 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { LocalDeploymentConfigurationError } from '../foundation/contract';
import {
normalizeLocalServiceBridgeCommand,
normalizeLocalServiceManagerIntent,
localServiceManagerIntentPath,
localServiceManagerOutcomePath,
type LocalServiceBridgeCommand,
type LocalServiceBridgeManager,
type LocalServiceManagerIntent,
} from './serviceBridgeContract';
import {
ensureRootServiceBridgeDirectory,
publishServiceBridgeFile,
readOwnerPrivateJsonFile,
readServiceBridgeFile,
validateServiceBridgeDirectory,
} from './serviceBridgeFiles';
import {
localServiceManagerObservationDigest,
localServiceManagerOutcomeDigest,
normalizeLocalServiceManagerObservation,
normalizeLocalServiceManagerOutcome,
type LocalServiceManagerManualReason,
type LocalServiceManagerMutationDisposition,
type LocalServiceManagerObservation,
type LocalServiceManagerOutcome,
} from './serviceOutcomeContract';
const MAX_MANAGER_OUTPUT_BYTES = 64 * 1024;
const MANAGER_TIMEOUT_MS = 30_000;
interface ServiceBridgeBarrier {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-local-service-bridge-barrier';
readonly actionId: string;
readonly intentDigest: string;
readonly descriptorDigest: string;
readonly managerKind: 'systemd' | 'openrc';
readonly preObservation: Readonly<LocalServiceManagerObservation>;
readonly createdAtMs: number;
readonly barrierDigest: string;
}
export interface LocalServiceBridgeRunResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.execute';
readonly status: 'prepared' | 'existing';
readonly state: LocalServiceManagerOutcome['state'];
readonly actionId: string;
readonly outcomeDigest: string;
}
export interface LocalServiceManagerRunRequest {
readonly executable: string;
readonly args: readonly string[];
readonly timeoutMs: number;
}
export interface LocalServiceManagerRunResult {
readonly status: number | null;
readonly signal: NodeJS.Signals | null;
readonly stdout: string;
readonly stderr: string;
readonly responseLost: boolean;
}
export interface LocalServiceBridgeDependencies {
readonly runManager?: (
request: Readonly<LocalServiceManagerRunRequest>,
) => Readonly<LocalServiceManagerRunResult>;
readonly now?: () => 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 digest(value: unknown): string {
return crypto
.createHash('sha256')
.update(JSON.stringify(value))
.digest('hex');
}
function sha256(bytes: Buffer): string {
return crypto.createHash('sha256').update(bytes).digest('hex');
}
function assertRootIdentity(): void {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
typeof process.getgid !== 'function' ||
typeof process.getegid !== 'function' ||
process.getuid() !== 0 ||
process.geteuid() !== 0 ||
process.getgid() !== 0 ||
process.getegid() !== 0
) {
configurationError('service bridge requires matching root identities');
}
}
function trustedRootExecutable(filePath: string, label: string): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
configurationError(`${label} is unavailable`, error);
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== 0 ||
(stat.mode & 0o022) !== 0 ||
(stat.mode & 0o111) === 0 ||
fs.realpathSync(filePath) !== filePath
) {
configurationError(`${label} must be a canonical root-owned executable`);
}
}
function validateRootDestinationParent(destinationPath: string): void {
const directory = path.dirname(destinationPath);
let stat: fs.Stats;
try {
stat = fs.lstatSync(directory);
} catch (error) {
configurationError('service descriptor destination is unavailable', error);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== 0 ||
(stat.mode & 0o022) !== 0 ||
fs.realpathSync(directory) !== directory
) {
configurationError('service descriptor destination is not trusted');
}
}
function defaultRunManager(
request: Readonly<LocalServiceManagerRunRequest>,
): Readonly<LocalServiceManagerRunResult> {
const result = spawnSync(request.executable, [...request.args], {
encoding: 'utf8',
env: Object.freeze({
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
LANG: 'C',
LC_ALL: 'C',
}),
timeout: request.timeoutMs,
maxBuffer: MAX_MANAGER_OUTPUT_BYTES,
shell: false,
windowsHide: true,
});
return Object.freeze({
status: result.status,
signal: result.signal,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
responseLost: result.error !== undefined || result.status === null,
});
}
function run(
runManager: NonNullable<LocalServiceBridgeDependencies['runManager']>,
executable: string,
args: readonly string[],
): Readonly<LocalServiceManagerRunResult> {
const result = runManager({
executable,
args: Object.freeze([...args]),
timeoutMs: MANAGER_TIMEOUT_MS,
});
if (
!result ||
typeof result !== 'object' ||
(result.status !== null && !Number.isSafeInteger(result.status)) ||
(result.signal !== null && typeof result.signal !== 'string') ||
typeof result.stdout !== 'string' ||
typeof result.stderr !== 'string' ||
typeof result.responseLost !== 'boolean' ||
Buffer.byteLength(result.stdout, 'utf8') > MAX_MANAGER_OUTPUT_BYTES ||
Buffer.byteLength(result.stderr, 'utf8') > MAX_MANAGER_OUTPUT_BYTES
) {
configurationError('service manager runner returned an invalid result');
}
return result;
}
function systemdObservation(
manager: Extract<LocalServiceBridgeManager, { kind: 'systemd' }>,
intent: Readonly<LocalServiceManagerIntent>,
runManager: NonNullable<LocalServiceBridgeDependencies['runManager']>,
now: () => number,
): Readonly<LocalServiceManagerObservation> {
const result = run(runManager, manager.executable, [
'show',
'qinglong3.service',
'--no-page',
'--property=LoadState,ActiveState,SubState,FragmentPath,MainPID,UnitFileState',
]);
const fields = new Map<string, string>();
if (!result.responseLost && result.status === 0) {
for (const line of result.stdout.split('\n')) {
const separator = line.indexOf('=');
if (separator > 0)
fields.set(line.slice(0, separator), line.slice(separator + 1));
}
}
const rawPid = Number(fields.get('MainPID') ?? '0');
const loadState = fields.get('LoadState');
const activeState = fields.get('ActiveState');
const enabledState = fields.get('UnitFileState');
const payload = Object.freeze({
managerKind: 'systemd' as const,
serviceName: 'qinglong3' as const,
fragmentPath:
fields.get('FragmentPath') ?? intent.descriptor.destinationPath,
loadState:
loadState === 'loaded'
? ('loaded' as const)
: loadState === 'not-found'
? ('not-found' as const)
: ('unknown' as const),
activeState:
activeState === 'active'
? ('active' as const)
: activeState === 'inactive'
? ('inactive' as const)
: activeState === 'failed'
? ('failed' as const)
: ('unknown' as const),
subState: (fields.get('SubState') ?? 'unknown').slice(0, 128),
enabledState:
enabledState === 'enabled'
? ('enabled' as const)
: enabledState === 'disabled'
? ('disabled' as const)
: enabledState === 'static'
? ('static' as const)
: ('unknown' as const),
mainPid:
Number.isSafeInteger(rawPid) && rawPid >= 0 && rawPid <= 0x7fffffff
? rawPid
: 0,
observedAtMs: now(),
});
return normalizeLocalServiceManagerObservation({
...payload,
observationDigest: localServiceManagerObservationDigest(payload),
});
}
function openrcObservation(
manager: Extract<LocalServiceBridgeManager, { kind: 'openrc' }>,
intent: Readonly<LocalServiceManagerIntent>,
runManager: NonNullable<LocalServiceBridgeDependencies['runManager']>,
now: () => number,
): Readonly<LocalServiceManagerObservation> {
const service = run(runManager, manager.serviceExecutable, [
'qinglong3',
'status',
]);
const enabled = run(runManager, manager.updateExecutable, [
'show',
'default',
]);
const active = !service.responseLost && service.status === 0;
const stopped =
!service.responseLost &&
service.status !== null &&
(service.stdout.includes('stopped') || service.status === 3);
const payload = Object.freeze({
managerKind: 'openrc' as const,
serviceName: 'qinglong3' as const,
fragmentPath: intent.descriptor.destinationPath,
loadState: fs.existsSync(intent.descriptor.destinationPath)
? ('loaded' as const)
: ('not-found' as const),
activeState: active
? ('active' as const)
: stopped
? ('inactive' as const)
: ('unknown' as const),
subState: active ? 'started' : stopped ? 'stopped' : 'unknown',
enabledState:
!enabled.responseLost &&
enabled.status === 0 &&
enabled.stdout.split('\n').some((line) => /\bqinglong3\b/.test(line))
? ('enabled' as const)
: ('unknown' as const),
mainPid: 0,
observedAtMs: now(),
});
return normalizeLocalServiceManagerObservation({
...payload,
observationDigest: localServiceManagerObservationDigest(payload),
});
}
function inspectManager(
manager: LocalServiceBridgeManager,
intent: Readonly<LocalServiceManagerIntent>,
runManager: NonNullable<LocalServiceBridgeDependencies['runManager']>,
now: () => number,
): Readonly<LocalServiceManagerObservation> {
return manager.kind === 'systemd'
? systemdObservation(manager, intent, runManager, now)
: openrcObservation(manager, intent, runManager, now);
}
function managerExecutables(
manager: LocalServiceBridgeManager,
): readonly string[] {
return manager.kind === 'systemd'
? Object.freeze([manager.executable])
: Object.freeze([manager.serviceExecutable, manager.updateExecutable]);
}
function executeMutation(
manager: LocalServiceBridgeManager,
intent: Readonly<LocalServiceManagerIntent>,
runManager: NonNullable<LocalServiceBridgeDependencies['runManager']>,
): Readonly<{ failed: boolean; responseLost: boolean }> {
const results: LocalServiceManagerRunResult[] = [];
if (manager.kind === 'systemd') {
if (intent.action === 'install-enable-start') {
results.push(run(runManager, manager.executable, ['daemon-reload']));
results.push(
run(runManager, manager.executable, ['enable', 'qinglong3.service']),
);
results.push(
run(runManager, manager.executable, ['start', 'qinglong3.service']),
);
} else {
results.push(
run(runManager, manager.executable, [
intent.action,
'qinglong3.service',
]),
);
}
} else if (intent.action === 'install-enable-start') {
results.push(
run(runManager, manager.updateExecutable, [
'add',
'qinglong3',
'default',
]),
);
results.push(
run(runManager, manager.serviceExecutable, ['qinglong3', 'start']),
);
} else if (intent.action === 'restart') {
results.push(
run(runManager, manager.serviceExecutable, ['qinglong3', 'stop']),
);
results.push(
run(runManager, manager.serviceExecutable, ['qinglong3', 'start']),
);
} else {
results.push(
run(runManager, manager.serviceExecutable, ['qinglong3', intent.action]),
);
}
return Object.freeze({
failed: results.some(
(result) => !result.responseLost && result.status !== 0,
),
responseLost: results.some((result) => result.responseLost),
});
}
function desiredStateProved(
intent: Readonly<LocalServiceManagerIntent>,
before: Readonly<LocalServiceManagerObservation>,
after: Readonly<LocalServiceManagerObservation>,
): boolean {
if (
after.managerKind !== intent.service.kind ||
after.fragmentPath !== intent.descriptor.destinationPath ||
after.loadState !== 'loaded'
) {
return false;
}
if (intent.action === 'stop') {
return after.activeState === 'inactive' && after.mainPid === 0;
}
if (
after.activeState !== 'active' ||
(intent.action === 'install-enable-start' &&
after.enabledState !== 'enabled')
) {
return false;
}
if (intent.service.kind === 'openrc') return true;
if (after.mainPid < 1) return false;
return (
intent.action !== 'restart' ||
before.mainPid === 0 ||
before.mainPid !== after.mainPid
);
}
function barrierRecord(
intent: Readonly<LocalServiceManagerIntent>,
observation: Readonly<LocalServiceManagerObservation>,
now: () => number,
): Readonly<ServiceBridgeBarrier> {
const payload = Object.freeze({
schemaVersion: 1 as const,
kind: 'qinglong3-local-service-bridge-barrier' as const,
actionId: intent.actionId,
intentDigest: intent.intentDigest,
descriptorDigest: intent.descriptor.sha256,
managerKind: intent.service.kind,
preObservation: observation,
createdAtMs: now(),
});
return Object.freeze({ ...payload, barrierDigest: digest(payload) });
}
function normalizeBarrier(
value: unknown,
intent: Readonly<LocalServiceManagerIntent>,
): Readonly<ServiceBridgeBarrier> {
const barrier = object(value, 'service bridge barrier');
exact(
barrier,
[
'actionId',
'barrierDigest',
'createdAtMs',
'descriptorDigest',
'intentDigest',
'kind',
'managerKind',
'preObservation',
'schemaVersion',
],
'service bridge barrier',
);
const observation = normalizeLocalServiceManagerObservation(
barrier.preObservation,
);
const { barrierDigest, ...payload } = barrier;
if (
barrier.schemaVersion !== 1 ||
barrier.kind !== 'qinglong3-local-service-bridge-barrier' ||
barrier.actionId !== intent.actionId ||
barrier.intentDigest !== intent.intentDigest ||
barrier.descriptorDigest !== intent.descriptor.sha256 ||
barrier.managerKind !== intent.service.kind ||
observation.managerKind !== intent.service.kind ||
!Number.isSafeInteger(barrier.createdAtMs) ||
(barrier.createdAtMs as number) < 0 ||
typeof barrierDigest !== 'string' ||
digest({ ...payload, preObservation: observation }) !== barrierDigest
) {
configurationError('service bridge barrier drifted');
}
return barrier as unknown as Readonly<ServiceBridgeBarrier>;
}
function outcomeRecord(
intent: Readonly<LocalServiceManagerIntent>,
state: LocalServiceManagerOutcome['state'],
disposition: LocalServiceManagerMutationDisposition,
reason: LocalServiceManagerManualReason | null,
observation: Readonly<LocalServiceManagerObservation>,
now: () => number,
): Readonly<LocalServiceManagerOutcome> {
const payload = Object.freeze({
schemaVersion: 1 as const,
kind: 'qinglong3-local-service-manager-outcome' as const,
actionId: intent.actionId,
action: intent.action,
intentDigest: intent.intentDigest,
descriptorDigest: intent.descriptor.sha256,
state,
mutationDisposition: disposition,
manualReason: reason,
observation,
completedAtMs: Math.max(now(), observation.observedAtMs),
});
return normalizeLocalServiceManagerOutcome({
...payload,
outcomeDigest: localServiceManagerOutcomeDigest(payload),
});
}
function readRootJson(filePath: string, label: string): unknown {
const file = readOwnerPrivateJsonFile(filePath, label);
if (file.uid !== 0 || file.gid !== 0) {
configurationError(`${label} is not root-owned`);
}
return file.value;
}
function publishOutcome(
actionDirectory: string,
intent: Readonly<LocalServiceManagerIntent>,
outcome: Readonly<LocalServiceManagerOutcome>,
): 'prepared' | 'existing' {
const contents = `${JSON.stringify(outcome, null, 2)}\n`;
const rootStatus = publishServiceBridgeFile(
path.join(actionDirectory, 'outcome.json'),
contents,
0o600,
0,
0,
'service bridge root outcome',
);
publishServiceBridgeFile(
intent.outcomePath,
contents,
0o600,
intent.service.uid,
intent.service.gid,
'service bridge Owner outcome',
);
return rootStatus;
}
function validateIntentMaterial(
intent: Readonly<LocalServiceManagerIntent>,
): Buffer {
validateServiceBridgeDirectory(
intent.deployment.root,
intent.service.uid,
intent.service.gid,
0o700,
'deploymentRoot',
);
validateServiceBridgeDirectory(
path.join(intent.deployment.root, 'service'),
intent.service.uid,
intent.service.gid,
0o700,
'serviceDescriptorRoot',
);
validateServiceBridgeDirectory(
path.dirname(intent.outcomePath),
intent.service.uid,
intent.service.gid,
0o700,
'serviceManagerOutcomeRoot',
);
const application = readServiceBridgeFile(
intent.deployment.applicationConfigPath,
{
uid: intent.service.uid,
gid: intent.service.gid,
mode: 0o600,
},
'application configuration',
);
const descriptor = readServiceBridgeFile(
intent.descriptor.sourcePath,
{
uid: intent.service.uid,
gid: intent.service.gid,
mode: intent.descriptor.sourceMode,
maximumBytes: 64 * 1024,
},
'service descriptor',
);
try {
if (
sha256(application) !== intent.deployment.applicationConfigSha256 ||
sha256(descriptor) !== intent.descriptor.sha256
) {
configurationError('service manager source material drifted');
}
return descriptor;
} finally {
application.fill(0);
}
}
function destinationMatches(
intent: Readonly<LocalServiceManagerIntent>,
): boolean {
try {
const bytes = readServiceBridgeFile(
intent.descriptor.destinationPath,
{
uid: 0,
gid: 0,
mode: intent.descriptor.destinationMode,
maximumBytes: 64 * 1024,
},
'installed service descriptor',
);
try {
return sha256(bytes) === intent.descriptor.sha256;
} finally {
bytes.fill(0);
}
} catch {
return false;
}
}
export function runLocalServiceBridge(
input: unknown,
dependencies: Readonly<LocalServiceBridgeDependencies> = {},
): Readonly<LocalServiceBridgeRunResult> {
assertRootIdentity();
const command = normalizeLocalServiceBridgeCommand(input);
const runManager = dependencies.runManager ?? defaultRunManager;
const now = dependencies.now ?? Date.now;
const ownedIntent = readOwnerPrivateJsonFile(
command.request.intentPath,
'service manager intent',
);
const intent = normalizeLocalServiceManagerIntent(ownedIntent.value);
if (
intent.intentDigest !== command.request.expectedIntentDigest ||
ownedIntent.uid !== intent.service.uid ||
ownedIntent.gid !== intent.service.gid ||
command.request.intentPath !==
localServiceManagerIntentPath(intent.deployment.root, intent.actionId) ||
intent.outcomePath !==
localServiceManagerOutcomePath(intent.deployment.root, intent.actionId) ||
command.options.manager.kind !== intent.service.kind
) {
configurationError('service bridge command and Owner intent drifted');
}
for (const executable of managerExecutables(command.options.manager)) {
trustedRootExecutable(executable, 'service manager executable');
}
validateRootDestinationParent(intent.descriptor.destinationPath);
const descriptor = validateIntentMaterial(intent);
ensureRootServiceBridgeDirectory(
command.options.controllerRoot,
'serviceBridgeControllerRoot',
);
const actionDirectory = path.join(
command.options.controllerRoot,
intent.actionId,
);
ensureRootServiceBridgeDirectory(actionDirectory, 'serviceBridgeActionRoot');
const barrierPath = path.join(actionDirectory, 'barrier.json');
const rootOutcomePath = path.join(actionDirectory, 'outcome.json');
try {
if (fs.existsSync(rootOutcomePath)) {
const existing = normalizeLocalServiceManagerOutcome(
readRootJson(rootOutcomePath, 'service bridge root outcome'),
);
if (
existing.actionId !== intent.actionId ||
existing.intentDigest !== intent.intentDigest ||
existing.descriptorDigest !== intent.descriptor.sha256
) {
configurationError('service bridge root outcome drifted');
}
publishOutcome(actionDirectory, intent, existing);
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.service-manager.execute' as const,
status: 'existing' as const,
state: existing.state,
actionId: existing.actionId,
outcomeDigest: existing.outcomeDigest,
});
}
const replay = fs.existsSync(barrierPath);
const barrier = replay
? normalizeBarrier(
readRootJson(barrierPath, 'service bridge barrier'),
intent,
)
: barrierRecord(
intent,
inspectManager(command.options.manager, intent, runManager, now),
now,
);
if (!replay) {
publishServiceBridgeFile(
barrierPath,
`${JSON.stringify(barrier, null, 2)}\n`,
0o600,
0,
0,
'service bridge barrier',
);
}
let descriptorReady = destinationMatches(intent);
if (
!replay &&
intent.action === 'install-enable-start' &&
!descriptorReady
) {
publishServiceBridgeFile(
intent.descriptor.destinationPath,
descriptor.toString('utf8'),
intent.descriptor.destinationMode,
0,
0,
'installed service descriptor',
);
descriptorReady = destinationMatches(intent);
}
if (!descriptorReady) {
const observation = inspectManager(
command.options.manager,
intent,
runManager,
now,
);
const outcome = outcomeRecord(
intent,
'manual_required',
replay ? 'replay-inspected' : 'executed',
'descriptor_install_unproved',
observation,
now,
);
const status = publishOutcome(actionDirectory, intent, outcome);
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.service-manager.execute' as const,
status,
state: outcome.state,
actionId: outcome.actionId,
outcomeDigest: outcome.outcomeDigest,
});
}
const mutation = replay
? Object.freeze({ failed: false, responseLost: false })
: executeMutation(command.options.manager, intent, runManager);
const observation = inspectManager(
command.options.manager,
intent,
runManager,
now,
);
const proved = desiredStateProved(
intent,
barrier.preObservation,
observation,
);
const disposition: LocalServiceManagerMutationDisposition = replay
? 'replay-inspected'
: mutation.responseLost
? 'response-loss-inspected'
: 'executed';
const manualReason: LocalServiceManagerManualReason | null = proved
? null
: mutation.failed
? 'manager_command_failed'
: 'manager_state_unproved';
const outcome = outcomeRecord(
intent,
proved
? intent.action === 'stop'
? 'stopped'
: 'active'
: 'manual_required',
disposition,
manualReason,
observation,
now,
);
const status = publishOutcome(actionDirectory, intent, outcome);
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.service-manager.execute' as const,
status,
state: outcome.state,
actionId: outcome.actionId,
outcomeDigest: outcome.outcomeDigest,
});
} finally {
descriptor.fill(0);
}
}
export function runLocalServiceBridgeCommandFile(
commandFile: string,
dependencies: Readonly<LocalServiceBridgeDependencies> = {},
): Readonly<LocalServiceBridgeRunResult> {
return runLocalServiceBridge(
readPrivateLocalCommandFile(commandFile),
dependencies,
);
}
@@ -0,0 +1,45 @@
#!/usr/bin/env node
import { runLocalServiceBridgeCommandFile } from './serviceBridge';
const USAGE =
'Usage: ql3-service-bridge run --command-file /absolute/root-owned-command.json';
function main(argv: readonly string[]): void {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
process.stdout.write(`${USAGE}\n`);
return;
}
if (argv.length !== 3 || argv[0] !== 'run' || argv[1] !== '--command-file') {
process.stderr.write(
`${JSON.stringify({
code: 'QL3_SERVICE_BRIDGE_CLI_USAGE_INVALID',
message: USAGE,
})}\n`,
);
process.exitCode = 64;
return;
}
try {
const result = runLocalServiceBridgeCommandFile(argv[2]!);
process.stdout.write(`${JSON.stringify(result)}\n`);
if (result.state === 'manual_required') process.exitCode = 2;
} catch (error) {
const candidate = error as {
readonly code?: unknown;
readonly name?: unknown;
};
process.stderr.write(
`${JSON.stringify({
code:
typeof candidate.code === 'string'
? candidate.code
: 'QL3_SERVICE_BRIDGE_FAILED',
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
})}\n`,
);
process.exitCode = 1;
}
}
main(process.argv.slice(2));
@@ -0,0 +1,453 @@
import crypto from 'node:crypto';
import path from 'node:path';
import { 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 UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const MAX_TARGET_GENERATION = 15;
export type LocalServiceManagerKind = 'systemd' | 'openrc';
export type LocalServiceManagerAction =
| 'install-enable-start'
| 'start'
| 'restart'
| 'stop';
export type LocalServiceManagerIntentLineage =
| Readonly<{ mode: 'fresh' }>
| Readonly<{
mode: 'adopted';
cutoverId: string;
generation: number;
expectedActivationDigest: string;
previousRecordDigest: string;
}>;
export interface LocalServiceManagerIntent {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-local-service-manager-intent';
readonly actionId: string;
readonly action: LocalServiceManagerAction;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly service: Readonly<{
kind: LocalServiceManagerKind;
name: 'qinglong3';
uid: number;
gid: number;
allowRootService: boolean;
}>;
readonly deployment: Readonly<{
root: string;
applicationConfigPath: string;
applicationConfigSha256: string;
}>;
readonly descriptor: Readonly<{
sourcePath: string;
destinationPath: string;
sha256: string;
sourceMode: number;
destinationMode: number;
}>;
readonly lineage: LocalServiceManagerIntentLineage;
readonly outcomePath: string;
readonly requestedAtMs: number;
readonly intentDigest: string;
}
export type LocalServiceBridgeManager =
| Readonly<{
kind: 'systemd';
executable: string;
}>
| Readonly<{
kind: 'openrc';
serviceExecutable: string;
updateExecutable: string;
}>;
export interface LocalServiceBridgeCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.execute';
readonly options: Readonly<{
controllerRoot: string;
allowRootController: true;
manager: LocalServiceBridgeManager;
}>;
readonly request: Readonly<{
intentPath: string;
expectedIntentDigest: string;
}>;
}
export function localServiceManagerIntentPath(
deploymentRoot: string,
actionId: string,
): string {
return path.join(
deploymentRoot,
'service',
'service-manager-intents',
`${actionId}.json`,
);
}
export function localServiceManagerOutcomePath(
deploymentRoot: string,
actionId: string,
): string {
return path.join(
deploymentRoot,
'service',
'service-manager-outcomes',
`${actionId}.json`,
);
}
function configurationError(message: string): never {
throw new LocalDeploymentConfigurationError(message);
}
function object(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
configurationError(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exact(
value: Record<string, unknown>,
keys: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
configurationError(`${label} shape is invalid`);
}
}
function safeAbsolutePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value ||
value.includes('\0') ||
value.includes('//') ||
!SAFE_PATH_PATTERN.test(value) ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
configurationError(`${label} must be a supervisor-safe absolute path`);
}
return value;
}
function safeInteger(value: unknown, label: string, maximum: number): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 0 ||
(value as number) > maximum
) {
configurationError(`${label} is invalid`);
}
return value as number;
}
function normalizedLineage(value: unknown): LocalServiceManagerIntentLineage {
const lineage = object(value, 'lineage');
if (lineage.mode === 'fresh') {
exact(lineage, ['mode'], 'lineage');
return Object.freeze({ mode: 'fresh' as const });
}
exact(
lineage,
[
'cutoverId',
'expectedActivationDigest',
'generation',
'mode',
'previousRecordDigest',
],
'lineage',
);
if (
lineage.mode !== 'adopted' ||
typeof lineage.cutoverId !== 'string' ||
!CUTOVER_ID_PATTERN.test(lineage.cutoverId) ||
!Number.isSafeInteger(lineage.generation) ||
(lineage.generation as number) < 1 ||
(lineage.generation as number) > MAX_TARGET_GENERATION ||
typeof lineage.expectedActivationDigest !== 'string' ||
!DIGEST_PATTERN.test(lineage.expectedActivationDigest) ||
typeof lineage.previousRecordDigest !== 'string' ||
!DIGEST_PATTERN.test(lineage.previousRecordDigest)
) {
configurationError('adopted lineage is invalid');
}
return Object.freeze({
mode: 'adopted' as const,
cutoverId: lineage.cutoverId,
generation: lineage.generation as number,
expectedActivationDigest: lineage.expectedActivationDigest,
previousRecordDigest: lineage.previousRecordDigest,
});
}
export function localServiceManagerIntentDigest(
value: Omit<LocalServiceManagerIntent, 'intentDigest'>,
): string {
return crypto
.createHash('sha256')
.update(JSON.stringify(value))
.digest('hex');
}
export function normalizeLocalServiceManagerIntent(
value: unknown,
): Readonly<LocalServiceManagerIntent> {
const intent = object(value, 'service manager intent');
exact(
intent,
[
'action',
'actionId',
'deployment',
'descriptor',
'instanceId',
'intentDigest',
'kind',
'lineage',
'outcomePath',
'profile',
'requestedAtMs',
'schemaVersion',
'service',
],
'service manager intent',
);
const service = object(intent.service, 'service');
exact(service, ['allowRootService', 'gid', 'kind', 'name', 'uid'], 'service');
if (
(service.kind !== 'systemd' && service.kind !== 'openrc') ||
service.name !== 'qinglong3' ||
typeof service.allowRootService !== 'boolean'
) {
configurationError('service identity is invalid');
}
const uid = safeInteger(service.uid, 'service uid', 0x7fffffff);
const gid = safeInteger(service.gid, 'service gid', 0x7fffffff);
if ((uid === 0) !== service.allowRootService) {
configurationError('allowRootService does not match the service uid');
}
const deployment = object(intent.deployment, 'deployment');
exact(
deployment,
['applicationConfigPath', 'applicationConfigSha256', 'root'],
'deployment',
);
const root = safeAbsolutePath(deployment.root, 'deployment root');
const applicationConfigPath = safeAbsolutePath(
deployment.applicationConfigPath,
'application config path',
);
const descriptor = object(intent.descriptor, 'descriptor');
exact(
descriptor,
[
'destinationMode',
'destinationPath',
'sha256',
'sourceMode',
'sourcePath',
],
'descriptor',
);
const sourcePath = safeAbsolutePath(
descriptor.sourcePath,
'descriptor source',
);
const destinationPath = safeAbsolutePath(
descriptor.destinationPath,
'descriptor destination',
);
const expectedSource = path.join(
root,
'service',
service.kind === 'systemd' ? 'qinglong3.service' : 'qinglong3.openrc',
);
const expectedDestination =
service.kind === 'systemd'
? '/etc/systemd/system/qinglong3.service'
: '/etc/init.d/qinglong3';
const expectedSourceMode = service.kind === 'systemd' ? 0o600 : 0o700;
const expectedDestinationMode = service.kind === 'systemd' ? 0o644 : 0o755;
const lineage = normalizedLineage(intent.lineage);
if (
intent.schemaVersion !== 1 ||
intent.kind !== 'qinglong3-local-service-manager-intent' ||
typeof intent.actionId !== 'string' ||
!UUID_V4_PATTERN.test(intent.actionId) ||
(intent.action !== 'install-enable-start' &&
intent.action !== 'start' &&
intent.action !== 'restart' &&
intent.action !== 'stop') ||
(intent.profile !== 'edge' && intent.profile !== 'standalone') ||
typeof intent.instanceId !== 'string' ||
!INSTANCE_ID_PATTERN.test(intent.instanceId) ||
applicationConfigPath !== path.join(root, 'local-application.json') ||
typeof deployment.applicationConfigSha256 !== 'string' ||
!DIGEST_PATTERN.test(deployment.applicationConfigSha256) ||
sourcePath !== expectedSource ||
destinationPath !== expectedDestination ||
descriptor.sourceMode !== expectedSourceMode ||
descriptor.destinationMode !== expectedDestinationMode ||
typeof descriptor.sha256 !== 'string' ||
!DIGEST_PATTERN.test(descriptor.sha256) ||
typeof intent.outcomePath !== 'string' ||
safeAbsolutePath(intent.outcomePath, 'outcome path') !==
path.join(
root,
'service',
'service-manager-outcomes',
`${intent.actionId}.json`,
) ||
!Number.isSafeInteger(intent.requestedAtMs) ||
(intent.requestedAtMs as number) < 0 ||
typeof intent.intentDigest !== 'string' ||
!DIGEST_PATTERN.test(intent.intentDigest)
) {
configurationError('service manager intent binding is invalid');
}
if (
lineage.mode === 'adopted' &&
((lineage.generation === 1 &&
intent.action !== 'install-enable-start' &&
intent.action !== 'start' &&
intent.action !== 'stop') ||
(lineage.generation >= 2 &&
intent.action !== 'restart' &&
intent.action !== 'stop'))
) {
configurationError(
'service manager action does not match the adopted generation',
);
}
const payload = Object.freeze({
schemaVersion: 1 as const,
kind: 'qinglong3-local-service-manager-intent' as const,
actionId: intent.actionId,
action: intent.action,
profile: intent.profile,
instanceId: intent.instanceId,
service: Object.freeze({
kind: service.kind,
name: 'qinglong3' as const,
uid,
gid,
allowRootService: service.allowRootService,
}),
deployment: Object.freeze({
root,
applicationConfigPath,
applicationConfigSha256: deployment.applicationConfigSha256,
}),
descriptor: Object.freeze({
sourcePath,
destinationPath,
sha256: descriptor.sha256,
sourceMode: expectedSourceMode,
destinationMode: expectedDestinationMode,
}),
lineage,
outcomePath: intent.outcomePath,
requestedAtMs: intent.requestedAtMs as number,
});
if (localServiceManagerIntentDigest(payload) !== intent.intentDigest) {
configurationError('service manager intent digest is invalid');
}
return Object.freeze({ ...payload, intentDigest: intent.intentDigest });
}
function normalizedManager(value: unknown): LocalServiceBridgeManager {
const manager = object(value, 'manager');
if (manager.kind === 'systemd') {
exact(manager, ['executable', 'kind'], 'manager');
return Object.freeze({
kind: 'systemd' as const,
executable: safeAbsolutePath(manager.executable, 'systemd executable'),
});
}
exact(manager, ['kind', 'serviceExecutable', 'updateExecutable'], 'manager');
if (manager.kind !== 'openrc') configurationError('manager kind is invalid');
return Object.freeze({
kind: 'openrc' as const,
serviceExecutable: safeAbsolutePath(
manager.serviceExecutable,
'OpenRC service executable',
),
updateExecutable: safeAbsolutePath(
manager.updateExecutable,
'OpenRC update executable',
),
});
}
export function normalizeLocalServiceBridgeCommand(
value: unknown,
): Readonly<LocalServiceBridgeCommand> {
const command = object(value, 'service bridge command');
exact(
command,
['operation', 'options', 'request', 'schemaVersion'],
'command',
);
const options = object(command.options, 'options');
exact(
options,
['allowRootController', 'controllerRoot', 'manager'],
'options',
);
const request = object(command.request, 'request');
exact(request, ['expectedIntentDigest', 'intentPath'], 'request');
if (
command.schemaVersion !== 1 ||
command.operation !== 'local.deployment.service-manager.execute' ||
options.allowRootController !== true ||
typeof request.expectedIntentDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedIntentDigest)
) {
configurationError('service bridge command binding is invalid');
}
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.service-manager.execute' as const,
options: Object.freeze({
controllerRoot: safeAbsolutePath(
options.controllerRoot,
'controller root',
),
allowRootController: true as const,
manager: normalizedManager(options.manager),
}),
request: Object.freeze({
intentPath: safeAbsolutePath(request.intentPath, 'intent path'),
expectedIntentDigest: request.expectedIntentDigest,
}),
});
}
@@ -0,0 +1,350 @@
import fs from 'node:fs';
import path from 'node:path';
import { LocalDeploymentConfigurationError } from '../foundation/contract';
const MAX_FILE_BYTES = 1024 * 1024;
export interface OwnedPrivateJson {
readonly uid: number;
readonly gid: number;
readonly value: unknown;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
function fsyncDirectory(directory: string): void {
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
try {
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
export function validateServiceBridgeDirectory(
directory: string,
uid: number,
gid: number | undefined,
mode: number,
label: string,
): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(directory);
} catch (error) {
configurationError(`${label} is unavailable`, error);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(gid !== undefined && stat.gid !== gid) ||
(stat.mode & 0o777) !== mode ||
fs.realpathSync(directory) !== directory
) {
configurationError(`${label} identity is invalid`);
}
}
export function ensureRootServiceBridgeDirectory(
directory: string,
label: string,
): 'prepared' | 'existing' {
let created = false;
try {
fs.mkdirSync(directory, { mode: 0o700 });
created = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
configurationError(`${label} cannot be created`, error);
}
}
validateServiceBridgeDirectory(directory, 0, 0, 0o700, label);
return created ? 'prepared' : 'existing';
}
function readExactFile(
filePath: string,
expected: Readonly<{
uid?: number;
gid?: number;
mode: number;
maximumBytes?: number;
}>,
label: string,
): Readonly<{ uid: number; gid: number; bytes: Buffer }> {
let descriptor: number | undefined;
try {
const before = fs.lstatSync(filePath, { bigint: true });
const maximumBytes = expected.maximumBytes ?? MAX_FILE_BYTES;
if (
!before.isFile() ||
before.isSymbolicLink() ||
(expected.uid !== undefined && Number(before.uid) !== expected.uid) ||
(expected.gid !== undefined && Number(before.gid) !== expected.gid) ||
(Number(before.mode) & 0o777) !== expected.mode ||
before.nlink !== 1n ||
before.size < 1n ||
before.size > BigInt(maximumBytes) ||
fs.realpathSync(filePath) !== filePath
) {
configurationError(`${label} identity is invalid`);
}
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor, { bigint: true });
if (
!opened.isFile() ||
opened.dev !== before.dev ||
opened.ino !== before.ino ||
opened.size !== before.size ||
opened.uid !== before.uid ||
opened.gid !== before.gid ||
(Number(opened.mode) & 0o777) !== expected.mode ||
opened.nlink !== 1n
) {
configurationError(`${label} identity changed while opening`);
}
const bytes = Buffer.allocUnsafe(Number(opened.size));
let offset = 0;
while (offset < bytes.byteLength) {
const read = fs.readSync(
descriptor,
bytes,
offset,
bytes.byteLength - offset,
null,
);
if (read === 0) break;
offset += read;
}
const after = fs.fstatSync(descriptor, { bigint: true });
if (
offset !== bytes.byteLength ||
after.dev !== opened.dev ||
after.ino !== opened.ino ||
after.size !== opened.size ||
after.uid !== opened.uid ||
after.gid !== opened.gid ||
(Number(after.mode) & 0o777) !== expected.mode ||
after.nlink !== 1n
) {
bytes.fill(0);
configurationError(`${label} identity changed while reading`);
}
return Object.freeze({
uid: Number(after.uid),
gid: Number(after.gid),
bytes,
});
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
return configurationError(`${label} cannot be read`, error);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function readOwnerPrivateJsonFile(
filePath: string,
label: string,
): Readonly<OwnedPrivateJson> {
const material = readExactFile(filePath, { mode: 0o600 }, label);
try {
return Object.freeze({
uid: material.uid,
gid: material.gid,
value: JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(material.bytes),
) as unknown,
});
} catch (error) {
return configurationError(`${label} JSON is invalid`, error);
} finally {
material.bytes.fill(0);
}
}
export function readServiceBridgeFile(
filePath: string,
expected: Readonly<{
uid: number;
gid: number;
mode: number;
maximumBytes?: number;
}>,
label: string,
): Buffer {
return readExactFile(filePath, expected, label).bytes;
}
function stagePathFor(targetPath: string): string {
return path.join(
path.dirname(targetPath),
`.${path.basename(targetPath)}.ql3-service-bridge-stage`,
);
}
function validatePublishedFile(
filePath: string,
bytes: Buffer,
mode: number,
uid: number,
gid: number,
allowedLinks: readonly number[],
label: string,
): fs.Stats {
const stat = fs.lstatSync(filePath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
stat.gid !== gid ||
(stat.mode & 0o777) !== mode ||
!allowedLinks.includes(stat.nlink) ||
stat.size !== bytes.byteLength ||
!bytes.equals(fs.readFileSync(filePath))
) {
configurationError(`${label} drifted`);
}
return stat;
}
function writeOwnedStage(
stagePath: string,
bytes: Buffer,
mode: number,
uid: number,
gid: number,
label: string,
): void {
let descriptor: number | undefined;
let created = false;
try {
descriptor = fs.openSync(
stagePath,
fs.constants.O_WRONLY |
fs.constants.O_CREAT |
fs.constants.O_EXCL |
(fs.constants.O_NOFOLLOW ?? 0),
0o600,
);
created = true;
const opened = fs.fstatSync(descriptor);
if (!opened.isFile() || opened.uid !== 0 || opened.nlink !== 1) {
configurationError(`${label} stage identity is invalid`);
}
let offset = 0;
while (offset < bytes.byteLength) {
const written = fs.writeSync(
descriptor,
bytes,
offset,
bytes.byteLength - offset,
);
if (written < 1) configurationError(`${label} stage write stalled`);
offset += written;
}
fs.fchownSync(descriptor, uid, gid);
fs.fchmodSync(descriptor, mode);
fs.fsyncSync(descriptor);
const after = fs.fstatSync(descriptor);
if (
!after.isFile() ||
after.uid !== uid ||
after.gid !== gid ||
(after.mode & 0o777) !== mode ||
after.nlink !== 1 ||
after.size !== bytes.byteLength
) {
configurationError(`${label} stage ownership is invalid`);
}
} catch (error) {
if (created) {
try {
fs.unlinkSync(stagePath);
} catch {
// A deterministic stage is intentionally left fail-closed.
}
}
if (error instanceof LocalDeploymentConfigurationError) throw error;
configurationError(`${label} stage cannot be written`, error);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function publishServiceBridgeFile(
targetPath: string,
contents: string,
mode: number,
uid: number,
gid: number,
label: string,
): 'prepared' | 'existing' {
const bytes = Buffer.from(contents, 'utf8');
if (bytes.byteLength < 2 || bytes.byteLength > MAX_FILE_BYTES) {
configurationError(`${label} has an invalid size`);
}
const directory = path.dirname(targetPath);
const stagePath = stagePathFor(targetPath);
const existed = fs.existsSync(targetPath);
if (existed) {
validatePublishedFile(targetPath, bytes, mode, uid, gid, [1, 2], label);
}
if (fs.existsSync(stagePath)) {
validatePublishedFile(
stagePath,
bytes,
mode,
uid,
gid,
[1, 2],
`${label} stage`,
);
} else if (!existed) {
writeOwnedStage(stagePath, bytes, mode, uid, gid, label);
}
if (!fs.existsSync(targetPath)) {
try {
fs.linkSync(stagePath, targetPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
configurationError(`${label} cannot be published`, error);
}
}
fsyncDirectory(directory);
}
const target = validatePublishedFile(
targetPath,
bytes,
mode,
uid,
gid,
[1, 2],
label,
);
if (fs.existsSync(stagePath)) {
const stage = validatePublishedFile(
stagePath,
bytes,
mode,
uid,
gid,
[1, 2],
`${label} stage`,
);
if (target.dev !== stage.dev || target.ino !== stage.ino) {
configurationError(`${label} stage identity drifted`);
}
fs.unlinkSync(stagePath);
fsyncDirectory(directory);
}
validatePublishedFile(targetPath, bytes, mode, uid, gid, [1], label);
return existed ? 'existing' : 'prepared';
}
@@ -0,0 +1,862 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
readPrivateLocalCommandFile,
readPrivateLocalJsonFile,
} from '@qinglong/local-command-file';
import {
currentIdentity,
LocalDeploymentConfigurationError,
} from '../foundation/contract';
import {
advanceLocalCutoverInstanceHead,
readLocalCutoverInstanceHead,
} from '../cutover/instanceLineage';
import {
cutoverDigest,
readTargetStartupReceipt,
type TargetStartupReceiptEvidence,
} from '../cutover/targetEvidence';
import {
localServiceManagerIntentPath,
localServiceManagerOutcomePath,
normalizeLocalServiceManagerIntent,
type LocalServiceManagerIntent,
} from './serviceBridgeContract';
import {
consumeLocalServiceManagerOutcome,
type LocalServiceManagerOutcomeConsumeCommand,
} from './serviceManagerIntent';
import {
normalizeLocalServiceManagerOutcome,
type LocalServiceManagerOutcome,
} from './serviceOutcomeContract';
import {
localServiceManagerCutoverRecord,
localServiceManagerCutoverRecordPath,
normalizeLocalServiceManagerCutoverRecord,
publishLocalServiceManagerCutoverRecord,
readLocalServiceManagerActiveRecord,
type LocalServiceManagerCutoverEvidence,
type LocalServiceManagerCutoverRecord,
type LocalServiceManagerCutoverState,
} from './serviceCutoverJournal';
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 UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
const MAX_PATH_BYTES = 4_096;
export interface LocalServiceManagerCutoverConsumeCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.cutover.consume';
readonly options: Readonly<{
deploymentRoot: string;
allowRootService: boolean;
startupTimeoutMs: number;
startupPollMs: number;
}>;
readonly request: Readonly<{
actionId: string;
expectedIntentDigest: string;
}>;
}
export interface LocalServiceManagerCutoverConsumeResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.cutover.consume';
readonly status: 'prepared' | 'existing';
readonly state: LocalServiceManagerCutoverState;
readonly cutoverId: string;
readonly generation: number;
readonly recordDigest: string;
readonly instanceHeadDigest: string;
}
export interface LocalServiceManagerCutoverDependencies {
readonly procRoot?: string;
readonly now?: () => number;
readonly wait?: (milliseconds: number) => Promise<void>;
}
interface AdoptedBinding {
readonly cutoverId: string;
readonly activationDigest: string;
readonly commitmentDigest: string;
readonly commitmentPath: string;
readonly activationPath: string;
readonly sourcePath: string;
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
readonly applicationConfigDigest: 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 safeAbsolutePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value ||
value.includes('\0') ||
value.includes('//') ||
!SAFE_PATH_PATTERN.test(value) ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
configurationError(`${label} must be a supervisor-safe absolute path`);
}
return value;
}
function normalizeCommand(
value: unknown,
): Readonly<LocalServiceManagerCutoverConsumeCommand> {
const command = object(value, 'service manager cutover command');
exact(
command,
['operation', 'options', 'request', 'schemaVersion'],
'command',
);
const options = object(command.options, 'options');
exact(
options,
['allowRootService', 'deploymentRoot', 'startupPollMs', 'startupTimeoutMs'],
'options',
);
const request = object(command.request, 'request');
exact(request, ['actionId', 'expectedIntentDigest'], 'request');
const identity = currentIdentity();
if (
command.schemaVersion !== 1 ||
command.operation !== 'local.deployment.service-manager.cutover.consume' ||
typeof options.allowRootService !== 'boolean' ||
(identity.uid === 0) !== options.allowRootService ||
!Number.isSafeInteger(options.startupTimeoutMs) ||
(options.startupTimeoutMs as number) < 100 ||
(options.startupTimeoutMs as number) > 120_000 ||
!Number.isSafeInteger(options.startupPollMs) ||
(options.startupPollMs as number) < 10 ||
(options.startupPollMs as number) > 1_000 ||
(options.startupPollMs as number) > (options.startupTimeoutMs as number) ||
typeof request.actionId !== 'string' ||
!UUID_V4_PATTERN.test(request.actionId) ||
typeof request.expectedIntentDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedIntentDigest)
) {
configurationError('service manager cutover command is invalid');
}
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.service-manager.cutover.consume' as const,
options: Object.freeze({
deploymentRoot: safeAbsolutePath(
options.deploymentRoot,
'deploymentRoot',
),
allowRootService: options.allowRootService,
startupTimeoutMs: options.startupTimeoutMs as number,
startupPollMs: options.startupPollMs as number,
}),
request: Object.freeze({
actionId: request.actionId,
expectedIntentDigest: request.expectedIntentDigest,
}),
});
}
function readIntentAndOutcome(
command: Readonly<LocalServiceManagerCutoverConsumeCommand>,
): Readonly<{
intent: Readonly<LocalServiceManagerIntent>;
outcome: Readonly<LocalServiceManagerOutcome>;
}> {
const intent = normalizeLocalServiceManagerIntent(
readPrivateLocalJsonFile(
localServiceManagerIntentPath(
command.options.deploymentRoot,
command.request.actionId,
),
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
),
);
const outcome = normalizeLocalServiceManagerOutcome(
readPrivateLocalJsonFile(
localServiceManagerOutcomePath(
command.options.deploymentRoot,
command.request.actionId,
),
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
),
);
if (
intent.intentDigest !== command.request.expectedIntentDigest ||
outcome.actionId !== intent.actionId ||
outcome.action !== intent.action ||
outcome.intentDigest !== intent.intentDigest
) {
configurationError('service manager cutover outcome binding drifted');
}
return Object.freeze({ intent, outcome });
}
function adoptedBinding(
intent: Readonly<LocalServiceManagerIntent>,
): Readonly<AdoptedBinding> {
if (intent.lineage.mode !== 'adopted') {
configurationError('fresh service outcome has no cutover lineage');
}
const config = object(
readPrivateLocalCommandFile(intent.deployment.applicationConfigPath),
'adopted application configuration',
);
const storage = object(config.storage, 'adopted storage');
const cutover = object(config.cutover, 'adopted cutover');
const expectedCommitmentPath = path.join(
intent.deployment.root,
'service',
'cutovers',
intent.lineage.cutoverId,
'0002-legacy-stopped.json',
);
if (
config.schema !== 'qinglong/local-application-process@v3' ||
config.profile !== intent.profile ||
config.instanceId !== intent.instanceId ||
storage.mode !== 'adopted' ||
storage.expectedActivationDigest !==
intent.lineage.expectedActivationDigest ||
cutover.cutoverId !== intent.lineage.cutoverId ||
typeof cutover.expectedCommitmentDigest !== 'string' ||
!DIGEST_PATTERN.test(cutover.expectedCommitmentDigest)
) {
configurationError('adopted application configuration drifted');
}
const binding = Object.freeze({
cutoverId: cutover.cutoverId,
activationDigest: storage.expectedActivationDigest,
commitmentDigest: cutover.expectedCommitmentDigest,
commitmentPath: safeAbsolutePath(cutover.commitmentPath, 'commitmentPath'),
activationPath: safeAbsolutePath(storage.activationPath, 'activationPath'),
sourcePath: safeAbsolutePath(storage.sourcePath, 'sourcePath'),
targetPath: safeAbsolutePath(storage.targetPath, 'targetPath'),
recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'),
manifestPath: safeAbsolutePath(storage.manifestPath, 'manifestPath'),
applicationConfigDigest: intent.deployment.applicationConfigSha256,
});
if (binding.commitmentPath !== expectedCommitmentPath) {
configurationError('adopted application material binding drifted');
}
return binding;
}
function textDigest(value: string): string {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
function privateFileIdentity(
filePath: string,
uid: number,
label: string,
): Readonly<{ device: string; inode: string; digest: string }> {
try {
const stat = fs.lstatSync(filePath, { bigint: true });
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
(Number(stat.mode) & 0o077) !== 0 ||
stat.nlink !== 1n ||
fs.realpathSync(filePath) !== filePath
) {
configurationError(`${label} identity is invalid`);
}
return Object.freeze({
device: stat.dev.toString(),
inode: stat.ino.toString(),
digest: cutoverDigest({
pathDigest: textDigest(filePath),
device: stat.dev.toString(),
inode: stat.ino.toString(),
uid: Number(stat.uid),
mode: Number(stat.mode) & 0o777,
}),
});
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
return configurationError(`${label} cannot be inspected`, error);
}
}
function privateFileSha256(
filePath: string,
uid: number,
label: string,
): string {
let descriptor: number | undefined;
const buffer = Buffer.allocUnsafe(64 * 1024);
try {
const before = fs.lstatSync(filePath, { bigint: true });
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor, { bigint: true });
if (
!opened.isFile() ||
opened.dev !== before.dev ||
opened.ino !== before.ino ||
opened.size !== before.size ||
Number(opened.uid) !== uid ||
(Number(opened.mode) & 0o077) !== 0 ||
opened.nlink !== 1n
) {
configurationError(`${label} identity changed while opening`);
}
const hash = crypto.createHash('sha256');
for (;;) {
const count = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null);
if (count === 0) break;
hash.update(buffer.subarray(0, count));
}
const after = fs.fstatSync(descriptor, { bigint: true });
if (
after.dev !== opened.dev ||
after.ino !== opened.ino ||
after.size !== opened.size ||
after.mtimeNs !== opened.mtimeNs ||
after.ctimeNs !== opened.ctimeNs ||
after.nlink !== 1n
) {
configurationError(`${label} changed while hashing`);
}
return hash.digest('hex');
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
return configurationError(`${label} cannot be hashed`, error);
} finally {
buffer.fill(0);
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function verifyAdoptedEvidence(
intent: Readonly<LocalServiceManagerIntent>,
binding: Readonly<AdoptedBinding>,
uid: number,
): string {
const activation = object(
readPrivateLocalCommandFile(binding.activationPath),
'activation',
);
const { activationDigest, ...activationPayload } = activation;
const commitment = object(
readPrivateLocalCommandFile(binding.commitmentPath),
'legacy silence commitment',
);
const { commitmentDigest, ...commitmentPayload } = commitment;
const manifestDocument = object(
readPrivateLocalCommandFile(binding.manifestPath),
'adoption manifest',
);
const { manifestDigest, ...manifestPayload } = manifestDocument;
if (
activation.schemaVersion !== 1 ||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
activation.state !== 'prepared' ||
activation.profile !== intent.profile ||
activation.sourcePathDigest !== textDigest(binding.sourcePath) ||
activation.targetPathDigest !== textDigest(binding.targetPath) ||
typeof activation.recoverySha256 !== 'string' ||
!DIGEST_PATTERN.test(activation.recoverySha256) ||
typeof activation.adoptionManifestDigest !== 'string' ||
!DIGEST_PATTERN.test(activation.adoptionManifestDigest) ||
activationDigest !== binding.activationDigest ||
cutoverDigest(activationPayload) !== activationDigest ||
commitment.schemaVersion !== 1 ||
commitment.kind !== 'qinglong3-local-legacy-silence-commitment' ||
commitment.state !== 'legacy_stopped' ||
commitment.cutoverId !== binding.cutoverId ||
commitment.profile !== intent.profile ||
commitment.instanceId !== intent.instanceId ||
commitment.activationDigest !== binding.activationDigest ||
commitmentDigest !== binding.commitmentDigest ||
cutoverDigest(commitmentPayload) !== commitmentDigest ||
typeof manifestDigest !== 'string' ||
!DIGEST_PATTERN.test(manifestDigest) ||
cutoverDigest(manifestPayload) !== manifestDigest ||
activation.adoptionManifestDigest !== manifestDigest
) {
configurationError('adopted activation or commitment drifted');
}
const target = privateFileIdentity(
binding.targetPath,
uid,
'target database',
);
const source = privateFileIdentity(binding.sourcePath, uid, 'legacy source');
const recovery = privateFileIdentity(binding.recoveryPath, uid, 'recovery');
const manifestIdentity = privateFileIdentity(
binding.manifestPath,
uid,
'manifest',
);
const sourceSha256 = privateFileSha256(
binding.sourcePath,
uid,
'legacy source',
);
const recoverySha256 = privateFileSha256(
binding.recoveryPath,
uid,
'recovery',
);
if (
activation.targetDevice !== target.device ||
activation.targetInode !== target.inode ||
sourceSha256 !== activation.recoverySha256 ||
recoverySha256 !== activation.recoverySha256
) {
configurationError('adopted data evidence drifted');
}
return cutoverDigest({
target: target.digest,
source: source.digest,
recovery: recovery.digest,
recoverySha256,
manifest: manifestIdentity.digest,
manifestDigest,
sourceSha256,
});
}
function parseProcessStartTicks(contents: string): string {
const commandEnd = contents.lastIndexOf(') ');
const fields =
commandEnd < 2
? []
: contents
.slice(commandEnd + 2)
.trim()
.split(/\s+/u);
const startTicks = fields[19];
if (!startTicks || !/^[1-9][0-9]{0,19}$/.test(startTicks)) {
configurationError('service process stat is invalid');
}
return startTicks;
}
function readShutdownReceipt(
intent: Readonly<LocalServiceManagerIntent>,
startup: Readonly<TargetStartupReceiptEvidence>,
): string | null {
const receiptPath = `${intent.deployment.applicationConfigPath}.stopped.json`;
if (!fs.existsSync(receiptPath)) return null;
const receipt = object(
readPrivateLocalJsonFile(receiptPath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
}),
'application shutdown receipt',
);
exact(
receipt,
[
'bootId',
'instanceId',
'nodeExecutable',
'nodeVersion',
'processId',
'processStartTicks',
'profile',
'schema',
'schemaVersion',
'sha256',
'signal',
'stoppedBootAgeMs',
'stopResult',
'startupReceiptDigest',
],
'application shutdown receipt',
);
const { sha256, ...payload } = receipt;
const digest = crypto
.createHash('sha256')
.update('qinglong.local-application-shutdown-receipt.v1\0', 'utf8')
.update(JSON.stringify(payload), 'utf8')
.digest('hex');
if (
receipt.schemaVersion !== 1 ||
receipt.schema !== 'qinglong/local-application-shutdown-receipt@v1' ||
receipt.instanceId !== intent.instanceId ||
receipt.profile !== intent.profile ||
receipt.signal !== 'SIGTERM' ||
receipt.stopResult !== 'stopped' ||
receipt.startupReceiptDigest !== startup.digest ||
receipt.bootId !== startup.bootId ||
!BOOT_ID_PATTERN.test(receipt.bootId as string) ||
!Number.isSafeInteger(receipt.stoppedBootAgeMs) ||
(receipt.stoppedBootAgeMs as number) < startup.activeBootAgeMs ||
receipt.processId !== startup.processId ||
receipt.processStartTicks !== startup.processStartTicks ||
!START_TICKS_PATTERN.test(receipt.processStartTicks as string) ||
receipt.nodeExecutable !== startup.nodeExecutable ||
typeof receipt.nodeVersion !== 'string' ||
!/^v24\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/u.test(
receipt.nodeVersion,
) ||
typeof sha256 !== 'string' ||
!DIGEST_PATTERN.test(sha256) ||
digest !== sha256
) {
configurationError('application shutdown receipt is invalid');
}
return digest;
}
function processIdentity(
receipt: Readonly<TargetStartupReceiptEvidence>,
intent: Readonly<LocalServiceManagerIntent>,
outcome: Readonly<LocalServiceManagerOutcome>,
procRoot: string,
): string {
if (
outcome.observation.mainPid !== 0 &&
outcome.observation.mainPid !== receipt.processId
) {
configurationError('manager PID does not match the startup receipt');
}
try {
const processRoot = path.join(procRoot, String(receipt.processId));
const before = fs.lstatSync(processRoot, { bigint: true });
const firstTicks = parseProcessStartTicks(
fs.readFileSync(path.join(processRoot, 'stat'), 'utf8'),
);
const executable = fs.realpathSync(path.join(processRoot, 'exe'));
const secondTicks = parseProcessStartTicks(
fs.readFileSync(path.join(processRoot, 'stat'), 'utf8'),
);
const after = fs.lstatSync(processRoot, { bigint: true });
if (
Number(before.uid) !== intent.service.uid ||
before.dev !== after.dev ||
before.ino !== after.ino ||
firstTicks !== receipt.processStartTicks ||
secondTicks !== firstTicks ||
executable !== receipt.nodeExecutable
) {
configurationError('startup receipt process identity drifted');
}
return cutoverDigest({
bootId: receipt.bootId,
processId: receipt.processId,
processStartTicks: receipt.processStartTicks,
nodeExecutable: receipt.nodeExecutable,
uid: intent.service.uid,
});
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
return configurationError('startup receipt process is unavailable', error);
}
}
async function awaitActiveReceipt(
command: Readonly<LocalServiceManagerCutoverConsumeCommand>,
intent: Readonly<LocalServiceManagerIntent>,
outcome: Readonly<LocalServiceManagerOutcome>,
previousReceiptDigest: string | null,
dependencies: LocalServiceManagerCutoverDependencies,
): Promise<Readonly<{
receiptDigest: string;
processIdentityDigest: string;
}> | null> {
const now = dependencies.now ?? Date.now;
const wait =
dependencies.wait ??
((milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
const procRoot = dependencies.procRoot ?? '/proc';
const deadline = now() + command.options.startupTimeoutMs;
for (;;) {
try {
const receipt = readTargetStartupReceipt({
request: {
applicationConfigPath: intent.deployment.applicationConfigPath,
instanceId: intent.instanceId,
profile: intent.profile,
},
});
if (receipt !== null && receipt.digest !== previousReceiptDigest) {
return Object.freeze({
receiptDigest: receipt.digest,
processIdentityDigest: processIdentity(
receipt,
intent,
outcome,
procRoot,
),
});
}
} catch {
// A service manager may report active just before the application
// atomically replaces its receipt. Retry only this Owner-side read.
}
if (now() >= deadline) return null;
await wait(command.options.startupPollMs);
}
}
function desiredState(
intent: Readonly<LocalServiceManagerIntent>,
outcome: Readonly<LocalServiceManagerOutcome>,
): LocalServiceManagerCutoverState {
if (outcome.state === 'manual_required') return 'manual_required';
if (intent.action === 'stop' && outcome.state === 'stopped') {
return 'target_stopped';
}
if (intent.action !== 'stop' && outcome.state === 'active') {
return 'target_active';
}
return configurationError('manager outcome state does not match its action');
}
function replayResult(
command: Readonly<LocalServiceManagerCutoverConsumeCommand>,
intent: Readonly<LocalServiceManagerIntent>,
outcome: Readonly<LocalServiceManagerOutcome>,
): Readonly<LocalServiceManagerCutoverConsumeResult> | undefined {
if (intent.lineage.mode !== 'adopted') return undefined;
const state = desiredState(intent, outcome);
const recordPath = localServiceManagerCutoverRecordPath(intent, state);
if (!fs.existsSync(recordPath)) return undefined;
const record = normalizeLocalServiceManagerCutoverRecord(
readPrivateLocalCommandFile(recordPath),
);
const head = readLocalCutoverInstanceHead(
intent.deployment.root,
intent.instanceId,
currentIdentity().uid,
);
if (
record.actionId !== intent.actionId ||
record.intentDigest !== intent.intentDigest ||
record.evidence.managerOutcomeDigest !== outcome.outcomeDigest ||
head.cutoverId !== intent.lineage.cutoverId ||
head.generation !== intent.lineage.generation ||
head.state !== record.state ||
head.sourceRecordDigest !== record.recordDigest
) {
configurationError('service manager cutover replay drifted');
}
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status: 'existing' as const,
state: record.state,
cutoverId: record.cutoverId,
generation: record.generation,
recordDigest: record.recordDigest,
instanceHeadDigest: head.headDigest,
});
}
export async function consumeLocalServiceManagerCutoverOutcome(
input: unknown,
dependencies: LocalServiceManagerCutoverDependencies = {},
): Promise<Readonly<LocalServiceManagerCutoverConsumeResult>> {
const command = normalizeCommand(input);
const identity = currentIdentity();
const { intent, outcome } = readIntentAndOutcome(command);
const replay = replayResult(command, intent, outcome);
if (replay !== undefined) return replay;
consumeLocalServiceManagerOutcome({
schemaVersion: 1,
operation: 'local.deployment.service-manager.outcome.consume',
options: {
deploymentRoot: command.options.deploymentRoot,
allowRootService: command.options.allowRootService,
},
request: command.request,
} satisfies LocalServiceManagerOutcomeConsumeCommand);
if (intent.lineage.mode !== 'adopted') {
configurationError('fresh service outcome cannot advance cutover lineage');
}
const state = desiredState(intent, outcome);
const binding = adoptedBinding(intent);
const targetDataIdentityDigest = verifyAdoptedEvidence(
intent,
binding,
identity.uid,
);
let startupReceiptDigest: string | null = null;
let shutdownReceiptDigest: string | null = null;
let processIdentityDigest: string | null = null;
let manualReason: string | null = outcome.manualReason;
let finalState = state;
if (state === 'target_active') {
let previousReceiptDigest: string | null = null;
if (intent.lineage.generation > 1) {
const previous = readLocalServiceManagerActiveRecord(
intent.deployment.root,
intent.lineage.cutoverId,
intent.lineage.generation - 1,
);
if (previous.recordDigest !== intent.lineage.previousRecordDigest) {
configurationError('previous active record lost the lineage CAS');
}
previousReceiptDigest = previous.evidence.startupReceiptDigest;
}
const active = await awaitActiveReceipt(
command,
intent,
outcome,
previousReceiptDigest,
dependencies,
);
if (active === null) {
finalState = 'manual_required';
manualReason = 'application_startup_receipt_unproved';
} else {
startupReceiptDigest = active.receiptDigest;
processIdentityDigest = active.processIdentityDigest;
}
} else if (state === 'target_stopped') {
const previous = readLocalServiceManagerActiveRecord(
intent.deployment.root,
intent.lineage.cutoverId,
intent.lineage.generation,
);
if (previous.recordDigest !== intent.lineage.previousRecordDigest) {
configurationError('stopped service lost the active lineage CAS');
}
startupReceiptDigest = previous.evidence.startupReceiptDigest;
processIdentityDigest = previous.evidence.processIdentityDigest;
const staleReceipt = readTargetStartupReceipt({
request: {
applicationConfigPath: intent.deployment.applicationConfigPath,
instanceId: intent.instanceId,
profile: intent.profile,
},
});
if (staleReceipt === null || staleReceipt.digest !== startupReceiptDigest) {
finalState = 'manual_required';
manualReason = 'stopped_process_identity_unproved';
} else {
shutdownReceiptDigest = readShutdownReceipt(intent, staleReceipt);
if (shutdownReceiptDigest === null) {
finalState = 'manual_required';
manualReason = 'application_shutdown_receipt_unproved';
} else {
try {
processIdentity(
staleReceipt,
intent,
outcome,
dependencies.procRoot ?? '/proc',
);
finalState = 'manual_required';
manualReason = 'stopped_process_still_active';
} catch {
// The exact receipted PID/start identity no longer exists, as required.
}
}
}
}
const evidence: Readonly<LocalServiceManagerCutoverEvidence> = Object.freeze({
managerOutcomeDigest: outcome.outcomeDigest,
managerObservationDigest: outcome.observation.observationDigest,
applicationConfigDigest: binding.applicationConfigDigest,
activationDigest: binding.activationDigest,
commitmentDigest: binding.commitmentDigest,
targetDataIdentityDigest,
startupReceiptDigest,
shutdownReceiptDigest,
processIdentityDigest,
manualReason,
});
const record = localServiceManagerCutoverRecord(
intent,
outcome,
finalState,
evidence,
);
const status = publishLocalServiceManagerCutoverRecord(
intent,
record,
identity.uid,
);
const head = advanceLocalCutoverInstanceHead(
{
options: { deploymentRoot: intent.deployment.root },
request: {
cutoverId: intent.lineage.cutoverId,
profile: intent.profile,
instanceId: intent.instanceId,
expectedActivationDigest: intent.lineage.expectedActivationDigest,
requestedAtMs: outcome.completedAtMs,
},
},
identity.uid,
finalState,
intent.lineage.generation,
record.recordDigest,
);
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status,
state: finalState,
cutoverId: intent.lineage.cutoverId,
generation: intent.lineage.generation,
recordDigest: record.recordDigest,
instanceHeadDigest: head.headDigest,
});
}
export function consumeLocalServiceManagerCutoverOutcomeCommandFile(
filePath: string,
): Promise<Readonly<LocalServiceManagerCutoverConsumeResult>> {
return consumeLocalServiceManagerCutoverOutcome(
readPrivateLocalJsonFile(filePath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
}),
);
}
@@ -0,0 +1,305 @@
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 '../cutover/targetEvidence';
import type {
LocalServiceManagerAction,
LocalServiceManagerIntent,
} from './serviceBridgeContract';
import type { LocalServiceManagerOutcome } from './serviceOutcomeContract';
const SCHEMA = 'qinglong3-local-service-manager-cutover-record';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
export type LocalServiceManagerCutoverState =
| 'target_active'
| 'target_stopped'
| 'manual_required';
export interface LocalServiceManagerCutoverEvidence {
readonly managerOutcomeDigest: string;
readonly managerObservationDigest: string;
readonly applicationConfigDigest: string;
readonly activationDigest: string;
readonly commitmentDigest: string;
readonly targetDataIdentityDigest: string;
readonly startupReceiptDigest: string | null;
readonly shutdownReceiptDigest: string | null;
readonly processIdentityDigest: string | null;
readonly manualReason: string | null;
}
export interface LocalServiceManagerCutoverRecord {
readonly schema: typeof SCHEMA;
readonly schemaVersion: 1;
readonly actionId: string;
readonly action: LocalServiceManagerAction;
readonly state: LocalServiceManagerCutoverState;
readonly cutoverId: string;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly activationDigest: string;
readonly generation: number;
readonly previousRecordDigest: string;
readonly intentDigest: string;
readonly requestedAtMs: number;
readonly completedAtMs: number;
readonly evidence: Readonly<LocalServiceManagerCutoverEvidence>;
readonly recordDigest: 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 generationName(generation: number): string {
return String(generation).padStart(2, '0');
}
export function localServiceManagerActiveRecordPath(
deploymentRoot: string,
cutoverId: string,
generation: number,
): string {
return path.join(
deploymentRoot,
'service',
'cutovers',
cutoverId,
`service-manager-g${generationName(generation)}-active.json`,
);
}
export function localServiceManagerCutoverRecordPath(
intent: Readonly<LocalServiceManagerIntent>,
state: LocalServiceManagerCutoverState,
): string {
if (intent.lineage.mode !== 'adopted') {
configurationError('fresh service intent has no cutover journal');
}
const suffix =
state === 'target_active'
? 'active'
: state === 'target_stopped'
? 'stopped'
: `manual-${intent.actionId}`;
return path.join(
intent.deployment.root,
'service',
'cutovers',
intent.lineage.cutoverId,
`service-manager-g${generationName(
intent.lineage.generation,
)}-${suffix}.json`,
);
}
export function localServiceManagerCutoverRecord(
intent: Readonly<LocalServiceManagerIntent>,
outcome: Readonly<LocalServiceManagerOutcome>,
state: LocalServiceManagerCutoverState,
evidence: Readonly<LocalServiceManagerCutoverEvidence>,
): Readonly<LocalServiceManagerCutoverRecord> {
if (intent.lineage.mode !== 'adopted') {
configurationError('fresh service intent has no cutover lineage');
}
const payload = Object.freeze({
schema: SCHEMA,
schemaVersion: 1 as const,
actionId: intent.actionId,
action: intent.action,
state,
cutoverId: intent.lineage.cutoverId,
profile: intent.profile,
instanceId: intent.instanceId,
activationDigest: intent.lineage.expectedActivationDigest,
generation: intent.lineage.generation,
previousRecordDigest: intent.lineage.previousRecordDigest,
intentDigest: intent.intentDigest,
requestedAtMs: intent.requestedAtMs,
completedAtMs: outcome.completedAtMs,
evidence,
});
return Object.freeze({ ...payload, recordDigest: cutoverDigest(payload) });
}
export function normalizeLocalServiceManagerCutoverRecord(
value: unknown,
): Readonly<LocalServiceManagerCutoverRecord> {
const record = object(value, 'service manager cutover record');
exact(
record,
[
'action',
'actionId',
'activationDigest',
'completedAtMs',
'cutoverId',
'evidence',
'generation',
'instanceId',
'intentDigest',
'previousRecordDigest',
'profile',
'recordDigest',
'requestedAtMs',
'schema',
'schemaVersion',
'state',
],
'service manager cutover record',
);
const evidence = object(record.evidence, 'service manager cutover evidence');
exact(
evidence,
[
'activationDigest',
'applicationConfigDigest',
'commitmentDigest',
'managerObservationDigest',
'managerOutcomeDigest',
'manualReason',
'processIdentityDigest',
'shutdownReceiptDigest',
'startupReceiptDigest',
'targetDataIdentityDigest',
],
'service manager cutover evidence',
);
const nullableDigest = (candidate: unknown): boolean =>
candidate === null ||
(typeof candidate === 'string' && DIGEST_PATTERN.test(candidate));
const { recordDigest, ...payload } = record;
if (
record.schema !== SCHEMA ||
record.schemaVersion !== 1 ||
typeof record.actionId !== 'string' ||
(record.action !== 'install-enable-start' &&
record.action !== 'start' &&
record.action !== 'restart' &&
record.action !== 'stop') ||
(record.state !== 'target_active' &&
record.state !== 'target_stopped' &&
record.state !== 'manual_required') ||
typeof record.cutoverId !== 'string' ||
(record.profile !== 'edge' && record.profile !== 'standalone') ||
typeof record.instanceId !== 'string' ||
typeof record.activationDigest !== 'string' ||
!DIGEST_PATTERN.test(record.activationDigest) ||
!Number.isSafeInteger(record.generation) ||
(record.generation as number) < 1 ||
typeof record.previousRecordDigest !== 'string' ||
!DIGEST_PATTERN.test(record.previousRecordDigest) ||
typeof record.intentDigest !== 'string' ||
!DIGEST_PATTERN.test(record.intentDigest) ||
!Number.isSafeInteger(record.requestedAtMs) ||
(record.requestedAtMs as number) < 0 ||
!Number.isSafeInteger(record.completedAtMs) ||
(record.completedAtMs as number) < (record.requestedAtMs as number) ||
typeof evidence.managerOutcomeDigest !== 'string' ||
!DIGEST_PATTERN.test(evidence.managerOutcomeDigest) ||
typeof evidence.managerObservationDigest !== 'string' ||
!DIGEST_PATTERN.test(evidence.managerObservationDigest) ||
typeof evidence.applicationConfigDigest !== 'string' ||
!DIGEST_PATTERN.test(evidence.applicationConfigDigest) ||
typeof evidence.activationDigest !== 'string' ||
!DIGEST_PATTERN.test(evidence.activationDigest) ||
typeof evidence.commitmentDigest !== 'string' ||
!DIGEST_PATTERN.test(evidence.commitmentDigest) ||
typeof evidence.targetDataIdentityDigest !== 'string' ||
!DIGEST_PATTERN.test(evidence.targetDataIdentityDigest) ||
!nullableDigest(evidence.startupReceiptDigest) ||
!nullableDigest(evidence.shutdownReceiptDigest) ||
!nullableDigest(evidence.processIdentityDigest) ||
(evidence.manualReason !== null &&
(typeof evidence.manualReason !== 'string' ||
Buffer.byteLength(evidence.manualReason, 'utf8') > 128)) ||
typeof recordDigest !== 'string' ||
!DIGEST_PATTERN.test(recordDigest) ||
cutoverDigest(payload) !== recordDigest
) {
configurationError('service manager cutover record drifted');
}
return record as unknown as Readonly<LocalServiceManagerCutoverRecord>;
}
export function publishLocalServiceManagerCutoverRecord(
intent: Readonly<LocalServiceManagerIntent>,
record: Readonly<LocalServiceManagerCutoverRecord>,
uid: number,
): 'prepared' | 'existing' {
const filePath = localServiceManagerCutoverRecordPath(intent, record.state);
const contents = `${JSON.stringify(record, null, 2)}\n`;
preflightPublishedFile(
filePath,
contents,
0o600,
uid,
'service manager cutover record',
);
return publishExactFile(
filePath,
contents,
0o600,
uid,
'service manager cutover record',
);
}
export function readLocalServiceManagerActiveRecord(
deploymentRoot: string,
cutoverId: string,
generation: number,
): Readonly<LocalServiceManagerCutoverRecord> {
const filePath = localServiceManagerActiveRecordPath(
deploymentRoot,
cutoverId,
generation,
);
if (!fs.existsSync(filePath)) {
configurationError('previous service manager active record is unavailable');
}
const record = normalizeLocalServiceManagerCutoverRecord(
readPrivateLocalCommandFile(filePath),
);
if (
record.state !== 'target_active' ||
record.cutoverId !== cutoverId ||
record.generation !== generation
) {
configurationError('previous service manager active record drifted');
}
return record;
}
@@ -0,0 +1,744 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
readPrivateLocalJsonFile,
} from '@qinglong/local-command-file';
import {
currentIdentity,
LocalDeploymentConfigurationError,
} from '../foundation/contract';
import {
assertLocalCutoverTargetHead,
localCutoverInstanceHeadPath,
} from '../cutover/instanceLineage';
import {
ensurePrivateDirectory,
preflightPublishedFile,
publishExactFile,
validatePrivateDirectory,
} from '../foundation/files';
import {
localServiceManagerIntentDigest,
localServiceManagerIntentPath,
localServiceManagerOutcomePath,
normalizeLocalServiceManagerIntent,
type LocalServiceManagerAction,
type LocalServiceManagerIntent,
type LocalServiceManagerIntentLineage,
type LocalServiceManagerKind,
} from './serviceBridgeContract';
import {
normalizeLocalServiceManagerOutcome,
type LocalServiceManagerOutcome,
} from './serviceOutcomeContract';
const MAX_PATH_BYTES = 4_096;
const MAX_DESCRIPTOR_BYTES = 64 * 1024;
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
export interface LocalServiceManagerIntentPrepareCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.intent.prepare';
readonly options: Readonly<{
deploymentRoot: string;
allowRootService: boolean;
}>;
readonly request: Readonly<{
actionId: string;
action: LocalServiceManagerAction;
serviceKind: LocalServiceManagerKind;
lineage: LocalServiceManagerIntentLineage;
requestedAtMs: number;
}>;
}
export interface LocalServiceManagerIntentPrepareResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.intent.prepare';
readonly status: 'prepared' | 'existing';
readonly actionId: string;
readonly intentPath: string;
readonly intentDigest: string;
readonly outcomePath: string;
}
export interface LocalServiceManagerOutcomeConsumeCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.outcome.consume';
readonly options: Readonly<{
deploymentRoot: string;
allowRootService: boolean;
}>;
readonly request: Readonly<{
actionId: string;
expectedIntentDigest: string;
}>;
}
export interface LocalServiceManagerOutcomeConsumeResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.outcome.consume';
readonly status: 'verified';
readonly actionId: string;
readonly state: LocalServiceManagerOutcome['state'];
readonly outcomeDigest: string;
readonly observationDigest: 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 safeAbsolutePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value ||
value.includes('\0') ||
value.includes('//') ||
!SAFE_PATH_PATTERN.test(value) ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
configurationError(`${label} must be a supervisor-safe absolute path`);
}
return value;
}
function validateIdentity(allowRootService: unknown): Readonly<{
uid: number;
gid: number;
}> {
const identity = currentIdentity();
if (
typeof allowRootService !== 'boolean' ||
(identity.uid === 0) !== allowRootService
) {
configurationError('allowRootService does not match the current identity');
}
return identity;
}
function privateFileBytes(
filePath: string,
expectedMode: number,
uid: number,
gid: number,
maximumBytes: number,
label: string,
): Buffer {
let descriptor: number | undefined;
try {
const before = fs.lstatSync(filePath, { bigint: true });
if (
!before.isFile() ||
before.isSymbolicLink() ||
Number(before.uid) !== uid ||
Number(before.gid) !== gid ||
(Number(before.mode) & 0o777) !== expectedMode ||
before.nlink !== 1n ||
before.size < 1n ||
before.size > BigInt(maximumBytes) ||
fs.realpathSync(filePath) !== filePath
) {
configurationError(`${label} identity is invalid`);
}
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor, { bigint: true });
if (
!opened.isFile() ||
opened.dev !== before.dev ||
opened.ino !== before.ino ||
opened.size !== before.size ||
Number(opened.uid) !== uid ||
Number(opened.gid) !== gid ||
(Number(opened.mode) & 0o777) !== expectedMode ||
opened.nlink !== 1n
) {
configurationError(`${label} identity changed while opening`);
}
const bytes = Buffer.allocUnsafe(Number(opened.size));
let offset = 0;
while (offset < bytes.byteLength) {
const read = fs.readSync(
descriptor,
bytes,
offset,
bytes.byteLength - offset,
null,
);
if (read === 0) break;
offset += read;
}
const after = fs.fstatSync(descriptor, { bigint: true });
if (
offset !== bytes.byteLength ||
after.dev !== opened.dev ||
after.ino !== opened.ino ||
after.size !== opened.size ||
Number(after.uid) !== uid ||
Number(after.gid) !== gid ||
(Number(after.mode) & 0o777) !== expectedMode ||
after.nlink !== 1n
) {
bytes.fill(0);
configurationError(`${label} identity changed while reading`);
}
return bytes;
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
return configurationError(`${label} cannot be read`, error);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function sha256(bytes: Buffer): string {
return crypto.createHash('sha256').update(bytes).digest('hex');
}
function parseApplicationIdentity(bytes: Buffer): Readonly<{
profile: 'edge' | 'standalone';
instanceId: string;
deployment:
| Readonly<{ mode: 'fresh' }>
| Readonly<{
mode: 'adopted';
cutoverId: string;
expectedActivationDigest: string;
expectedCommitmentDigest: string;
commitmentPath: string;
activationPath: string;
legacySourcePath: string;
targetDatabasePath: string;
recoveryPath: string;
manifestPath: string;
}>;
}> {
let value: unknown;
try {
value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
) as unknown;
} catch (error) {
configurationError('application configuration is invalid', error);
}
const application = object(value, 'application configuration');
if (
(application.schema !== 'qinglong/local-application-process@v2' &&
application.schema !== 'qinglong/local-application-process@v3') ||
(application.profile !== 'edge' && application.profile !== 'standalone') ||
typeof application.instanceId !== 'string'
) {
configurationError('application identity is invalid');
}
const storage = object(application.storage, 'application storage');
if (application.schema === 'qinglong/local-application-process@v2') {
if (storage.mode !== 'fresh') {
configurationError('v2 service application must use fresh storage');
}
return Object.freeze({
profile: application.profile,
instanceId: application.instanceId,
deployment: Object.freeze({ mode: 'fresh' as const }),
});
}
const cutover = object(application.cutover, 'application cutover');
if (
storage.mode !== 'adopted' ||
typeof cutover.cutoverId !== 'string' ||
typeof storage.expectedActivationDigest !== 'string' ||
!DIGEST_PATTERN.test(storage.expectedActivationDigest) ||
typeof cutover.expectedCommitmentDigest !== 'string' ||
!DIGEST_PATTERN.test(cutover.expectedCommitmentDigest)
) {
configurationError('adopted application binding is invalid');
}
return Object.freeze({
profile: application.profile,
instanceId: application.instanceId,
deployment: Object.freeze({
mode: 'adopted' as const,
cutoverId: cutover.cutoverId,
expectedActivationDigest: storage.expectedActivationDigest,
expectedCommitmentDigest: cutover.expectedCommitmentDigest,
commitmentPath: safeAbsolutePath(
cutover.commitmentPath,
'commitmentPath',
),
activationPath: safeAbsolutePath(
storage.activationPath,
'activationPath',
),
legacySourcePath: safeAbsolutePath(
storage.sourcePath,
'legacySourcePath',
),
targetDatabasePath: safeAbsolutePath(
storage.targetPath,
'targetDatabasePath',
),
recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'),
manifestPath: safeAbsolutePath(storage.manifestPath, 'manifestPath'),
}),
});
}
function assertApplicationLineageBinding(
application: ReturnType<typeof parseApplicationIdentity>,
intent: Readonly<LocalServiceManagerIntent>,
): void {
if (intent.lineage.mode === 'fresh') {
if (application.deployment.mode !== 'fresh') {
configurationError('fresh service intent cannot start adopted storage');
}
return;
}
if (
application.deployment.mode !== 'adopted' ||
application.deployment.cutoverId !== intent.lineage.cutoverId ||
application.deployment.expectedActivationDigest !==
intent.lineage.expectedActivationDigest ||
((intent.action === 'install-enable-start' || intent.action === 'start') &&
intent.lineage.generation === 1 &&
application.deployment.expectedCommitmentDigest !==
intent.lineage.previousRecordDigest)
) {
configurationError(
'service intent does not match adopted application binding',
);
}
}
function intentDirectory(deploymentRoot: string): string {
return path.join(deploymentRoot, 'service', 'service-manager-intents');
}
function outcomeDirectory(deploymentRoot: string): string {
return path.join(deploymentRoot, 'service', 'service-manager-outcomes');
}
function assertIntentLineageHead(
intent: Readonly<LocalServiceManagerIntent>,
uid: number,
): void {
const headPath = localCutoverInstanceHeadPath(
intent.deployment.root,
intent.instanceId,
);
if (intent.lineage.mode === 'fresh') {
if (fs.existsSync(headPath)) {
configurationError(
'fresh service intent cannot bypass an instance lineage head',
);
}
return;
}
const head = assertLocalCutoverTargetHead(
Object.freeze({
options: Object.freeze({ deploymentRoot: intent.deployment.root }),
request: Object.freeze({
cutoverId: intent.lineage.cutoverId,
profile: intent.profile,
instanceId: intent.instanceId,
expectedActivationDigest: intent.lineage.expectedActivationDigest,
requestedAtMs: intent.requestedAtMs,
}),
}),
uid,
);
const expected =
intent.action === 'restart'
? Object.freeze({
state: 'target_active' as const,
generation: intent.lineage.generation - 1,
})
: intent.action === 'stop'
? Object.freeze({
state: 'target_active' as const,
generation: intent.lineage.generation,
})
: Object.freeze({ state: 'legacy_stopped' as const, generation: 0 });
if (
head.state !== expected.state ||
head.generation !== expected.generation ||
head.sourceRecordDigest !== intent.lineage.previousRecordDigest
) {
configurationError(
'service intent lost the instance lineage compare-and-swap',
);
}
}
function normalizePrepareCommand(
value: unknown,
): Readonly<LocalServiceManagerIntentPrepareCommand> {
const command = object(value, 'service manager intent command');
exact(
command,
['operation', 'options', 'request', 'schemaVersion'],
'command',
);
const options = object(command.options, 'options');
exact(options, ['allowRootService', 'deploymentRoot'], 'options');
const request = object(command.request, 'request');
exact(
request,
['action', 'actionId', 'lineage', 'requestedAtMs', 'serviceKind'],
'request',
);
if (
command.schemaVersion !== 1 ||
command.operation !== 'local.deployment.service-manager.intent.prepare' ||
typeof request.actionId !== 'string' ||
!UUID_V4_PATTERN.test(request.actionId) ||
(request.action !== 'install-enable-start' &&
request.action !== 'start' &&
request.action !== 'restart' &&
request.action !== 'stop') ||
(request.serviceKind !== 'systemd' && request.serviceKind !== 'openrc') ||
!Number.isSafeInteger(request.requestedAtMs) ||
(request.requestedAtMs as number) < 0
) {
configurationError('service manager intent command is invalid');
}
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.service-manager.intent.prepare' as const,
options: Object.freeze({
deploymentRoot: safeAbsolutePath(
options.deploymentRoot,
'deploymentRoot',
),
allowRootService: options.allowRootService as boolean,
}),
request: Object.freeze({
actionId: request.actionId,
action: request.action,
serviceKind: request.serviceKind,
lineage: request.lineage as LocalServiceManagerIntentLineage,
requestedAtMs: request.requestedAtMs as number,
}),
});
}
export function prepareLocalServiceManagerIntent(
input: unknown,
): Readonly<LocalServiceManagerIntentPrepareResult> {
const command = normalizePrepareCommand(input);
const identity = validateIdentity(command.options.allowRootService);
const root = command.options.deploymentRoot;
const serviceRoot = path.join(root, 'service');
validatePrivateDirectory(root, identity.uid, 'deploymentRoot');
validatePrivateDirectory(serviceRoot, identity.uid, 'serviceDescriptorRoot');
ensurePrivateDirectory(
intentDirectory(root),
identity.uid,
'serviceManagerIntentRoot',
);
ensurePrivateDirectory(
outcomeDirectory(root),
identity.uid,
'serviceManagerOutcomeRoot',
);
const applicationConfigPath = path.join(root, 'local-application.json');
const applicationBytes = privateFileBytes(
applicationConfigPath,
0o600,
identity.uid,
identity.gid,
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
'application configuration',
);
const descriptorPath = path.join(
serviceRoot,
command.request.serviceKind === 'systemd'
? 'qinglong3.service'
: 'qinglong3.openrc',
);
const sourceMode = command.request.serviceKind === 'systemd' ? 0o600 : 0o700;
const descriptorBytes = privateFileBytes(
descriptorPath,
sourceMode,
identity.uid,
identity.gid,
MAX_DESCRIPTOR_BYTES,
'service descriptor',
);
try {
const application = parseApplicationIdentity(applicationBytes);
const payload: Omit<LocalServiceManagerIntent, 'intentDigest'> =
Object.freeze({
schemaVersion: 1 as const,
kind: 'qinglong3-local-service-manager-intent' as const,
actionId: command.request.actionId,
action: command.request.action,
profile: application.profile,
instanceId: application.instanceId,
service: Object.freeze({
kind: command.request.serviceKind,
name: 'qinglong3' as const,
uid: identity.uid,
gid: identity.gid,
allowRootService: command.options.allowRootService,
}),
deployment: Object.freeze({
root,
applicationConfigPath,
applicationConfigSha256: sha256(applicationBytes),
}),
descriptor: Object.freeze({
sourcePath: descriptorPath,
destinationPath:
command.request.serviceKind === 'systemd'
? '/etc/systemd/system/qinglong3.service'
: '/etc/init.d/qinglong3',
sha256: sha256(descriptorBytes),
sourceMode,
destinationMode:
command.request.serviceKind === 'systemd' ? 0o644 : 0o755,
}),
lineage: command.request.lineage,
outcomePath: localServiceManagerOutcomePath(
root,
command.request.actionId,
),
requestedAtMs: command.request.requestedAtMs,
});
const intent = normalizeLocalServiceManagerIntent({
...payload,
intentDigest: localServiceManagerIntentDigest(payload),
});
assertApplicationLineageBinding(application, intent);
assertIntentLineageHead(intent, identity.uid);
const intentPath = localServiceManagerIntentPath(root, intent.actionId);
const contents = `${JSON.stringify(intent, null, 2)}\n`;
preflightPublishedFile(
intentPath,
contents,
0o600,
identity.uid,
'service manager intent',
);
const status = publishExactFile(
intentPath,
contents,
0o600,
identity.uid,
'service manager intent',
);
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.service-manager.intent.prepare' as const,
status,
actionId: intent.actionId,
intentPath,
intentDigest: intent.intentDigest,
outcomePath: intent.outcomePath,
});
} finally {
applicationBytes.fill(0);
descriptorBytes.fill(0);
}
}
function normalizeConsumeCommand(
value: unknown,
): Readonly<LocalServiceManagerOutcomeConsumeCommand> {
const command = object(value, 'service manager outcome command');
exact(
command,
['operation', 'options', 'request', 'schemaVersion'],
'command',
);
const options = object(command.options, 'options');
exact(options, ['allowRootService', 'deploymentRoot'], 'options');
const request = object(command.request, 'request');
exact(request, ['actionId', 'expectedIntentDigest'], 'request');
if (
command.schemaVersion !== 1 ||
command.operation !== 'local.deployment.service-manager.outcome.consume' ||
typeof request.actionId !== 'string' ||
!UUID_V4_PATTERN.test(request.actionId) ||
typeof request.expectedIntentDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedIntentDigest)
) {
configurationError('service manager outcome command is invalid');
}
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.service-manager.outcome.consume' as const,
options: Object.freeze({
deploymentRoot: safeAbsolutePath(
options.deploymentRoot,
'deploymentRoot',
),
allowRootService: options.allowRootService as boolean,
}),
request: Object.freeze({
actionId: request.actionId,
expectedIntentDigest: request.expectedIntentDigest,
}),
});
}
function privateJsonGid(filePath: string, gid: number, label: string): unknown {
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
configurationError(`${label} is unavailable`, error);
}
if (stat.gid !== gid) configurationError(`${label} group identity drifted`);
return readPrivateLocalJsonFile(filePath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
});
}
export function consumeLocalServiceManagerOutcome(
input: unknown,
): Readonly<LocalServiceManagerOutcomeConsumeResult> {
const command = normalizeConsumeCommand(input);
const identity = validateIdentity(command.options.allowRootService);
validatePrivateDirectory(
command.options.deploymentRoot,
identity.uid,
'deploymentRoot',
);
const intent = normalizeLocalServiceManagerIntent(
privateJsonGid(
localServiceManagerIntentPath(
command.options.deploymentRoot,
command.request.actionId,
),
identity.gid,
'service manager intent',
),
);
const outcome = normalizeLocalServiceManagerOutcome(
privateJsonGid(
localServiceManagerOutcomePath(
command.options.deploymentRoot,
command.request.actionId,
),
identity.gid,
'service manager outcome',
),
);
if (
intent.actionId !== command.request.actionId ||
intent.intentDigest !== command.request.expectedIntentDigest ||
intent.service.uid !== identity.uid ||
intent.service.gid !== identity.gid ||
intent.service.allowRootService !== command.options.allowRootService ||
intent.deployment.root !== command.options.deploymentRoot ||
intent.outcomePath !==
localServiceManagerOutcomePath(
command.options.deploymentRoot,
command.request.actionId,
) ||
outcome.actionId !== intent.actionId ||
outcome.action !== intent.action ||
outcome.intentDigest !== intent.intentDigest ||
outcome.descriptorDigest !== intent.descriptor.sha256 ||
outcome.observation.managerKind !== intent.service.kind ||
outcome.observation.fragmentPath !== intent.descriptor.destinationPath
) {
configurationError('service manager outcome binding drifted');
}
assertIntentLineageHead(intent, identity.uid);
const applicationBytes = privateFileBytes(
intent.deployment.applicationConfigPath,
0o600,
identity.uid,
identity.gid,
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
'application configuration',
);
const descriptorBytes = privateFileBytes(
intent.descriptor.sourcePath,
intent.descriptor.sourceMode,
identity.uid,
identity.gid,
MAX_DESCRIPTOR_BYTES,
'service descriptor',
);
try {
if (
sha256(applicationBytes) !== intent.deployment.applicationConfigSha256 ||
sha256(descriptorBytes) !== intent.descriptor.sha256
) {
configurationError('service manager source material drifted');
}
} finally {
applicationBytes.fill(0);
descriptorBytes.fill(0);
}
return Object.freeze({
schemaVersion: 1 as const,
operation: 'local.deployment.service-manager.outcome.consume' as const,
status: 'verified' as const,
actionId: outcome.actionId,
state: outcome.state,
outcomeDigest: outcome.outcomeDigest,
observationDigest: outcome.observation.observationDigest,
});
}
export function prepareLocalServiceManagerIntentCommandFile(
filePath: string,
): Readonly<LocalServiceManagerIntentPrepareResult> {
return prepareLocalServiceManagerIntent(
readPrivateLocalJsonFile(filePath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
}),
);
}
export function consumeLocalServiceManagerOutcomeCommandFile(
filePath: string,
): Readonly<LocalServiceManagerOutcomeConsumeResult> {
return consumeLocalServiceManagerOutcome(
readPrivateLocalJsonFile(filePath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
}),
);
}
@@ -0,0 +1,249 @@
import crypto from 'node:crypto';
import { LocalDeploymentConfigurationError } from '../foundation/contract';
import type {
LocalServiceManagerAction,
LocalServiceManagerKind,
} from './serviceBridgeContract';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const MAX_PID = 0x7fffffff;
export type LocalServiceManagerOutcomeState =
| 'active'
| 'stopped'
| 'manual_required';
export type LocalServiceManagerMutationDisposition =
| 'executed'
| 'response-loss-inspected'
| 'replay-inspected';
export type LocalServiceManagerManualReason =
| 'descriptor_install_unproved'
| 'manager_command_failed'
| 'manager_state_unproved';
export interface LocalServiceManagerObservation {
readonly managerKind: LocalServiceManagerKind;
readonly serviceName: 'qinglong3';
readonly fragmentPath: string;
readonly loadState: 'loaded' | 'not-found' | 'unknown';
readonly activeState: 'active' | 'inactive' | 'failed' | 'unknown';
readonly subState: string;
readonly enabledState: 'enabled' | 'disabled' | 'static' | 'unknown';
readonly mainPid: number;
readonly observedAtMs: number;
readonly observationDigest: string;
}
export interface LocalServiceManagerOutcome {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-local-service-manager-outcome';
readonly actionId: string;
readonly action: LocalServiceManagerAction;
readonly intentDigest: string;
readonly descriptorDigest: string;
readonly state: LocalServiceManagerOutcomeState;
readonly mutationDisposition: LocalServiceManagerMutationDisposition;
readonly manualReason: LocalServiceManagerManualReason | null;
readonly observation: Readonly<LocalServiceManagerObservation>;
readonly completedAtMs: number;
readonly outcomeDigest: 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 digest(value: unknown): string {
return crypto
.createHash('sha256')
.update(JSON.stringify(value))
.digest('hex');
}
export function localServiceManagerObservationDigest(
value: Omit<LocalServiceManagerObservation, 'observationDigest'>,
): string {
return digest(value);
}
export function localServiceManagerOutcomeDigest(
value: Omit<LocalServiceManagerOutcome, 'outcomeDigest'>,
): string {
return digest(value);
}
export function normalizeLocalServiceManagerObservation(
value: unknown,
): Readonly<LocalServiceManagerObservation> {
const observation = object(value, 'service manager observation');
exact(
observation,
[
'activeState',
'enabledState',
'fragmentPath',
'loadState',
'mainPid',
'managerKind',
'observationDigest',
'observedAtMs',
'serviceName',
'subState',
],
'service manager observation',
);
if (
(observation.managerKind !== 'systemd' &&
observation.managerKind !== 'openrc') ||
observation.serviceName !== 'qinglong3' ||
typeof observation.fragmentPath !== 'string' ||
(observation.loadState !== 'loaded' &&
observation.loadState !== 'not-found' &&
observation.loadState !== 'unknown') ||
(observation.activeState !== 'active' &&
observation.activeState !== 'inactive' &&
observation.activeState !== 'failed' &&
observation.activeState !== 'unknown') ||
typeof observation.subState !== 'string' ||
Buffer.byteLength(observation.subState, 'utf8') > 128 ||
(observation.enabledState !== 'enabled' &&
observation.enabledState !== 'disabled' &&
observation.enabledState !== 'static' &&
observation.enabledState !== 'unknown') ||
!Number.isSafeInteger(observation.mainPid) ||
(observation.mainPid as number) < 0 ||
(observation.mainPid as number) > MAX_PID ||
!Number.isSafeInteger(observation.observedAtMs) ||
(observation.observedAtMs as number) < 0 ||
typeof observation.observationDigest !== 'string' ||
!DIGEST_PATTERN.test(observation.observationDigest)
) {
configurationError('service manager observation is invalid');
}
const payload = Object.freeze({
managerKind: observation.managerKind,
serviceName: 'qinglong3' as const,
fragmentPath: observation.fragmentPath,
loadState: observation.loadState,
activeState: observation.activeState,
subState: observation.subState,
enabledState: observation.enabledState,
mainPid: observation.mainPid as number,
observedAtMs: observation.observedAtMs as number,
});
if (
localServiceManagerObservationDigest(payload) !==
observation.observationDigest
) {
configurationError('service manager observation digest is invalid');
}
return Object.freeze({
...payload,
observationDigest: observation.observationDigest,
});
}
export function normalizeLocalServiceManagerOutcome(
value: unknown,
): Readonly<LocalServiceManagerOutcome> {
const outcome = object(value, 'service manager outcome');
exact(
outcome,
[
'action',
'actionId',
'completedAtMs',
'descriptorDigest',
'intentDigest',
'kind',
'manualReason',
'mutationDisposition',
'observation',
'outcomeDigest',
'schemaVersion',
'state',
],
'service manager outcome',
);
const observation = normalizeLocalServiceManagerObservation(
outcome.observation,
);
if (
outcome.schemaVersion !== 1 ||
outcome.kind !== 'qinglong3-local-service-manager-outcome' ||
typeof outcome.actionId !== 'string' ||
(outcome.action !== 'install-enable-start' &&
outcome.action !== 'start' &&
outcome.action !== 'restart' &&
outcome.action !== 'stop') ||
typeof outcome.intentDigest !== 'string' ||
!DIGEST_PATTERN.test(outcome.intentDigest) ||
typeof outcome.descriptorDigest !== 'string' ||
!DIGEST_PATTERN.test(outcome.descriptorDigest) ||
(outcome.state !== 'active' &&
outcome.state !== 'stopped' &&
outcome.state !== 'manual_required') ||
(outcome.mutationDisposition !== 'executed' &&
outcome.mutationDisposition !== 'response-loss-inspected' &&
outcome.mutationDisposition !== 'replay-inspected') ||
(outcome.manualReason !== null &&
outcome.manualReason !== 'descriptor_install_unproved' &&
outcome.manualReason !== 'manager_command_failed' &&
outcome.manualReason !== 'manager_state_unproved') ||
(outcome.state === 'manual_required') !== (outcome.manualReason !== null) ||
!Number.isSafeInteger(outcome.completedAtMs) ||
(outcome.completedAtMs as number) < observation.observedAtMs ||
typeof outcome.outcomeDigest !== 'string' ||
!DIGEST_PATTERN.test(outcome.outcomeDigest)
) {
configurationError('service manager outcome is invalid');
}
const payload = Object.freeze({
schemaVersion: 1 as const,
kind: 'qinglong3-local-service-manager-outcome' as const,
actionId: outcome.actionId,
action: outcome.action,
intentDigest: outcome.intentDigest,
descriptorDigest: outcome.descriptorDigest,
state: outcome.state,
mutationDisposition: outcome.mutationDisposition,
manualReason: outcome.manualReason,
observation,
completedAtMs: outcome.completedAtMs as number,
});
if (localServiceManagerOutcomeDigest(payload) !== outcome.outcomeDigest) {
configurationError('service manager outcome digest is invalid');
}
return Object.freeze({ ...payload, outcomeDigest: outcome.outcomeDigest });
}