feat(ql3): prepare service manager legacy rollback

This commit is contained in:
whyour
2026-08-20 16:34:30 +08:00
parent 0c503932cc
commit ad291e514d
11 changed files with 1058 additions and 13 deletions
@@ -22,6 +22,14 @@ export interface TargetDataReconciliationEvidence {
readonly evidenceDigest: string;
}
export interface TargetDataReconciliationInput {
readonly profile: 'edge' | 'standalone';
readonly activationPath: string;
readonly legacySourcePath: string;
readonly targetDatabasePath: string;
readonly expectedActivationDigest: string;
}
interface FileEvidence {
readonly sha256: string;
readonly identityDigest: string;
@@ -150,22 +158,36 @@ function evidence(
export function readTargetDataReconciliationEvidence(
command: Readonly<LocalDeploymentTargetRunCommand>,
uid: number,
): Readonly<TargetDataReconciliationEvidence> {
return readTargetDataReconciliationEvidenceForPaths(
{
profile: command.request.profile,
activationPath: command.request.activationPath,
legacySourcePath: command.request.legacySourcePath,
targetDatabasePath: command.request.targetDatabasePath,
expectedActivationDigest: command.request.expectedActivationDigest,
},
uid,
);
}
export function readTargetDataReconciliationEvidenceForPaths(
input: Readonly<TargetDataReconciliationInput>,
uid: number,
): Readonly<TargetDataReconciliationEvidence> {
try {
const activation = object(
readPrivateLocalCommandFile(command.request.activationPath),
readPrivateLocalCommandFile(input.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 ||
activation.profile !== input.profile ||
activation.sourcePathDigest !== textDigest(input.legacySourcePath) ||
activation.targetPathDigest !== textDigest(input.targetDatabasePath) ||
activationDigest !== input.expectedActivationDigest ||
typeof activationDigest !== 'string' ||
!DIGEST_PATTERN.test(activationDigest) ||
typeof activation.targetSha256 !== 'string' ||
@@ -179,12 +201,12 @@ export function readTargetDataReconciliationEvidence(
throw new Error('activation identity drifted');
}
const target = fileEvidence(
command.request.targetDatabasePath,
input.targetDatabasePath,
uid,
'target database',
);
const source = fileEvidence(
command.request.legacySourcePath,
input.legacySourcePath,
uid,
'legacy source database',
);
@@ -79,6 +79,10 @@ import {
consumeLocalServiceManagerCutoverOutcome,
consumeLocalServiceManagerCutoverOutcomeCommandFile,
} from './service-manager/serviceCutoverConsumer';
import {
prepareLocalServiceManagerLegacyRollback,
prepareLocalServiceManagerLegacyRollbackCommandFile,
} from './service-manager/serviceLegacyRollback';
export {
LocalDeploymentConfigurationError,
@@ -181,6 +185,8 @@ export {
consumeLocalServiceManagerCutoverOutcomeCommandFile,
prepareLocalServiceManagerIntent,
prepareLocalServiceManagerIntentCommandFile,
prepareLocalServiceManagerLegacyRollback,
prepareLocalServiceManagerLegacyRollbackCommandFile,
};
export {
localServiceManagerIntentDigest,
@@ -209,6 +215,13 @@ export {
type LocalServiceManagerCutoverRecord,
type LocalServiceManagerCutoverState,
} from './service-manager/serviceCutoverJournal';
export {
localServiceManagerLegacyRollbackPreparationPath,
normalizeLocalServiceManagerLegacyRollbackPreparation,
type LocalServiceManagerLegacyRollbackPreparation,
type LocalServiceManagerLegacyRollbackPrepareCommand,
type LocalServiceManagerLegacyRollbackPrepareResult,
} from './service-manager/serviceLegacyRollback';
export async function prepareLocalDeployment(
input: unknown,
@@ -10,6 +10,7 @@ import {
inspectLocalDeploymentStatusCommandFile,
preflightLocalDeploymentComposeCommandFile,
prepareLocalServiceManagerIntentCommandFile,
prepareLocalServiceManagerLegacyRollbackCommandFile,
prepareLocalDeploymentCommandFile,
restoreLocalDeploymentComposeCommitCommandFile,
restoreLocalDeploymentComposePrepareCommandFile,
@@ -22,7 +23,7 @@ import {
} 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';
'Usage: ql3-local-deploy <prepare|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|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')) {
@@ -36,6 +37,7 @@ async function main(argv: readonly string[]): Promise<void> {
argv[0] !== 'service-intent-prepare' &&
argv[0] !== 'service-outcome-consume' &&
argv[0] !== 'service-cutover-consume' &&
argv[0] !== 'service-legacy-rollback-prepare' &&
argv[0] !== 'cutover-legacy-stop' &&
argv[0] !== 'cutover-target-start' &&
argv[0] !== 'cutover-target-restart' &&
@@ -74,6 +76,8 @@ async function main(argv: readonly string[]): Promise<void> {
? consumeLocalServiceManagerOutcomeCommandFile(argv[2]!)
: argv[0] === 'service-cutover-consume'
? consumeLocalServiceManagerCutoverOutcomeCommandFile(argv[2]!)
: argv[0] === 'service-legacy-rollback-prepare'
? prepareLocalServiceManagerLegacyRollbackCommandFile(argv[2]!)
: argv[0] === 'cutover-legacy-stop'
? stopLegacyDockerForLocalDeploymentCommandFile(argv[2]!)
: argv[0] === 'cutover-target-start' ||
@@ -0,0 +1,633 @@
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 {
advanceLocalCutoverInstanceHead,
readLocalCutoverInstanceHead,
} from '../cutover/instanceLineage';
import {
readTargetDataReconciliationEvidenceForPaths,
verifyTargetDataReconciliationEvidence,
type TargetDataReconciliationEvidence,
} from '../cutover/targetDataEvidence';
import { cutoverDigest } from '../cutover/targetEvidence';
import { currentIdentity } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import { preflightPublishedFile, publishExactFile } from '../foundation/files';
import {
localServiceManagerIntentPath,
normalizeLocalServiceManagerIntent,
} from './serviceBridgeContract';
import { normalizeLocalServiceManagerCutoverRecord } from './serviceCutoverJournal';
const SCHEMA = 'qinglong3-local-service-manager-rollback-preparation';
const ZERO_DIGEST = '0'.repeat(64);
const DIGEST_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 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 SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
const MAX_PATH_BYTES = 4_096;
const MAX_GENERATION = 15;
export interface LocalServiceManagerLegacyRollbackPrepareCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.legacy-rollback.prepare';
readonly options: Readonly<{
deploymentRoot: string;
allowRootService: boolean;
}>;
readonly request: Readonly<{
cutoverId: string;
profile: 'edge' | 'standalone';
instanceId: string;
generation: number;
expectedActivationDigest: string;
expectedStoppedRecordDigest: string;
expectedInstanceHeadDigest: string;
requestedAtMs: number;
}>;
}
export interface LocalServiceManagerLegacyRollbackPrepareResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.legacy-rollback.prepare';
readonly status: 'prepared' | 'existing' | 'not-prepared';
readonly state: 'rollback_prepared' | 'target_stopped';
readonly rollbackDisposition: TargetDataReconciliationEvidence['disposition'];
readonly cutoverId: string;
readonly generation: number;
readonly preparationDigest: string;
readonly instanceHeadDigest: string;
}
export interface LocalServiceManagerLegacyRollbackPreparation {
readonly schema: typeof SCHEMA;
readonly schemaVersion: 1;
readonly state: 'rollback_prepared';
readonly cutoverId: string;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly generation: number;
readonly activationDigest: string;
readonly expectedInstanceHeadDigest: string;
readonly stoppedRecordDigest: string;
readonly stoppedManagerOutcomeDigest: string;
readonly applicationConfigDigest: string;
readonly commitmentDigest: string;
readonly shutdownReceiptDigest: string;
readonly reconciliation: Readonly<TargetDataReconciliationEvidence>;
readonly requestedAtMs: number;
readonly preparationDigest: string;
}
interface AdoptedPaths {
readonly activationPath: string;
readonly legacySourcePath: string;
readonly targetDatabasePath: string;
readonly expectedActivationDigest: string;
readonly commitmentDigest: 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<LocalServiceManagerLegacyRollbackPrepareCommand> {
const command = object(value, 'service manager rollback prepare 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,
[
'cutoverId',
'expectedActivationDigest',
'expectedInstanceHeadDigest',
'expectedStoppedRecordDigest',
'generation',
'instanceId',
'profile',
'requestedAtMs',
],
'request',
);
const identity = currentIdentity();
if (
command.schemaVersion !== 1 ||
command.operation !==
'local.deployment.service-manager.legacy-rollback.prepare' ||
typeof options.allowRootService !== 'boolean' ||
(identity.uid === 0) !== options.allowRootService ||
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) ||
!Number.isSafeInteger(request.generation) ||
(request.generation as number) < 1 ||
(request.generation as number) > MAX_GENERATION ||
typeof request.expectedActivationDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedActivationDigest) ||
typeof request.expectedStoppedRecordDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedStoppedRecordDigest) ||
typeof request.expectedInstanceHeadDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedInstanceHeadDigest) ||
!Number.isSafeInteger(request.requestedAtMs) ||
(request.requestedAtMs as number) < 0
) {
configurationError('service manager rollback prepare command is invalid');
}
return Object.freeze({
schemaVersion: 1 as const,
operation:
'local.deployment.service-manager.legacy-rollback.prepare' as const,
options: Object.freeze({
deploymentRoot: safeAbsolutePath(
options.deploymentRoot,
'deploymentRoot',
),
allowRootService: options.allowRootService,
}),
request: Object.freeze({
cutoverId: request.cutoverId,
profile: request.profile,
instanceId: request.instanceId,
generation: request.generation as number,
expectedActivationDigest: request.expectedActivationDigest,
expectedStoppedRecordDigest: request.expectedStoppedRecordDigest,
expectedInstanceHeadDigest: request.expectedInstanceHeadDigest,
requestedAtMs: request.requestedAtMs as number,
}),
});
}
function stoppedRecordPath(
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
): string {
return path.join(
command.options.deploymentRoot,
'service',
'cutovers',
command.request.cutoverId,
`service-manager-g${String(command.request.generation).padStart(
2,
'0',
)}-stopped.json`,
);
}
export function localServiceManagerLegacyRollbackPreparationPath(
deploymentRoot: string,
cutoverId: string,
generation: number,
): string {
return path.join(
deploymentRoot,
'service',
'cutovers',
cutoverId,
`service-manager-g${String(generation).padStart(
2,
'0',
)}-rollback-prepared.json`,
);
}
function applicationSha256(filePath: string): string {
try {
return crypto
.createHash('sha256')
.update(fs.readFileSync(filePath))
.digest('hex');
} catch (error) {
return configurationError(
'application configuration cannot be hashed',
error,
);
}
}
function adoptedPaths(
applicationPath: string,
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
expectedApplicationDigest: string,
expectedCommitmentDigest: string,
): Readonly<AdoptedPaths> {
if (applicationSha256(applicationPath) !== expectedApplicationDigest) {
configurationError('adopted application configuration digest drifted');
}
const application = object(
readPrivateLocalCommandFile(applicationPath),
'adopted application configuration',
);
const storage = object(application.storage, 'adopted storage');
const cutover = object(application.cutover, 'adopted cutover');
const commitmentPath = safeAbsolutePath(
cutover.commitmentPath,
'commitmentPath',
);
const expectedCommitmentPath = path.join(
command.options.deploymentRoot,
'service',
'cutovers',
command.request.cutoverId,
'0002-legacy-stopped.json',
);
const commitment = object(
readPrivateLocalCommandFile(commitmentPath),
'legacy silence commitment',
);
const { commitmentDigest, ...commitmentPayload } = commitment;
if (
application.schema !== 'qinglong/local-application-process@v3' ||
application.profile !== command.request.profile ||
application.instanceId !== command.request.instanceId ||
storage.mode !== 'adopted' ||
storage.expectedActivationDigest !==
command.request.expectedActivationDigest ||
cutover.cutoverId !== command.request.cutoverId ||
cutover.expectedCommitmentDigest !== expectedCommitmentDigest ||
commitmentPath !== expectedCommitmentPath ||
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 !== expectedCommitmentDigest ||
cutoverDigest(commitmentPayload) !== commitmentDigest
) {
configurationError('adopted application rollback binding drifted');
}
return Object.freeze({
activationPath: safeAbsolutePath(storage.activationPath, 'activationPath'),
legacySourcePath: safeAbsolutePath(storage.sourcePath, 'legacySourcePath'),
targetDatabasePath: safeAbsolutePath(
storage.targetPath,
'targetDatabasePath',
),
expectedActivationDigest: command.request.expectedActivationDigest,
commitmentDigest: expectedCommitmentDigest,
});
}
function preparationRecord(
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
stopped: ReturnType<typeof normalizeLocalServiceManagerCutoverRecord>,
reconciliation: Readonly<TargetDataReconciliationEvidence>,
): Readonly<LocalServiceManagerLegacyRollbackPreparation> {
if (
stopped.evidence.shutdownReceiptDigest === null ||
stopped.evidence.manualReason !== null
) {
configurationError('stopped service evidence cannot authorize rollback');
}
const payload = Object.freeze({
schema: SCHEMA,
schemaVersion: 1 as const,
state: 'rollback_prepared' as const,
cutoverId: command.request.cutoverId,
profile: command.request.profile,
instanceId: command.request.instanceId,
generation: command.request.generation,
activationDigest: command.request.expectedActivationDigest,
expectedInstanceHeadDigest: command.request.expectedInstanceHeadDigest,
stoppedRecordDigest: stopped.recordDigest,
stoppedManagerOutcomeDigest: stopped.evidence.managerOutcomeDigest,
applicationConfigDigest: stopped.evidence.applicationConfigDigest,
commitmentDigest: stopped.evidence.commitmentDigest,
shutdownReceiptDigest: stopped.evidence.shutdownReceiptDigest,
reconciliation,
requestedAtMs: command.request.requestedAtMs,
});
return Object.freeze({
...payload,
preparationDigest: cutoverDigest(payload),
});
}
export function normalizeLocalServiceManagerLegacyRollbackPreparation(
value: unknown,
): Readonly<LocalServiceManagerLegacyRollbackPreparation> {
const record = object(value, 'service manager rollback preparation');
exact(
record,
[
'activationDigest',
'applicationConfigDigest',
'commitmentDigest',
'cutoverId',
'expectedInstanceHeadDigest',
'generation',
'instanceId',
'preparationDigest',
'profile',
'reconciliation',
'requestedAtMs',
'schema',
'schemaVersion',
'shutdownReceiptDigest',
'state',
'stoppedManagerOutcomeDigest',
'stoppedRecordDigest',
],
'service manager rollback preparation',
);
const reconciliation = verifyTargetDataReconciliationEvidence(
record.reconciliation,
);
const { preparationDigest, ...rawPayload } = record;
const payload = { ...rawPayload, reconciliation };
if (
record.schema !== SCHEMA ||
record.schemaVersion !== 1 ||
record.state !== 'rollback_prepared' ||
typeof record.cutoverId !== 'string' ||
!CUTOVER_ID_PATTERN.test(record.cutoverId) ||
(record.profile !== 'edge' && record.profile !== 'standalone') ||
typeof record.instanceId !== 'string' ||
!INSTANCE_ID_PATTERN.test(record.instanceId) ||
!Number.isSafeInteger(record.generation) ||
(record.generation as number) < 1 ||
(record.generation as number) > MAX_GENERATION ||
typeof record.activationDigest !== 'string' ||
!DIGEST_PATTERN.test(record.activationDigest) ||
typeof record.expectedInstanceHeadDigest !== 'string' ||
!DIGEST_PATTERN.test(record.expectedInstanceHeadDigest) ||
typeof record.stoppedRecordDigest !== 'string' ||
!DIGEST_PATTERN.test(record.stoppedRecordDigest) ||
typeof record.stoppedManagerOutcomeDigest !== 'string' ||
!DIGEST_PATTERN.test(record.stoppedManagerOutcomeDigest) ||
typeof record.applicationConfigDigest !== 'string' ||
!DIGEST_PATTERN.test(record.applicationConfigDigest) ||
typeof record.commitmentDigest !== 'string' ||
!DIGEST_PATTERN.test(record.commitmentDigest) ||
typeof record.shutdownReceiptDigest !== 'string' ||
!DIGEST_PATTERN.test(record.shutdownReceiptDigest) ||
reconciliation.disposition !== 'rollback_candidate' ||
!Number.isSafeInteger(record.requestedAtMs) ||
(record.requestedAtMs as number) < 0 ||
typeof preparationDigest !== 'string' ||
!DIGEST_PATTERN.test(preparationDigest) ||
cutoverDigest(payload) !== preparationDigest
) {
configurationError('service manager rollback preparation drifted');
}
return Object.freeze({
...(payload as Omit<
LocalServiceManagerLegacyRollbackPreparation,
'preparationDigest'
>),
preparationDigest,
});
}
function result(
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
status: LocalServiceManagerLegacyRollbackPrepareResult['status'],
state: LocalServiceManagerLegacyRollbackPrepareResult['state'],
reconciliation: Readonly<TargetDataReconciliationEvidence>,
preparationDigest: string,
instanceHeadDigest: string,
): Readonly<LocalServiceManagerLegacyRollbackPrepareResult> {
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status,
state,
rollbackDisposition: reconciliation.disposition,
cutoverId: command.request.cutoverId,
generation: command.request.generation,
preparationDigest,
instanceHeadDigest,
});
}
export function prepareLocalServiceManagerLegacyRollback(
input: unknown,
): Readonly<LocalServiceManagerLegacyRollbackPrepareResult> {
const command = normalizeCommand(input);
const identity = currentIdentity();
const head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
command.request.instanceId,
identity.uid,
);
if (
head.profile !== command.request.profile ||
head.cutoverId !== command.request.cutoverId ||
head.activationDigest !== command.request.expectedActivationDigest ||
head.generation !== command.request.generation ||
(head.state !== 'target_stopped' && head.state !== 'rollback_prepared')
) {
configurationError('rollback prepare is not bound to the instance head');
}
const stopped = normalizeLocalServiceManagerCutoverRecord(
readPrivateLocalCommandFile(stoppedRecordPath(command)),
);
if (
stopped.state !== 'target_stopped' ||
stopped.action !== 'stop' ||
stopped.cutoverId !== command.request.cutoverId ||
stopped.profile !== command.request.profile ||
stopped.instanceId !== command.request.instanceId ||
stopped.activationDigest !== command.request.expectedActivationDigest ||
stopped.generation !== command.request.generation ||
stopped.recordDigest !== command.request.expectedStoppedRecordDigest ||
stopped.completedAtMs > command.request.requestedAtMs ||
(head.state === 'target_stopped' &&
head.sourceRecordDigest !== stopped.recordDigest) ||
(head.state === 'target_stopped' &&
head.headDigest !== command.request.expectedInstanceHeadDigest)
) {
configurationError('stopped service rollback binding drifted');
}
const intent = normalizeLocalServiceManagerIntent(
readPrivateLocalJsonFile(
localServiceManagerIntentPath(
command.options.deploymentRoot,
stopped.actionId,
),
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
),
);
if (
!UUID_V4_PATTERN.test(intent.actionId) ||
intent.intentDigest !== stopped.intentDigest ||
intent.action !== 'stop' ||
intent.profile !== command.request.profile ||
intent.instanceId !== command.request.instanceId ||
intent.lineage.mode !== 'adopted' ||
intent.lineage.cutoverId !== command.request.cutoverId ||
intent.lineage.generation !== command.request.generation ||
intent.lineage.expectedActivationDigest !==
command.request.expectedActivationDigest
) {
configurationError('stopped service intent binding drifted');
}
const paths = adoptedPaths(
intent.deployment.applicationConfigPath,
command,
stopped.evidence.applicationConfigDigest,
stopped.evidence.commitmentDigest,
);
const reconciliation = readTargetDataReconciliationEvidenceForPaths(
{
profile: command.request.profile,
activationPath: paths.activationPath,
legacySourcePath: paths.legacySourcePath,
targetDatabasePath: paths.targetDatabasePath,
expectedActivationDigest: paths.expectedActivationDigest,
},
identity.uid,
);
if (reconciliation.disposition !== 'rollback_candidate') {
if (head.state !== 'target_stopped') {
configurationError('prepared rollback data drifted after authorization');
}
return result(
command,
'not-prepared',
'target_stopped',
reconciliation,
ZERO_DIGEST,
head.headDigest,
);
}
const preparation = preparationRecord(command, stopped, reconciliation);
const filePath = localServiceManagerLegacyRollbackPreparationPath(
command.options.deploymentRoot,
command.request.cutoverId,
command.request.generation,
);
if (head.state === 'rollback_prepared') {
const existing = normalizeLocalServiceManagerLegacyRollbackPreparation(
readPrivateLocalCommandFile(filePath),
);
if (
existing.preparationDigest !== preparation.preparationDigest ||
head.sourceRecordDigest !== existing.preparationDigest ||
head.previousHeadDigest !== command.request.expectedInstanceHeadDigest
) {
configurationError('service manager rollback preparation replay drifted');
}
return result(
command,
'existing',
'rollback_prepared',
reconciliation,
existing.preparationDigest,
head.headDigest,
);
}
const contents = `${JSON.stringify(preparation, null, 2)}\n`;
preflightPublishedFile(
filePath,
contents,
0o600,
identity.uid,
'service manager rollback preparation',
);
const status = publishExactFile(
filePath,
contents,
0o600,
identity.uid,
'service manager rollback preparation',
);
const next = advanceLocalCutoverInstanceHead(
{
options: { deploymentRoot: command.options.deploymentRoot },
request: {
cutoverId: command.request.cutoverId,
profile: command.request.profile,
instanceId: command.request.instanceId,
expectedActivationDigest: command.request.expectedActivationDigest,
requestedAtMs: command.request.requestedAtMs,
},
},
identity.uid,
'rollback_prepared',
command.request.generation,
preparation.preparationDigest,
);
return result(
command,
status,
'rollback_prepared',
reconciliation,
preparation.preparationDigest,
next.headDigest,
);
}
export function prepareLocalServiceManagerLegacyRollbackCommandFile(
filePath: string,
): Readonly<LocalServiceManagerLegacyRollbackPrepareResult> {
return prepareLocalServiceManagerLegacyRollback(
readPrivateLocalJsonFile(filePath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
}),
);
}
@@ -8,6 +8,10 @@ const { test } = require('node:test');
const {
consumeLocalServiceManagerCutoverOutcome,
} = require('../dist/deployment/service-manager/serviceCutoverConsumer.js');
const {
localServiceManagerLegacyRollbackPreparationPath,
prepareLocalServiceManagerLegacyRollback,
} = require('../dist/deployment/service-manager/serviceLegacyRollback.js');
const {
prepareLocalServiceManagerIntent,
} = require('../dist/deployment/service-manager/serviceManagerIntent.js');
@@ -163,8 +167,10 @@ function fixture(t) {
cutoverId,
activationDigest,
commitmentDigest,
commitmentPath,
applicationPath,
sourcePath,
targetPath,
identity,
procRoot,
};
@@ -315,6 +321,69 @@ function consumeCommand(state, prepared) {
};
}
async function stopAdoptedTarget(state, suffix = '031') {
const active = prepare(
state,
1,
'install-enable-start',
state.commitmentDigest,
`123e4567-e89b-42d3-a456-426614174${suffix}`,
);
publishOutcome(active, 'install-enable-start', 'active', 4723, 1786416000200);
const startupReceiptDigest = publishReceipt(state, 4723, '100007');
const activeResult = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, active),
{ procRoot: state.procRoot },
);
const stopped = prepare(
state,
1,
'stop',
activeResult.recordDigest,
`123e4567-e89b-42d3-a456-426614174${String(Number(suffix) + 1).padStart(
3,
'0',
)}`,
);
publishOutcome(stopped, 'stop', 'stopped', 0, 1786416000300);
publishShutdownReceipt(state, 4723, '100007', startupReceiptDigest);
fs.rmSync(path.join(state.procRoot, '4723'), {
recursive: true,
force: true,
});
const stoppedResult = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, stopped),
{ procRoot: state.procRoot },
);
const head = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
return { stoppedResult, head };
}
function rollbackPrepareCommand(state, stoppedResult, head) {
return {
schemaVersion: 1,
operation: 'local.deployment.service-manager.legacy-rollback.prepare',
options: {
deploymentRoot: state.root,
allowRootService: process.getuid() === 0,
},
request: {
cutoverId: state.cutoverId,
profile: 'edge',
instanceId: 'edge-router-1',
generation: 1,
expectedActivationDigest: state.activationDigest,
expectedStoppedRecordDigest: stoppedResult.recordDigest,
expectedInstanceHeadDigest: head.headDigest,
requestedAtMs: 1786416000400,
},
};
}
test('commits adopted service active evidence and replays from the instance head', async (t) => {
const state = fixture(t);
const prepared = prepare(
@@ -468,6 +537,117 @@ test('stop advances only after the exact receipted process identity disappears',
assert.equal(record.evidence.shutdownReceiptDigest, shutdownReceiptDigest);
});
test('prepares and exactly replays lossless service-manager legacy rollback evidence', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state);
const command = rollbackPrepareCommand(state, stoppedResult, head);
const prepared = prepareLocalServiceManagerLegacyRollback(command);
assert.equal(prepared.status, 'prepared');
assert.equal(prepared.state, 'rollback_prepared');
assert.equal(prepared.rollbackDisposition, 'rollback_candidate');
assert.match(prepared.preparationDigest, /^[0-9a-f]{64}$/);
const preparedHead = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(preparedHead.state, 'rollback_prepared');
assert.equal(preparedHead.sourceRecordDigest, prepared.preparationDigest);
const record = JSON.parse(
fs.readFileSync(
localServiceManagerLegacyRollbackPreparationPath(
state.root,
state.cutoverId,
1,
),
'utf8',
),
);
assert.equal(record.stoppedRecordDigest, stoppedResult.recordDigest);
assert.equal(record.reconciliation.targetMatchesActivation, true);
assert.equal(record.reconciliation.sourceMatchesRecovery, true);
assert.equal(record.reconciliation.targetSidecarsClear, true);
assert.equal(record.reconciliation.sourceSidecarsClear, true);
const replay = prepareLocalServiceManagerLegacyRollback(command);
assert.equal(replay.status, 'existing');
assert.equal(replay.preparationDigest, prepared.preparationDigest);
assert.equal(replay.instanceHeadDigest, prepared.instanceHeadDigest);
});
test('keeps target_stopped when service-manager rollback would discard target writes', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state, '033');
fs.writeFileSync(state.targetPath, 'target-written-by-qinglong3\n', {
mode: 0o600,
});
const result = prepareLocalServiceManagerLegacyRollback(
rollbackPrepareCommand(state, stoppedResult, head),
);
assert.equal(result.status, 'not-prepared');
assert.equal(result.state, 'target_stopped');
assert.equal(result.rollbackDisposition, 'reconciliation_required');
assert.equal(result.preparationDigest, '0'.repeat(64));
const unchanged = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(unchanged.state, 'target_stopped');
assert.equal(unchanged.headDigest, head.headDigest);
assert.equal(
fs.existsSync(
localServiceManagerLegacyRollbackPreparationPath(
state.root,
state.cutoverId,
1,
),
),
false,
);
});
test('rejects application configuration drift before service-manager rollback preparation', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state, '035');
fs.appendFileSync(state.applicationPath, ' ');
assert.throws(
() =>
prepareLocalServiceManagerLegacyRollback(
rollbackPrepareCommand(state, stoppedResult, head),
),
/application configuration digest drifted/,
);
const unchanged = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(unchanged.state, 'target_stopped');
assert.equal(unchanged.headDigest, head.headDigest);
});
test('rejects legacy silence commitment drift before service-manager rollback preparation', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state, '037');
const commitment = JSON.parse(fs.readFileSync(state.commitmentPath, 'utf8'));
commitment.observedAtMs += 1;
writePrivate(state.commitmentPath, commitment);
assert.throws(
() =>
prepareLocalServiceManagerLegacyRollback(
rollbackPrepareCommand(state, stoppedResult, head),
),
/application rollback binding drifted/,
);
const unchanged = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(unchanged.state, 'target_stopped');
assert.equal(unchanged.headDigest, head.headDigest);
});
test('stop without an exact shutdown receipt requires manual resolution', async (t) => {
const state = fixture(t);
const first = prepare(