mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
1008 lines
35 KiB
TypeScript
1008 lines
35 KiB
TypeScript
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 { normalizeLocalDataDirectoryApplicationCommit } from '@qinglong/local-sqlite/data-directory-application-commit';
|
|
|
|
import { currentIdentity } from '../foundation/contract';
|
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
|
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>;
|
|
readonly afterRecordPublished?: () => 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;
|
|
readonly legacyDataApplication?: Readonly<{
|
|
commitPath: string;
|
|
commitDigest: string;
|
|
receiptDigest: string;
|
|
}>;
|
|
}
|
|
|
|
interface VerifiedAdoptedEvidence {
|
|
readonly targetDataIdentityDigest: string;
|
|
readonly legacyDataApplicationCommitDigest: string | null;
|
|
readonly legacyDataApplicationReceiptDigest: string | null;
|
|
}
|
|
|
|
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 legacyDataApplication =
|
|
config.schema === 'qinglong/local-application-process@v4'
|
|
? object(config.legacyDataApplication, 'legacy data application binding')
|
|
: undefined;
|
|
if (legacyDataApplication !== undefined) {
|
|
exact(
|
|
legacyDataApplication,
|
|
['commitPath', 'expectedCommitDigest', 'expectedReceiptDigest'],
|
|
'legacy data application binding',
|
|
);
|
|
}
|
|
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.schema !== 'qinglong/local-application-process@v4') ||
|
|
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) ||
|
|
(legacyDataApplication !== undefined &&
|
|
(typeof legacyDataApplication.expectedCommitDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(legacyDataApplication.expectedCommitDigest) ||
|
|
typeof legacyDataApplication.expectedReceiptDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(legacyDataApplication.expectedReceiptDigest)))
|
|
) {
|
|
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,
|
|
...(legacyDataApplication === undefined
|
|
? {}
|
|
: {
|
|
legacyDataApplication: Object.freeze({
|
|
commitPath: safeAbsolutePath(
|
|
legacyDataApplication.commitPath,
|
|
'legacyDataApplication.commitPath',
|
|
),
|
|
commitDigest: legacyDataApplication.expectedCommitDigest as string,
|
|
receiptDigest:
|
|
legacyDataApplication.expectedReceiptDigest as string,
|
|
}),
|
|
}),
|
|
});
|
|
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,
|
|
): Readonly<VerifiedAdoptedEvidence> {
|
|
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.sourceSha256 !== 'string' ||
|
|
!DIGEST_PATTERN.test(activation.sourceSha256) ||
|
|
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');
|
|
}
|
|
let legacyDataApplicationCommitDigest: string | null = null;
|
|
let legacyDataApplicationReceiptDigest: string | null = null;
|
|
if (binding.legacyDataApplication !== undefined) {
|
|
try {
|
|
const dataCommit = normalizeLocalDataDirectoryApplicationCommit(
|
|
readPrivateLocalCommandFile(binding.legacyDataApplication.commitPath),
|
|
);
|
|
if (
|
|
dataCommit.profile !== intent.profile ||
|
|
dataCommit.commitDigest !==
|
|
binding.legacyDataApplication.commitDigest ||
|
|
dataCommit.receiptDigest !== binding.legacyDataApplication.receiptDigest
|
|
) {
|
|
configurationError('legacy data application commitment drifted');
|
|
}
|
|
legacyDataApplicationCommitDigest = dataCommit.commitDigest;
|
|
legacyDataApplicationReceiptDigest = dataCommit.receiptDigest;
|
|
} catch (error) {
|
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
|
configurationError(
|
|
'legacy data application commitment is invalid',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
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.sourceSha256 ||
|
|
recoverySha256 !== activation.recoverySha256
|
|
) {
|
|
configurationError('adopted data evidence drifted');
|
|
}
|
|
return Object.freeze({
|
|
targetDataIdentityDigest: cutoverDigest({
|
|
target: target.digest,
|
|
source: source.digest,
|
|
recovery: recovery.digest,
|
|
recoverySha256,
|
|
manifest: manifestIdentity.digest,
|
|
manifestDigest,
|
|
sourceSha256,
|
|
}),
|
|
legacyDataApplicationCommitDigest,
|
|
legacyDataApplicationReceiptDigest,
|
|
});
|
|
}
|
|
|
|
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 candidatePaths = [
|
|
localServiceManagerCutoverRecordPath(intent, state),
|
|
...(state === 'manual_required'
|
|
? []
|
|
: [localServiceManagerCutoverRecordPath(intent, 'manual_required')]),
|
|
].filter((candidate) => fs.existsSync(candidate));
|
|
if (candidatePaths.length === 0) return undefined;
|
|
if (candidatePaths.length !== 1) {
|
|
configurationError('service manager cutover replay is ambiguous');
|
|
}
|
|
const record = normalizeLocalServiceManagerCutoverRecord(
|
|
readPrivateLocalCommandFile(candidatePaths[0]!),
|
|
);
|
|
let head = readLocalCutoverInstanceHead(
|
|
intent.deployment.root,
|
|
intent.instanceId,
|
|
currentIdentity().uid,
|
|
);
|
|
const completionFence =
|
|
intent.schemaVersion === 2 && intent.lineage.mode === 'adopted'
|
|
? intent.lineage.completionFence
|
|
: undefined;
|
|
const stoppedCaptureProgress =
|
|
record.state === 'target_stopped' &&
|
|
(head.state === 'reconciliation_capture_prepared' ||
|
|
head.state === 'reconciliation_captured' ||
|
|
head.state === 'reconciliation_plan_prepared' ||
|
|
head.state === 'reconciliation_planned' ||
|
|
head.state === 'reconciliation_review_prepared' ||
|
|
head.state === 'reconciliation_reviewed' ||
|
|
head.state === 'reconciliation_application_prepared' ||
|
|
head.state === 'reconciliation_application_planned' ||
|
|
head.state === 'reconciliation_automation_planned' ||
|
|
head.state === 'reconciliation_automation_decision_prepared' ||
|
|
head.state === 'reconciliation_automation_reviewed');
|
|
const completionRestartPendingHead =
|
|
(record.state === 'target_active' || record.state === 'manual_required') &&
|
|
completionFence !== undefined &&
|
|
record.schemaVersion === 3 &&
|
|
record.evidence.completionFence?.expectedInstanceHeadDigest ===
|
|
completionFence.expectedInstanceHeadDigest &&
|
|
record.evidence.completionFence.expectedCompletionDigest ===
|
|
completionFence.expectedCompletionDigest &&
|
|
head.state === 'reconciliation_completed' &&
|
|
head.headDigest === completionFence.expectedInstanceHeadDigest &&
|
|
head.sourceRecordDigest === completionFence.expectedCompletionDigest;
|
|
if (
|
|
record.actionId !== intent.actionId ||
|
|
record.intentDigest !== intent.intentDigest ||
|
|
record.evidence.managerOutcomeDigest !== outcome.outcomeDigest ||
|
|
record.profile !== intent.profile ||
|
|
record.instanceId !== intent.instanceId ||
|
|
record.activationDigest !== intent.lineage.expectedActivationDigest ||
|
|
record.generation !== intent.lineage.generation ||
|
|
record.previousRecordDigest !== intent.lineage.previousRecordDigest ||
|
|
head.cutoverId !== intent.lineage.cutoverId ||
|
|
head.generation !==
|
|
(completionRestartPendingHead
|
|
? intent.lineage.generation - 1
|
|
: intent.lineage.generation) ||
|
|
(!stoppedCaptureProgress &&
|
|
!completionRestartPendingHead &&
|
|
head.state !== record.state) ||
|
|
(!stoppedCaptureProgress &&
|
|
!completionRestartPendingHead &&
|
|
head.sourceRecordDigest !== record.recordDigest)
|
|
) {
|
|
configurationError('service manager cutover replay drifted');
|
|
}
|
|
if (completionRestartPendingHead) {
|
|
head = advanceLocalCutoverInstanceHead(
|
|
{
|
|
options: { deploymentRoot: intent.deployment.root },
|
|
request: {
|
|
cutoverId: intent.lineage.cutoverId,
|
|
profile: intent.profile,
|
|
instanceId: intent.instanceId,
|
|
expectedActivationDigest: intent.lineage.expectedActivationDigest,
|
|
requestedAtMs: record.completedAtMs,
|
|
},
|
|
},
|
|
currentIdentity().uid,
|
|
record.state,
|
|
intent.lineage.generation,
|
|
record.recordDigest,
|
|
);
|
|
}
|
|
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 adoptedEvidence = 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: adoptedEvidence.targetDataIdentityDigest,
|
|
legacyDataApplicationCommitDigest:
|
|
adoptedEvidence.legacyDataApplicationCommitDigest,
|
|
legacyDataApplicationReceiptDigest:
|
|
adoptedEvidence.legacyDataApplicationReceiptDigest,
|
|
startupReceiptDigest,
|
|
shutdownReceiptDigest,
|
|
processIdentityDigest,
|
|
manualReason,
|
|
...(intent.schemaVersion === 2 &&
|
|
intent.lineage.completionFence !== undefined
|
|
? {
|
|
completionFence: intent.lineage.completionFence,
|
|
}
|
|
: {}),
|
|
});
|
|
const record = localServiceManagerCutoverRecord(
|
|
intent,
|
|
outcome,
|
|
finalState,
|
|
evidence,
|
|
);
|
|
const status = publishLocalServiceManagerCutoverRecord(
|
|
intent,
|
|
record,
|
|
identity.uid,
|
|
);
|
|
dependencies.afterRecordPublished?.();
|
|
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,
|
|
}),
|
|
);
|
|
}
|