mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): prepare service manager legacy rollback
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user