feat(ql3): commit service manager legacy rollback

This commit is contained in:
whyour
2026-08-20 17:40:29 +08:00
parent ad291e514d
commit 2cd6ea1d55
16 changed files with 3382 additions and 62 deletions
@@ -82,7 +82,13 @@ import {
import {
prepareLocalServiceManagerLegacyRollback,
prepareLocalServiceManagerLegacyRollbackCommandFile,
} from './service-manager/serviceLegacyRollback';
} from './service-manager/legacy-rollback/preparation';
import {
authorizeLocalServiceManagerLegacyRollback,
authorizeLocalServiceManagerLegacyRollbackCommandFile,
consumeLocalServiceManagerLegacyRollback,
consumeLocalServiceManagerLegacyRollbackCommandFile,
} from './service-manager/legacy-rollback/consumer';
export {
LocalDeploymentConfigurationError,
@@ -187,6 +193,10 @@ export {
prepareLocalServiceManagerIntentCommandFile,
prepareLocalServiceManagerLegacyRollback,
prepareLocalServiceManagerLegacyRollbackCommandFile,
authorizeLocalServiceManagerLegacyRollback,
authorizeLocalServiceManagerLegacyRollbackCommandFile,
consumeLocalServiceManagerLegacyRollback,
consumeLocalServiceManagerLegacyRollbackCommandFile,
};
export {
localServiceManagerIntentDigest,
@@ -221,7 +231,29 @@ export {
type LocalServiceManagerLegacyRollbackPreparation,
type LocalServiceManagerLegacyRollbackPrepareCommand,
type LocalServiceManagerLegacyRollbackPrepareResult,
} from './service-manager/serviceLegacyRollback';
} from './service-manager/legacy-rollback/preparation';
export {
normalizeLocalServiceManagerLegacyRollbackCompletion,
type LocalServiceManagerLegacyRollbackAuthorizeCommand,
type LocalServiceManagerLegacyRollbackAuthorizeResult,
type LocalServiceManagerLegacyRollbackCompletion,
type LocalServiceManagerLegacyRollbackConsumeCommand,
type LocalServiceManagerLegacyRollbackConsumeResult,
} from './service-manager/legacy-rollback/consumer';
export {
localServiceManagerLegacyCompletionPath,
localServiceManagerLegacyDescriptorPath,
localServiceManagerLegacyStartAuthorizationPath,
localServiceManagerLegacyStartOutcomePath,
localServiceManagerTargetDescriptorPath,
normalizeLocalServiceManagerLegacyRollbackBridgeCommand,
normalizeLocalServiceManagerLegacyStartAuthorization,
normalizeLocalServiceManagerLegacyStartOutcome,
type LocalServiceManagerLegacyRollbackBridgeCommand,
type LocalServiceManagerLegacyRollbackBridgeResult,
type LocalServiceManagerLegacyStartAuthorization,
type LocalServiceManagerLegacyStartOutcome,
} from './service-manager/legacy-rollback/contract';
export async function prepareLocalDeployment(
input: unknown,
@@ -3,10 +3,12 @@
// Keep the deployment binary beside its composition authority.
import {
applyLocalDeploymentComposeCommandFile,
authorizeLocalServiceManagerLegacyRollbackCommandFile,
collectLocalDeploymentComposeEvidenceCommitCommandFile,
collectLocalDeploymentComposeEvidencePrepareCommandFile,
consumeLocalServiceManagerOutcomeCommandFile,
consumeLocalServiceManagerCutoverOutcomeCommandFile,
consumeLocalServiceManagerLegacyRollbackCommandFile,
inspectLocalDeploymentStatusCommandFile,
preflightLocalDeploymentComposeCommandFile,
prepareLocalServiceManagerIntentCommandFile,
@@ -23,7 +25,7 @@ import {
} from './localDeployment';
const USAGE =
'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';
'Usage: ql3-local-deploy <prepare|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
async function main(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
@@ -38,6 +40,8 @@ async function main(argv: readonly string[]): Promise<void> {
argv[0] !== 'service-outcome-consume' &&
argv[0] !== 'service-cutover-consume' &&
argv[0] !== 'service-legacy-rollback-prepare' &&
argv[0] !== 'service-legacy-rollback-authorize' &&
argv[0] !== 'service-legacy-rollback-consume' &&
argv[0] !== 'cutover-legacy-stop' &&
argv[0] !== 'cutover-target-start' &&
argv[0] !== 'cutover-target-restart' &&
@@ -78,6 +82,10 @@ async function main(argv: readonly string[]): Promise<void> {
? consumeLocalServiceManagerCutoverOutcomeCommandFile(argv[2]!)
: argv[0] === 'service-legacy-rollback-prepare'
? prepareLocalServiceManagerLegacyRollbackCommandFile(argv[2]!)
: argv[0] === 'service-legacy-rollback-authorize'
? authorizeLocalServiceManagerLegacyRollbackCommandFile(argv[2]!)
: argv[0] === 'service-legacy-rollback-consume'
? consumeLocalServiceManagerLegacyRollbackCommandFile(argv[2]!)
: argv[0] === 'cutover-legacy-stop'
? stopLegacyDockerForLocalDeploymentCommandFile(argv[2]!)
: argv[0] === 'cutover-target-start' ||
@@ -0,0 +1,881 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { LocalDeploymentConfigurationError } from '../../foundation/error';
import type {
LocalServiceBridgeManager,
LocalServiceManagerKind,
} from '../serviceBridgeContract';
import {
ensureRootServiceBridgeDirectory,
publishServiceBridgeFile,
readOwnerPrivateJsonFile,
readServiceBridgeFile,
validateServiceBridgeDirectory,
} from '../serviceBridgeFiles';
import type {
LocalServiceManagerRunRequest,
LocalServiceManagerRunResult,
} from '../serviceBridge';
import {
localServiceManagerLegacyDescriptorPath,
localServiceManagerLegacyStartAuthorizationPath,
localServiceManagerLegacyStartOutcomeDigest,
localServiceManagerLegacyStartOutcomePath,
localServiceManagerRollbackObservationDigest,
localServiceManagerTargetDescriptorPath,
normalizeLocalServiceManagerLegacyRollbackBridgeCommand,
normalizeLocalServiceManagerLegacyStartAuthorization,
normalizeLocalServiceManagerLegacyStartOutcome,
normalizeLocalServiceManagerRollbackObservation,
type LocalServiceManagerLegacyRollbackBridgeCommand,
type LocalServiceManagerLegacyRollbackBridgeResult,
type LocalServiceManagerLegacyStartAuthorization,
type LocalServiceManagerLegacyStartOutcome,
type LocalServiceManagerRollbackManualReason,
type LocalServiceManagerRollbackMutationDisposition,
type LocalServiceManagerRollbackObservation,
} from './contract';
const BARRIER_SCHEMA = 'qinglong3-local-service-manager-legacy-start-barrier';
const MAX_MANAGER_OUTPUT_BYTES = 64 * 1024;
const MANAGER_TIMEOUT_MS = 45_000;
interface LegacyStartBarrier {
readonly schema: typeof BARRIER_SCHEMA;
readonly schemaVersion: 1;
readonly cutoverId: string;
readonly generation: number;
readonly managerKind: LocalServiceManagerKind;
readonly preparationDigest: string;
readonly authorizationDigest: string;
readonly ownerUid: number;
readonly ownerGid: number;
readonly legacyDescriptorDigest: string;
readonly targetDescriptorDigest: string;
readonly legacyPreObservation: Readonly<LocalServiceManagerRollbackObservation>;
readonly targetPreObservation: Readonly<LocalServiceManagerRollbackObservation>;
readonly createdAtMs: number;
readonly barrierDigest: string;
}
export interface LocalServiceManagerLegacyRollbackBridgeDependencies {
readonly runManager?: (
request: Readonly<LocalServiceManagerRunRequest>,
) => Readonly<LocalServiceManagerRunResult>;
readonly now?: () => number;
readonly afterBarrier?: () => void;
readonly afterStart?: () => void;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
function object(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
configurationError(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exact(
value: Record<string, unknown>,
keys: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
configurationError(`${label} shape is invalid`);
}
}
function digest(value: unknown): string {
return crypto
.createHash('sha256')
.update(JSON.stringify(value))
.digest('hex');
}
function sha256(bytes: Buffer): string {
return crypto.createHash('sha256').update(bytes).digest('hex');
}
function assertRootIdentity(): void {
if (
process.platform === 'win32' ||
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
process.getuid() !== 0 ||
process.geteuid() !== 0
) {
configurationError('legacy rollback bridge must run as root');
}
}
function trustedRootExecutable(filePath: string, label: string): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
configurationError(`${label} is unavailable`, error);
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== 0 ||
stat.gid !== 0 ||
stat.nlink < 1 ||
(stat.mode & 0o022) !== 0 ||
fs.realpathSync(filePath) !== filePath
) {
configurationError(`${label} identity is invalid`);
}
}
function managerExecutables(
manager: LocalServiceBridgeManager,
): readonly string[] {
return manager.kind === 'systemd'
? Object.freeze([manager.executable])
: Object.freeze([manager.serviceExecutable, manager.updateExecutable]);
}
function defaultRunManager(
request: Readonly<LocalServiceManagerRunRequest>,
): Readonly<LocalServiceManagerRunResult> {
const result = spawnSync(request.executable, [...request.args], {
encoding: 'utf8',
env: Object.freeze({
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
LANG: 'C',
LC_ALL: 'C',
}),
timeout: request.timeoutMs,
maxBuffer: MAX_MANAGER_OUTPUT_BYTES,
shell: false,
windowsHide: true,
});
return Object.freeze({
status: result.status,
signal: result.signal,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
responseLost: result.error !== undefined || result.status === null,
});
}
function run(
runner: NonNullable<
LocalServiceManagerLegacyRollbackBridgeDependencies['runManager']
>,
executable: string,
args: readonly string[],
): Readonly<LocalServiceManagerRunResult> {
const result = runner({
executable,
args: Object.freeze([...args]),
timeoutMs: MANAGER_TIMEOUT_MS,
});
if (
!result ||
typeof result !== 'object' ||
(result.status !== null && !Number.isSafeInteger(result.status)) ||
(result.signal !== null && typeof result.signal !== 'string') ||
typeof result.stdout !== 'string' ||
typeof result.stderr !== 'string' ||
typeof result.responseLost !== 'boolean' ||
Buffer.byteLength(result.stdout, 'utf8') > MAX_MANAGER_OUTPUT_BYTES ||
Buffer.byteLength(result.stderr, 'utf8') > MAX_MANAGER_OUTPUT_BYTES
) {
configurationError('legacy rollback manager runner returned invalid data');
}
return result;
}
function systemdObservation(
manager: Extract<LocalServiceBridgeManager, { kind: 'systemd' }>,
serviceName: 'qinglong' | 'qinglong3',
fragmentPath: string,
runner: NonNullable<
LocalServiceManagerLegacyRollbackBridgeDependencies['runManager']
>,
now: () => number,
): Readonly<LocalServiceManagerRollbackObservation> {
const result = run(runner, manager.executable, [
'show',
`${serviceName}.service`,
'--no-page',
'--property=LoadState,ActiveState,SubState,FragmentPath,MainPID,UnitFileState',
]);
const fields = new Map<string, string>();
if (!result.responseLost && result.status === 0) {
for (const line of result.stdout.split('\n')) {
const separator = line.indexOf('=');
if (separator > 0) {
fields.set(line.slice(0, separator), line.slice(separator + 1));
}
}
}
const rawPid = Number(fields.get('MainPID') ?? '0');
const loadState = fields.get('LoadState');
const activeState = fields.get('ActiveState');
const enabledState = fields.get('UnitFileState');
const payload = Object.freeze({
managerKind: 'systemd' as const,
serviceName,
fragmentPath: fields.get('FragmentPath') || fragmentPath,
loadState:
loadState === 'loaded'
? ('loaded' as const)
: loadState === 'not-found'
? ('not-found' as const)
: ('unknown' as const),
activeState:
activeState === 'active'
? ('active' as const)
: activeState === 'inactive'
? ('inactive' as const)
: activeState === 'failed'
? ('failed' as const)
: ('unknown' as const),
subState: (fields.get('SubState') ?? 'unknown').slice(0, 128),
enabledState:
enabledState === 'enabled'
? ('enabled' as const)
: enabledState === 'disabled'
? ('disabled' as const)
: enabledState === 'static'
? ('static' as const)
: ('unknown' as const),
mainPid:
Number.isSafeInteger(rawPid) && rawPid >= 0 && rawPid <= 0x7fffffff
? rawPid
: 0,
observedAtMs: now(),
});
return normalizeLocalServiceManagerRollbackObservation({
...payload,
observationDigest: localServiceManagerRollbackObservationDigest(payload),
});
}
function openrcObservation(
manager: Extract<LocalServiceBridgeManager, { kind: 'openrc' }>,
serviceName: 'qinglong' | 'qinglong3',
fragmentPath: string,
runner: NonNullable<
LocalServiceManagerLegacyRollbackBridgeDependencies['runManager']
>,
now: () => number,
): Readonly<LocalServiceManagerRollbackObservation> {
const service = run(runner, manager.serviceExecutable, [
serviceName,
'status',
]);
const enabled = run(runner, manager.updateExecutable, ['show', 'default']);
const active = !service.responseLost && service.status === 0;
const stopped =
!service.responseLost &&
service.status !== null &&
(service.stdout.includes('stopped') || service.status === 3);
const payload = Object.freeze({
managerKind: 'openrc' as const,
serviceName,
fragmentPath,
loadState: fs.existsSync(fragmentPath)
? ('loaded' as const)
: ('not-found' as const),
activeState: active
? ('active' as const)
: stopped
? ('inactive' as const)
: ('unknown' as const),
subState: active ? 'started' : stopped ? 'stopped' : 'unknown',
enabledState:
!enabled.responseLost &&
enabled.status === 0 &&
enabled.stdout
.split('\n')
.some((line) => new RegExp(`\\b${serviceName}\\b`).test(line))
? ('enabled' as const)
: ('unknown' as const),
mainPid: 0,
observedAtMs: now(),
});
return normalizeLocalServiceManagerRollbackObservation({
...payload,
observationDigest: localServiceManagerRollbackObservationDigest(payload),
});
}
function inspectManager(
manager: LocalServiceBridgeManager,
serviceName: 'qinglong' | 'qinglong3',
fragmentPath: string,
runner: NonNullable<
LocalServiceManagerLegacyRollbackBridgeDependencies['runManager']
>,
now: () => number,
): Readonly<LocalServiceManagerRollbackObservation> {
return manager.kind === 'systemd'
? systemdObservation(manager, serviceName, fragmentPath, runner, now)
: openrcObservation(manager, serviceName, fragmentPath, runner, now);
}
function descriptorDigest(
filePath: string,
kind: LocalServiceManagerKind,
): string {
const bytes = readServiceBridgeFile(
filePath,
{
uid: 0,
gid: 0,
mode: kind === 'systemd' ? 0o644 : 0o755,
maximumBytes: 64 * 1024,
},
'installed rollback service descriptor',
);
try {
return sha256(bytes);
} finally {
bytes.fill(0);
}
}
function ownerMaterialMatches(
command: Readonly<LocalServiceManagerLegacyRollbackBridgeCommand>,
authorization: Readonly<LocalServiceManagerLegacyStartAuthorization>,
ownerUid: number,
ownerGid: number,
): boolean {
const material = [
[
path.join(command.options.deploymentRoot, 'local-application.json'),
authorization.applicationConfigDigest,
'application configuration',
],
[
path.join(
command.options.deploymentRoot,
'service',
'cutovers',
authorization.cutoverId,
'0002-legacy-stopped.json',
),
authorization.commitmentFileDigest,
'legacy silence commitment',
],
] as const;
try {
for (const [filePath, expectedDigest, label] of material) {
const bytes = readServiceBridgeFile(
filePath,
{ uid: ownerUid, gid: ownerGid, mode: 0o600 },
label,
);
try {
if (sha256(bytes) !== expectedDigest) return false;
} finally {
bytes.fill(0);
}
}
return true;
} catch {
return false;
}
}
function barrierRecord(
authorization: Readonly<LocalServiceManagerLegacyStartAuthorization>,
ownerUid: number,
ownerGid: number,
legacyDescriptorDigest: string,
targetDescriptorDigest: string,
legacyPreObservation: Readonly<LocalServiceManagerRollbackObservation>,
targetPreObservation: Readonly<LocalServiceManagerRollbackObservation>,
now: () => number,
): Readonly<LegacyStartBarrier> {
const payload = Object.freeze({
schema: BARRIER_SCHEMA,
schemaVersion: 1 as const,
cutoverId: authorization.cutoverId,
generation: authorization.generation,
managerKind: authorization.managerKind,
preparationDigest: authorization.preparationDigest,
authorizationDigest: authorization.authorizationDigest,
ownerUid,
ownerGid,
legacyDescriptorDigest,
targetDescriptorDigest,
legacyPreObservation,
targetPreObservation,
createdAtMs: now(),
});
return Object.freeze({ ...payload, barrierDigest: digest(payload) });
}
function normalizeBarrier(
value: unknown,
authorization: Readonly<LocalServiceManagerLegacyStartAuthorization>,
): Readonly<LegacyStartBarrier> {
const barrier = object(value, 'legacy start barrier');
exact(
barrier,
[
'authorizationDigest',
'barrierDigest',
'createdAtMs',
'cutoverId',
'generation',
'legacyDescriptorDigest',
'legacyPreObservation',
'managerKind',
'ownerGid',
'ownerUid',
'preparationDigest',
'schema',
'schemaVersion',
'targetDescriptorDigest',
'targetPreObservation',
],
'legacy start barrier',
);
const legacyPreObservation = normalizeLocalServiceManagerRollbackObservation(
barrier.legacyPreObservation,
);
const targetPreObservation = normalizeLocalServiceManagerRollbackObservation(
barrier.targetPreObservation,
);
const { barrierDigest, ...rawPayload } = barrier;
const payload = {
...rawPayload,
legacyPreObservation,
targetPreObservation,
};
if (
barrier.schema !== BARRIER_SCHEMA ||
barrier.schemaVersion !== 1 ||
barrier.cutoverId !== authorization.cutoverId ||
barrier.generation !== authorization.generation ||
barrier.managerKind !== authorization.managerKind ||
barrier.preparationDigest !== authorization.preparationDigest ||
barrier.authorizationDigest !== authorization.authorizationDigest ||
!Number.isSafeInteger(barrier.ownerUid) ||
(barrier.ownerUid as number) < 0 ||
!Number.isSafeInteger(barrier.ownerGid) ||
(barrier.ownerGid as number) < 0 ||
typeof barrier.legacyDescriptorDigest !== 'string' ||
!/^[0-9a-f]{64}$/.test(barrier.legacyDescriptorDigest) ||
barrier.targetDescriptorDigest !== authorization.targetDescriptorDigest ||
legacyPreObservation.managerKind !== authorization.managerKind ||
legacyPreObservation.serviceName !== 'qinglong' ||
targetPreObservation.managerKind !== authorization.managerKind ||
targetPreObservation.serviceName !== 'qinglong3' ||
!Number.isSafeInteger(barrier.createdAtMs) ||
(barrier.createdAtMs as number) < authorization.requestedAtMs ||
typeof barrierDigest !== 'string' ||
!/^[0-9a-f]{64}$/.test(barrierDigest) ||
digest(payload) !== barrierDigest
) {
configurationError('legacy start barrier drifted');
}
return barrier as unknown as Readonly<LegacyStartBarrier>;
}
function preconditionProved(
observation: Readonly<LocalServiceManagerRollbackObservation>,
expectedPath: string,
): boolean {
return (
observation.fragmentPath === expectedPath &&
observation.loadState === 'loaded' &&
observation.activeState === 'inactive' &&
observation.mainPid === 0
);
}
function finalStateProved(
managerKind: LocalServiceManagerKind,
legacy: Readonly<LocalServiceManagerRollbackObservation>,
target: Readonly<LocalServiceManagerRollbackObservation>,
legacyPath: string,
targetPath: string,
): boolean {
return (
legacy.fragmentPath === legacyPath &&
legacy.loadState === 'loaded' &&
legacy.activeState === 'active' &&
(managerKind === 'openrc' || legacy.mainPid > 0) &&
target.fragmentPath === targetPath &&
target.loadState === 'loaded' &&
target.activeState === 'inactive' &&
target.mainPid === 0
);
}
function executeStart(
manager: LocalServiceBridgeManager,
runner: NonNullable<
LocalServiceManagerLegacyRollbackBridgeDependencies['runManager']
>,
): Readonly<{ failed: boolean; responseLost: boolean }> {
const result =
manager.kind === 'systemd'
? run(runner, manager.executable, ['start', 'qinglong.service'])
: run(runner, manager.serviceExecutable, ['qinglong', 'start']);
return Object.freeze({
failed: !result.responseLost && result.status !== 0,
responseLost: result.responseLost,
});
}
function outcomeRecord(
authorization: Readonly<LocalServiceManagerLegacyStartAuthorization>,
barrier: Readonly<LegacyStartBarrier>,
state: LocalServiceManagerLegacyStartOutcome['state'],
disposition: LocalServiceManagerRollbackMutationDisposition,
manualReason: LocalServiceManagerRollbackManualReason | null,
legacyObservation: Readonly<LocalServiceManagerRollbackObservation>,
targetObservation: Readonly<LocalServiceManagerRollbackObservation>,
now: () => number,
): Readonly<LocalServiceManagerLegacyStartOutcome> {
const payload = Object.freeze({
schema: 'qinglong3-local-service-manager-legacy-start-outcome' as const,
schemaVersion: 1 as const,
state,
cutoverId: authorization.cutoverId,
profile: authorization.profile,
instanceId: authorization.instanceId,
generation: authorization.generation,
activationDigest: authorization.activationDigest,
managerKind: authorization.managerKind,
preparationDigest: authorization.preparationDigest,
authorizationDigest: authorization.authorizationDigest,
barrierDigest: barrier.barrierDigest,
legacyDescriptorDigest: barrier.legacyDescriptorDigest,
targetDescriptorDigest: barrier.targetDescriptorDigest,
mutationDisposition: disposition,
manualReason,
legacyObservation,
targetObservation,
completedAtMs: Math.max(
now(),
legacyObservation.observedAtMs,
targetObservation.observedAtMs,
),
});
return normalizeLocalServiceManagerLegacyStartOutcome({
...payload,
outcomeDigest: localServiceManagerLegacyStartOutcomeDigest(payload),
});
}
function readRootJson(filePath: string, label: string): unknown {
const file = readOwnerPrivateJsonFile(filePath, label);
if (file.uid !== 0 || file.gid !== 0) {
configurationError(`${label} is not root-owned`);
}
return file.value;
}
function publishOutcome(
actionDirectory: string,
ownerOutcomePath: string,
ownerUid: number,
ownerGid: number,
outcome: Readonly<LocalServiceManagerLegacyStartOutcome>,
): 'prepared' | 'existing' {
const contents = `${JSON.stringify(outcome, null, 2)}\n`;
const status = publishServiceBridgeFile(
path.join(actionDirectory, 'outcome.json'),
contents,
0o600,
0,
0,
'legacy start root outcome',
);
publishServiceBridgeFile(
ownerOutcomePath,
contents,
0o600,
ownerUid,
ownerGid,
'legacy start Owner outcome',
);
return status;
}
function result(
command: Readonly<LocalServiceManagerLegacyRollbackBridgeCommand>,
status: 'prepared' | 'existing',
outcome: Readonly<LocalServiceManagerLegacyStartOutcome>,
): Readonly<LocalServiceManagerLegacyRollbackBridgeResult> {
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status,
state: outcome.state,
cutoverId: outcome.cutoverId,
generation: outcome.generation,
outcomeDigest: outcome.outcomeDigest,
});
}
export function runLocalServiceManagerLegacyRollbackBridge(
input: unknown,
dependencies: Readonly<LocalServiceManagerLegacyRollbackBridgeDependencies> = {},
): Readonly<LocalServiceManagerLegacyRollbackBridgeResult> {
assertRootIdentity();
const command =
normalizeLocalServiceManagerLegacyRollbackBridgeCommand(input);
const runner = dependencies.runManager ?? defaultRunManager;
const now = dependencies.now ?? Date.now;
for (const executable of managerExecutables(command.options.manager)) {
trustedRootExecutable(executable, 'service manager executable');
}
const authorizationPath = localServiceManagerLegacyStartAuthorizationPath(
command.options.deploymentRoot,
command.request.cutoverId,
command.request.generation,
);
const ownedAuthorization = readOwnerPrivateJsonFile(
authorizationPath,
'legacy start authorization',
);
const authorization = normalizeLocalServiceManagerLegacyStartAuthorization(
ownedAuthorization.value,
);
if (
authorization.authorizationDigest !==
command.request.expectedAuthorizationDigest ||
authorization.cutoverId !== command.request.cutoverId ||
authorization.generation !== command.request.generation ||
authorization.managerKind !== command.options.manager.kind
) {
configurationError('legacy rollback root authorization drifted');
}
const ownerUid = ownedAuthorization.uid;
const ownerGid = ownedAuthorization.gid;
validateServiceBridgeDirectory(
command.options.deploymentRoot,
ownerUid,
ownerGid,
0o700,
'deploymentRoot',
);
validateServiceBridgeDirectory(
path.join(command.options.deploymentRoot, 'service'),
ownerUid,
ownerGid,
0o700,
'serviceDescriptorRoot',
);
const legacyPath = localServiceManagerLegacyDescriptorPath(
authorization.managerKind,
);
const targetPath = localServiceManagerTargetDescriptorPath(
authorization.managerKind,
);
ensureRootServiceBridgeDirectory(
command.options.controllerRoot,
'serviceBridgeControllerRoot',
);
const rollbackRoot = path.join(
command.options.controllerRoot,
'legacy-rollbacks',
);
ensureRootServiceBridgeDirectory(rollbackRoot, 'legacyRollbackRoot');
const actionDirectory = path.join(
rollbackRoot,
authorization.authorizationDigest,
);
ensureRootServiceBridgeDirectory(actionDirectory, 'legacyRollbackActionRoot');
const barrierPath = path.join(actionDirectory, 'barrier.json');
const rootOutcomePath = path.join(actionDirectory, 'outcome.json');
const ownerOutcomePath = localServiceManagerLegacyStartOutcomePath(
command.options.deploymentRoot,
authorization.cutoverId,
authorization.generation,
);
if (fs.existsSync(rootOutcomePath)) {
const existing = normalizeLocalServiceManagerLegacyStartOutcome(
readRootJson(rootOutcomePath, 'legacy start root outcome'),
);
if (
existing.authorizationDigest !== authorization.authorizationDigest ||
existing.preparationDigest !== authorization.preparationDigest ||
existing.managerKind !== authorization.managerKind
) {
configurationError('legacy start root outcome drifted');
}
publishOutcome(
actionDirectory,
ownerOutcomePath,
ownerUid,
ownerGid,
existing,
);
return result(command, 'existing', existing);
}
const legacyDigest = descriptorDigest(legacyPath, authorization.managerKind);
const targetDigest = descriptorDigest(targetPath, authorization.managerKind);
if (
targetDigest !== authorization.targetDescriptorDigest ||
!ownerMaterialMatches(command, authorization, ownerUid, ownerGid)
) {
configurationError('legacy rollback root preflight material drifted');
}
const replay = fs.existsSync(barrierPath);
const barrier = replay
? normalizeBarrier(
readRootJson(barrierPath, 'legacy start barrier'),
authorization,
)
: barrierRecord(
authorization,
ownerUid,
ownerGid,
legacyDigest,
targetDigest,
inspectManager(
command.options.manager,
'qinglong',
legacyPath,
runner,
now,
),
inspectManager(
command.options.manager,
'qinglong3',
targetPath,
runner,
now,
),
now,
);
if (!replay) {
publishServiceBridgeFile(
barrierPath,
`${JSON.stringify(barrier, null, 2)}\n`,
0o600,
0,
0,
'legacy start barrier',
);
dependencies.afterBarrier?.();
}
let mutation: Readonly<{ failed: boolean; responseLost: boolean }> =
Object.freeze({ failed: false, responseLost: false });
let immediateReason: LocalServiceManagerRollbackManualReason | null = null;
if (
!preconditionProved(barrier.legacyPreObservation, legacyPath) ||
!preconditionProved(barrier.targetPreObservation, targetPath)
) {
immediateReason = 'start_precondition_unproved';
} else if (
descriptorDigest(legacyPath, authorization.managerKind) !==
barrier.legacyDescriptorDigest ||
descriptorDigest(targetPath, authorization.managerKind) !==
barrier.targetDescriptorDigest
) {
immediateReason = 'service_descriptor_drifted';
} else if (
!ownerMaterialMatches(command, authorization, ownerUid, ownerGid)
) {
immediateReason = 'authorization_material_drifted';
} else if (!replay) {
mutation = executeStart(command.options.manager, runner);
dependencies.afterStart?.();
}
const legacyObservation = inspectManager(
command.options.manager,
'qinglong',
legacyPath,
runner,
now,
);
const targetObservation = inspectManager(
command.options.manager,
'qinglong3',
targetPath,
runner,
now,
);
let descriptorDrifted = false;
try {
descriptorDrifted =
descriptorDigest(legacyPath, authorization.managerKind) !==
barrier.legacyDescriptorDigest ||
descriptorDigest(targetPath, authorization.managerKind) !==
barrier.targetDescriptorDigest;
} catch {
descriptorDrifted = true;
}
const proved =
immediateReason === null &&
!descriptorDrifted &&
finalStateProved(
authorization.managerKind,
legacyObservation,
targetObservation,
legacyPath,
targetPath,
);
const disposition: LocalServiceManagerRollbackMutationDisposition = replay
? 'replay-inspected'
: mutation.responseLost
? 'response-loss-inspected'
: 'executed';
const manualReason: LocalServiceManagerRollbackManualReason | null = proved
? null
: immediateReason ??
(descriptorDrifted
? 'service_descriptor_drifted'
: mutation.failed
? 'manager_command_failed'
: 'manager_state_unproved');
const outcome = outcomeRecord(
authorization,
barrier,
proved ? 'legacy_running' : 'manual_required',
disposition,
manualReason,
legacyObservation,
targetObservation,
now,
);
const status = publishOutcome(
actionDirectory,
ownerOutcomePath,
ownerUid,
ownerGid,
outcome,
);
return result(command, status, outcome);
}
export function runLocalServiceManagerLegacyRollbackBridgeCommandFile(
commandFile: string,
dependencies: Readonly<LocalServiceManagerLegacyRollbackBridgeDependencies> = {},
): Readonly<LocalServiceManagerLegacyRollbackBridgeResult> {
return runLocalServiceManagerLegacyRollbackBridge(
readPrivateLocalCommandFile(commandFile),
dependencies,
);
}
@@ -0,0 +1,814 @@
import path from 'node:path';
import {
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
readPrivateLocalJsonFile,
} from '@qinglong/local-command-file';
import {
advanceLocalCutoverInstanceHead,
readLocalCutoverInstanceHead,
} from '../../cutover/instanceLineage';
import { cutoverDigest } from '../../cutover/targetEvidence';
import { currentIdentity } from '../../foundation/contract';
import { LocalDeploymentConfigurationError } from '../../foundation/error';
import {
preflightPublishedFile,
publishExactFile,
} from '../../foundation/files';
import {
localServiceManagerLegacyCompletionPath,
localServiceManagerLegacyStartAuthorizationDigest,
localServiceManagerLegacyStartAuthorizationPath,
localServiceManagerLegacyStartOutcomePath,
normalizeLocalServiceManagerLegacyStartAuthorization,
normalizeLocalServiceManagerLegacyStartOutcome,
type LocalServiceManagerLegacyStartAuthorization,
type LocalServiceManagerLegacyStartOutcome,
} from './contract';
import {
localServiceManagerLegacyRollbackPreparationPath,
normalizeLocalServiceManagerLegacyRollbackPreparation,
prepareLocalServiceManagerLegacyRollback,
type LocalServiceManagerLegacyRollbackPreparation,
} from './preparation';
const COMPLETION_SCHEMA =
'qinglong3-local-service-manager-legacy-rollback-completion';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
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;
interface RollbackIdentity {
readonly cutoverId: string;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly generation: number;
readonly expectedActivationDigest: string;
}
export interface LocalServiceManagerLegacyRollbackAuthorizeCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.legacy-rollback.authorize';
readonly options: Readonly<{
deploymentRoot: string;
allowRootService: boolean;
}>;
readonly request: Readonly<
RollbackIdentity & {
expectedPreparationDigest: string;
expectedInstanceHeadDigest: string;
requestedAtMs: number;
}
>;
}
export interface LocalServiceManagerLegacyRollbackAuthorizeResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.legacy-rollback.authorize';
readonly status: 'prepared' | 'existing';
readonly state: 'legacy_restart_requested';
readonly cutoverId: string;
readonly generation: number;
readonly preparationDigest: string;
readonly authorizationDigest: string;
readonly instanceHeadDigest: string;
}
export interface LocalServiceManagerLegacyRollbackConsumeCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.legacy-rollback.consume';
readonly options: Readonly<{
deploymentRoot: string;
allowRootService: boolean;
}>;
readonly request: Readonly<
RollbackIdentity & {
expectedPreparationDigest: string;
expectedAuthorizationDigest: string;
expectedAuthorizationHeadDigest: string;
requestedAtMs: number;
}
>;
}
export interface LocalServiceManagerLegacyRollbackConsumeResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.legacy-rollback.consume';
readonly status: 'prepared' | 'existing';
readonly state: 'legacy_running' | 'manual_required';
readonly cutoverId: string;
readonly generation: number;
readonly outcomeDigest: string;
readonly completionDigest: string;
readonly instanceHeadDigest: string;
}
export interface LocalServiceManagerLegacyRollbackCompletion {
readonly schema: typeof COMPLETION_SCHEMA;
readonly schemaVersion: 1;
readonly state: 'legacy_running' | 'manual_required';
readonly cutoverId: string;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly generation: number;
readonly activationDigest: string;
readonly preparationDigest: string;
readonly authorizationDigest: string;
readonly expectedAuthorizationHeadDigest: string;
readonly rootOutcomeDigest: string;
readonly rootBarrierDigest: string;
readonly legacyDescriptorDigest: string;
readonly targetDescriptorDigest: string;
readonly legacyObservationDigest: string;
readonly targetObservationDigest: string;
readonly manualReason: LocalServiceManagerLegacyStartOutcome['manualReason'];
readonly completedAtMs: number;
readonly completionDigest: 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 normalizeIdentity(
value: Record<string, unknown>,
): Readonly<RollbackIdentity> {
if (
typeof value.cutoverId !== 'string' ||
!CUTOVER_ID_PATTERN.test(value.cutoverId) ||
(value.profile !== 'edge' && value.profile !== 'standalone') ||
typeof value.instanceId !== 'string' ||
!INSTANCE_ID_PATTERN.test(value.instanceId) ||
!Number.isSafeInteger(value.generation) ||
(value.generation as number) < 1 ||
(value.generation as number) > MAX_GENERATION ||
typeof value.expectedActivationDigest !== 'string' ||
!DIGEST_PATTERN.test(value.expectedActivationDigest)
) {
configurationError('legacy rollback identity is invalid');
}
return Object.freeze({
cutoverId: value.cutoverId,
profile: value.profile,
instanceId: value.instanceId,
generation: value.generation as number,
expectedActivationDigest: value.expectedActivationDigest,
});
}
function normalizeAuthorizeCommand(
value: unknown,
): Readonly<LocalServiceManagerLegacyRollbackAuthorizeCommand> {
const command = object(value, 'legacy rollback authorize 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',
'expectedPreparationDigest',
'generation',
'instanceId',
'profile',
'requestedAtMs',
],
'request',
);
const identity = currentIdentity();
const rollbackIdentity = normalizeIdentity(request);
if (
command.schemaVersion !== 1 ||
command.operation !==
'local.deployment.service-manager.legacy-rollback.authorize' ||
typeof options.allowRootService !== 'boolean' ||
(identity.uid === 0) !== options.allowRootService ||
typeof request.expectedPreparationDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedPreparationDigest) ||
typeof request.expectedInstanceHeadDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedInstanceHeadDigest) ||
!Number.isSafeInteger(request.requestedAtMs) ||
(request.requestedAtMs as number) < 0
) {
configurationError('legacy rollback authorize command is invalid');
}
return Object.freeze({
schemaVersion: 1 as const,
operation:
'local.deployment.service-manager.legacy-rollback.authorize' as const,
options: Object.freeze({
deploymentRoot: safeAbsolutePath(
options.deploymentRoot,
'deploymentRoot',
),
allowRootService: options.allowRootService,
}),
request: Object.freeze({
...rollbackIdentity,
expectedPreparationDigest: request.expectedPreparationDigest,
expectedInstanceHeadDigest: request.expectedInstanceHeadDigest,
requestedAtMs: request.requestedAtMs as number,
}),
});
}
function normalizeConsumeCommand(
value: unknown,
): Readonly<LocalServiceManagerLegacyRollbackConsumeCommand> {
const command = object(value, 'legacy rollback consume 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',
'expectedAuthorizationDigest',
'expectedAuthorizationHeadDigest',
'expectedPreparationDigest',
'generation',
'instanceId',
'profile',
'requestedAtMs',
],
'request',
);
const identity = currentIdentity();
const rollbackIdentity = normalizeIdentity(request);
if (
command.schemaVersion !== 1 ||
command.operation !==
'local.deployment.service-manager.legacy-rollback.consume' ||
typeof options.allowRootService !== 'boolean' ||
(identity.uid === 0) !== options.allowRootService ||
typeof request.expectedPreparationDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedPreparationDigest) ||
typeof request.expectedAuthorizationDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedAuthorizationDigest) ||
typeof request.expectedAuthorizationHeadDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedAuthorizationHeadDigest) ||
!Number.isSafeInteger(request.requestedAtMs) ||
(request.requestedAtMs as number) < 0
) {
configurationError('legacy rollback consume command is invalid');
}
return Object.freeze({
schemaVersion: 1 as const,
operation:
'local.deployment.service-manager.legacy-rollback.consume' as const,
options: Object.freeze({
deploymentRoot: safeAbsolutePath(
options.deploymentRoot,
'deploymentRoot',
),
allowRootService: options.allowRootService,
}),
request: Object.freeze({
...rollbackIdentity,
expectedPreparationDigest: request.expectedPreparationDigest,
expectedAuthorizationDigest: request.expectedAuthorizationDigest,
expectedAuthorizationHeadDigest: request.expectedAuthorizationHeadDigest,
requestedAtMs: request.requestedAtMs as number,
}),
});
}
function readPreparation(
deploymentRoot: string,
identity: Readonly<RollbackIdentity>,
): Readonly<LocalServiceManagerLegacyRollbackPreparation> {
return normalizeLocalServiceManagerLegacyRollbackPreparation(
readPrivateLocalJsonFile(
localServiceManagerLegacyRollbackPreparationPath(
deploymentRoot,
identity.cutoverId,
identity.generation,
),
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
),
);
}
function assertPreparationBinding(
preparation: Readonly<LocalServiceManagerLegacyRollbackPreparation>,
identity: Readonly<RollbackIdentity>,
expectedPreparationDigest: string,
): void {
if (
preparation.cutoverId !== identity.cutoverId ||
preparation.profile !== identity.profile ||
preparation.instanceId !== identity.instanceId ||
preparation.generation !== identity.generation ||
preparation.activationDigest !== identity.expectedActivationDigest ||
preparation.preparationDigest !== expectedPreparationDigest
) {
configurationError('legacy rollback preparation binding drifted');
}
}
function authorizationRecord(
command: Readonly<LocalServiceManagerLegacyRollbackAuthorizeCommand>,
preparation: Readonly<LocalServiceManagerLegacyRollbackPreparation>,
): Readonly<LocalServiceManagerLegacyStartAuthorization> {
const payload = Object.freeze({
schema:
'qinglong3-local-service-manager-legacy-start-authorization' as const,
schemaVersion: 1 as const,
state: 'legacy_restart_requested' as const,
cutoverId: command.request.cutoverId,
profile: command.request.profile,
instanceId: command.request.instanceId,
generation: command.request.generation,
activationDigest: command.request.expectedActivationDigest,
managerKind: preparation.managerKind,
expectedInstanceHeadDigest: command.request.expectedInstanceHeadDigest,
preparationDigest: preparation.preparationDigest,
reconciliationEvidenceDigest: preparation.reconciliation.evidenceDigest,
applicationConfigDigest: preparation.applicationConfigDigest,
commitmentFileDigest: preparation.commitmentFileDigest,
targetDescriptorDigest: preparation.targetDescriptorDigest,
requestedAtMs: command.request.requestedAtMs,
});
return normalizeLocalServiceManagerLegacyStartAuthorization({
...payload,
authorizationDigest:
localServiceManagerLegacyStartAuthorizationDigest(payload),
});
}
function authorizeResult(
command: Readonly<LocalServiceManagerLegacyRollbackAuthorizeCommand>,
status: 'prepared' | 'existing',
preparationDigest: string,
authorizationDigest: string,
instanceHeadDigest: string,
): Readonly<LocalServiceManagerLegacyRollbackAuthorizeResult> {
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status,
state: 'legacy_restart_requested' as const,
cutoverId: command.request.cutoverId,
generation: command.request.generation,
preparationDigest,
authorizationDigest,
instanceHeadDigest,
});
}
export function authorizeLocalServiceManagerLegacyRollback(
input: unknown,
): Readonly<LocalServiceManagerLegacyRollbackAuthorizeResult> {
const command = normalizeAuthorizeCommand(input);
const identity = currentIdentity();
const preparation = readPreparation(
command.options.deploymentRoot,
command.request,
);
assertPreparationBinding(
preparation,
command.request,
command.request.expectedPreparationDigest,
);
const authorization = authorizationRecord(command, preparation);
const authorizationPath = localServiceManagerLegacyStartAuthorizationPath(
command.options.deploymentRoot,
command.request.cutoverId,
command.request.generation,
);
const head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
command.request.instanceId,
identity.uid,
);
if (head.state === 'legacy_restart_requested') {
const existing = normalizeLocalServiceManagerLegacyStartAuthorization(
readPrivateLocalJsonFile(authorizationPath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
}),
);
if (
existing.authorizationDigest !== authorization.authorizationDigest ||
head.sourceRecordDigest !== existing.authorizationDigest ||
head.previousHeadDigest !== command.request.expectedInstanceHeadDigest
) {
configurationError('legacy rollback authorization replay drifted');
}
return authorizeResult(
command,
'existing',
preparation.preparationDigest,
existing.authorizationDigest,
head.headDigest,
);
}
if (
head.state !== 'rollback_prepared' ||
head.headDigest !== command.request.expectedInstanceHeadDigest ||
head.sourceRecordDigest !== preparation.preparationDigest ||
head.profile !== command.request.profile ||
head.cutoverId !== command.request.cutoverId ||
head.activationDigest !== command.request.expectedActivationDigest ||
head.generation !== command.request.generation ||
preparation.requestedAtMs > command.request.requestedAtMs
) {
configurationError('legacy rollback authorization lost the instance head');
}
const revalidated = prepareLocalServiceManagerLegacyRollback({
schemaVersion: 1,
operation: 'local.deployment.service-manager.legacy-rollback.prepare',
options: command.options,
request: {
cutoverId: preparation.cutoverId,
profile: preparation.profile,
instanceId: preparation.instanceId,
generation: preparation.generation,
expectedActivationDigest: preparation.activationDigest,
expectedStoppedRecordDigest: preparation.stoppedRecordDigest,
expectedInstanceHeadDigest: preparation.expectedInstanceHeadDigest,
requestedAtMs: preparation.requestedAtMs,
},
});
if (
revalidated.status !== 'existing' ||
revalidated.state !== 'rollback_prepared' ||
revalidated.preparationDigest !== preparation.preparationDigest
) {
configurationError('legacy rollback preparation cannot authorize start');
}
const contents = `${JSON.stringify(authorization, null, 2)}\n`;
preflightPublishedFile(
authorizationPath,
contents,
0o600,
identity.uid,
'legacy start authorization',
);
const status = publishExactFile(
authorizationPath,
contents,
0o600,
identity.uid,
'legacy start authorization',
);
const next = advanceLocalCutoverInstanceHead(
{
options: { deploymentRoot: command.options.deploymentRoot },
request: command.request,
},
identity.uid,
'legacy_restart_requested',
command.request.generation,
authorization.authorizationDigest,
);
return authorizeResult(
command,
status,
preparation.preparationDigest,
authorization.authorizationDigest,
next.headDigest,
);
}
function completionRecord(
command: Readonly<LocalServiceManagerLegacyRollbackConsumeCommand>,
outcome: Readonly<LocalServiceManagerLegacyStartOutcome>,
): Readonly<LocalServiceManagerLegacyRollbackCompletion> {
const payload = Object.freeze({
schema: COMPLETION_SCHEMA,
schemaVersion: 1 as const,
state: outcome.state,
cutoverId: command.request.cutoverId,
profile: command.request.profile,
instanceId: command.request.instanceId,
generation: command.request.generation,
activationDigest: command.request.expectedActivationDigest,
preparationDigest: command.request.expectedPreparationDigest,
authorizationDigest: command.request.expectedAuthorizationDigest,
expectedAuthorizationHeadDigest:
command.request.expectedAuthorizationHeadDigest,
rootOutcomeDigest: outcome.outcomeDigest,
rootBarrierDigest: outcome.barrierDigest,
legacyDescriptorDigest: outcome.legacyDescriptorDigest,
targetDescriptorDigest: outcome.targetDescriptorDigest,
legacyObservationDigest: outcome.legacyObservation.observationDigest,
targetObservationDigest: outcome.targetObservation.observationDigest,
manualReason: outcome.manualReason,
completedAtMs: Math.max(
command.request.requestedAtMs,
outcome.completedAtMs,
),
});
return Object.freeze({
...payload,
completionDigest: cutoverDigest(payload),
});
}
export function normalizeLocalServiceManagerLegacyRollbackCompletion(
value: unknown,
): Readonly<LocalServiceManagerLegacyRollbackCompletion> {
const record = object(value, 'legacy rollback completion');
exact(
record,
[
'activationDigest',
'authorizationDigest',
'completedAtMs',
'completionDigest',
'cutoverId',
'expectedAuthorizationHeadDigest',
'generation',
'instanceId',
'legacyDescriptorDigest',
'legacyObservationDigest',
'manualReason',
'preparationDigest',
'profile',
'rootBarrierDigest',
'rootOutcomeDigest',
'schema',
'schemaVersion',
'state',
'targetDescriptorDigest',
'targetObservationDigest',
],
'legacy rollback completion',
);
const { completionDigest, ...payload } = record;
const digests = [
record.activationDigest,
record.preparationDigest,
record.authorizationDigest,
record.expectedAuthorizationHeadDigest,
record.rootOutcomeDigest,
record.rootBarrierDigest,
record.legacyDescriptorDigest,
record.targetDescriptorDigest,
record.legacyObservationDigest,
record.targetObservationDigest,
];
if (
record.schema !== COMPLETION_SCHEMA ||
record.schemaVersion !== 1 ||
(record.state !== 'legacy_running' && record.state !== 'manual_required') ||
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 ||
digests.some(
(candidate) =>
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
) ||
(record.manualReason !== null &&
record.manualReason !== 'start_precondition_unproved' &&
record.manualReason !== 'authorization_material_drifted' &&
record.manualReason !== 'manager_command_failed' &&
record.manualReason !== 'manager_state_unproved' &&
record.manualReason !== 'service_descriptor_drifted') ||
(record.state === 'manual_required') !== (record.manualReason !== null) ||
!Number.isSafeInteger(record.completedAtMs) ||
(record.completedAtMs as number) < 0 ||
typeof completionDigest !== 'string' ||
!DIGEST_PATTERN.test(completionDigest) ||
cutoverDigest(payload) !== completionDigest
) {
configurationError('legacy rollback completion drifted');
}
return record as unknown as Readonly<LocalServiceManagerLegacyRollbackCompletion>;
}
function consumeResult(
command: Readonly<LocalServiceManagerLegacyRollbackConsumeCommand>,
status: 'prepared' | 'existing',
completion: Readonly<LocalServiceManagerLegacyRollbackCompletion>,
instanceHeadDigest: string,
): Readonly<LocalServiceManagerLegacyRollbackConsumeResult> {
return Object.freeze({
schemaVersion: 1 as const,
operation: command.operation,
status,
state: completion.state,
cutoverId: completion.cutoverId,
generation: completion.generation,
outcomeDigest: completion.rootOutcomeDigest,
completionDigest: completion.completionDigest,
instanceHeadDigest,
});
}
export function consumeLocalServiceManagerLegacyRollback(
input: unknown,
): Readonly<LocalServiceManagerLegacyRollbackConsumeResult> {
const command = normalizeConsumeCommand(input);
const identity = currentIdentity();
const preparation = readPreparation(
command.options.deploymentRoot,
command.request,
);
assertPreparationBinding(
preparation,
command.request,
command.request.expectedPreparationDigest,
);
const authorization = normalizeLocalServiceManagerLegacyStartAuthorization(
readPrivateLocalJsonFile(
localServiceManagerLegacyStartAuthorizationPath(
command.options.deploymentRoot,
command.request.cutoverId,
command.request.generation,
),
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
),
);
if (
authorization.cutoverId !== command.request.cutoverId ||
authorization.profile !== command.request.profile ||
authorization.instanceId !== command.request.instanceId ||
authorization.generation !== command.request.generation ||
authorization.activationDigest !==
command.request.expectedActivationDigest ||
authorization.preparationDigest !== preparation.preparationDigest ||
authorization.authorizationDigest !==
command.request.expectedAuthorizationDigest
) {
configurationError('legacy start authorization binding drifted');
}
const head = readLocalCutoverInstanceHead(
command.options.deploymentRoot,
command.request.instanceId,
identity.uid,
);
const completionPath = localServiceManagerLegacyCompletionPath(
command.options.deploymentRoot,
command.request.cutoverId,
command.request.generation,
);
if (head.state === 'legacy_running' || head.state === 'manual_required') {
const existing = normalizeLocalServiceManagerLegacyRollbackCompletion(
readPrivateLocalJsonFile(completionPath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
}),
);
if (
existing.state !== head.state ||
existing.authorizationDigest !== authorization.authorizationDigest ||
existing.expectedAuthorizationHeadDigest !==
command.request.expectedAuthorizationHeadDigest ||
head.sourceRecordDigest !== existing.completionDigest ||
head.previousHeadDigest !==
command.request.expectedAuthorizationHeadDigest
) {
configurationError('legacy rollback completion replay drifted');
}
return consumeResult(command, 'existing', existing, head.headDigest);
}
if (
head.state !== 'legacy_restart_requested' ||
head.headDigest !== command.request.expectedAuthorizationHeadDigest ||
head.sourceRecordDigest !== authorization.authorizationDigest ||
head.profile !== command.request.profile ||
head.cutoverId !== command.request.cutoverId ||
head.activationDigest !== command.request.expectedActivationDigest ||
head.generation !== command.request.generation ||
authorization.requestedAtMs > command.request.requestedAtMs
) {
configurationError('legacy rollback consume lost the instance head');
}
const outcome = normalizeLocalServiceManagerLegacyStartOutcome(
readPrivateLocalJsonFile(
localServiceManagerLegacyStartOutcomePath(
command.options.deploymentRoot,
command.request.cutoverId,
command.request.generation,
),
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
),
);
if (
outcome.cutoverId !== command.request.cutoverId ||
outcome.profile !== command.request.profile ||
outcome.instanceId !== command.request.instanceId ||
outcome.generation !== command.request.generation ||
outcome.activationDigest !== command.request.expectedActivationDigest ||
outcome.managerKind !== preparation.managerKind ||
outcome.preparationDigest !== preparation.preparationDigest ||
outcome.authorizationDigest !== authorization.authorizationDigest ||
outcome.targetDescriptorDigest !== preparation.targetDescriptorDigest ||
outcome.completedAtMs > command.request.requestedAtMs
) {
configurationError('legacy start outcome binding drifted');
}
const completion = completionRecord(command, outcome);
const contents = `${JSON.stringify(completion, null, 2)}\n`;
preflightPublishedFile(
completionPath,
contents,
0o600,
identity.uid,
'legacy rollback completion',
);
const status = publishExactFile(
completionPath,
contents,
0o600,
identity.uid,
'legacy rollback completion',
);
const next = advanceLocalCutoverInstanceHead(
{
options: { deploymentRoot: command.options.deploymentRoot },
request: command.request,
},
identity.uid,
completion.state,
command.request.generation,
completion.completionDigest,
);
return consumeResult(command, status, completion, next.headDigest);
}
export function authorizeLocalServiceManagerLegacyRollbackCommandFile(
filePath: string,
): Readonly<LocalServiceManagerLegacyRollbackAuthorizeResult> {
return authorizeLocalServiceManagerLegacyRollback(
readPrivateLocalJsonFile(filePath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
}),
);
}
export function consumeLocalServiceManagerLegacyRollbackCommandFile(
filePath: string,
): Readonly<LocalServiceManagerLegacyRollbackConsumeResult> {
return consumeLocalServiceManagerLegacyRollback(
readPrivateLocalJsonFile(filePath, {
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
}),
);
}
@@ -0,0 +1,575 @@
import crypto from 'node:crypto';
import path from 'node:path';
import { LocalDeploymentConfigurationError } from '../../foundation/error';
import type {
LocalServiceBridgeManager,
LocalServiceManagerKind,
} from '../serviceBridgeContract';
const AUTHORIZATION_SCHEMA =
'qinglong3-local-service-manager-legacy-start-authorization';
const OUTCOME_SCHEMA = 'qinglong3-local-service-manager-legacy-start-outcome';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
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;
const MAX_PID = 0x7fffffff;
export type LocalServiceManagerRollbackMutationDisposition =
| 'executed'
| 'response-loss-inspected'
| 'replay-inspected';
export type LocalServiceManagerRollbackManualReason =
| 'start_precondition_unproved'
| 'authorization_material_drifted'
| 'manager_command_failed'
| 'manager_state_unproved'
| 'service_descriptor_drifted';
export interface LocalServiceManagerLegacyStartAuthorization {
readonly schema: typeof AUTHORIZATION_SCHEMA;
readonly schemaVersion: 1;
readonly state: 'legacy_restart_requested';
readonly cutoverId: string;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly generation: number;
readonly activationDigest: string;
readonly managerKind: LocalServiceManagerKind;
readonly expectedInstanceHeadDigest: string;
readonly preparationDigest: string;
readonly reconciliationEvidenceDigest: string;
readonly applicationConfigDigest: string;
readonly commitmentFileDigest: string;
readonly targetDescriptorDigest: string;
readonly requestedAtMs: number;
readonly authorizationDigest: string;
}
export interface LocalServiceManagerRollbackObservation {
readonly managerKind: LocalServiceManagerKind;
readonly serviceName: 'qinglong' | 'qinglong3';
readonly fragmentPath: string;
readonly loadState: 'loaded' | 'not-found' | 'unknown';
readonly activeState: 'active' | 'inactive' | 'failed' | 'unknown';
readonly subState: string;
readonly enabledState: 'enabled' | 'disabled' | 'static' | 'unknown';
readonly mainPid: number;
readonly observedAtMs: number;
readonly observationDigest: string;
}
export interface LocalServiceManagerLegacyStartOutcome {
readonly schema: typeof OUTCOME_SCHEMA;
readonly schemaVersion: 1;
readonly state: 'legacy_running' | 'manual_required';
readonly cutoverId: string;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly generation: number;
readonly activationDigest: string;
readonly managerKind: LocalServiceManagerKind;
readonly preparationDigest: string;
readonly authorizationDigest: string;
readonly barrierDigest: string;
readonly legacyDescriptorDigest: string;
readonly targetDescriptorDigest: string;
readonly mutationDisposition: LocalServiceManagerRollbackMutationDisposition;
readonly manualReason: LocalServiceManagerRollbackManualReason | null;
readonly legacyObservation: Readonly<LocalServiceManagerRollbackObservation>;
readonly targetObservation: Readonly<LocalServiceManagerRollbackObservation>;
readonly completedAtMs: number;
readonly outcomeDigest: string;
}
export interface LocalServiceManagerLegacyRollbackBridgeCommand {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.legacy-rollback.execute';
readonly options: Readonly<{
deploymentRoot: string;
controllerRoot: string;
allowRootController: true;
manager: LocalServiceBridgeManager;
}>;
readonly request: Readonly<{
cutoverId: string;
generation: number;
expectedAuthorizationDigest: string;
}>;
}
export interface LocalServiceManagerLegacyRollbackBridgeResult {
readonly schemaVersion: 1;
readonly operation: 'local.deployment.service-manager.legacy-rollback.execute';
readonly status: 'prepared' | 'existing';
readonly state: LocalServiceManagerLegacyStartOutcome['state'];
readonly cutoverId: string;
readonly generation: number;
readonly outcomeDigest: string;
}
function configurationError(message: string): never {
throw new LocalDeploymentConfigurationError(message);
}
function object(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
configurationError(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exact(
value: Record<string, unknown>,
keys: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
configurationError(`${label} shape is invalid`);
}
}
function digest(value: unknown): string {
return crypto
.createHash('sha256')
.update(JSON.stringify(value))
.digest('hex');
}
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 normalizedManager(value: unknown): LocalServiceBridgeManager {
const manager = object(value, 'manager');
if (manager.kind === 'systemd') {
exact(manager, ['executable', 'kind'], 'manager');
return Object.freeze({
kind: 'systemd' as const,
executable: safeAbsolutePath(manager.executable, 'systemd executable'),
});
}
exact(manager, ['kind', 'serviceExecutable', 'updateExecutable'], 'manager');
if (manager.kind !== 'openrc') configurationError('manager kind is invalid');
return Object.freeze({
kind: 'openrc' as const,
serviceExecutable: safeAbsolutePath(
manager.serviceExecutable,
'OpenRC service executable',
),
updateExecutable: safeAbsolutePath(
manager.updateExecutable,
'OpenRC update executable',
),
});
}
export function localServiceManagerLegacyStartAuthorizationPath(
deploymentRoot: string,
cutoverId: string,
generation: number,
): string {
return path.join(
deploymentRoot,
'service',
'cutovers',
cutoverId,
`service-manager-g${String(generation).padStart(
2,
'0',
)}-legacy-start-intent.json`,
);
}
export function localServiceManagerLegacyStartOutcomePath(
deploymentRoot: string,
cutoverId: string,
generation: number,
): string {
return path.join(
deploymentRoot,
'service',
'cutovers',
cutoverId,
`service-manager-g${String(generation).padStart(
2,
'0',
)}-legacy-start-outcome.json`,
);
}
export function localServiceManagerLegacyCompletionPath(
deploymentRoot: string,
cutoverId: string,
generation: number,
): string {
return path.join(
deploymentRoot,
'service',
'cutovers',
cutoverId,
`service-manager-g${String(generation).padStart(
2,
'0',
)}-legacy-completion.json`,
);
}
export function localServiceManagerLegacyDescriptorPath(
kind: LocalServiceManagerKind,
): string {
return kind === 'systemd'
? '/etc/systemd/system/qinglong.service'
: '/etc/init.d/qinglong';
}
export function localServiceManagerTargetDescriptorPath(
kind: LocalServiceManagerKind,
): string {
return kind === 'systemd'
? '/etc/systemd/system/qinglong3.service'
: '/etc/init.d/qinglong3';
}
export function localServiceManagerLegacyStartAuthorizationDigest(
value: Omit<
LocalServiceManagerLegacyStartAuthorization,
'authorizationDigest'
>,
): string {
return digest(value);
}
export function normalizeLocalServiceManagerLegacyStartAuthorization(
value: unknown,
): Readonly<LocalServiceManagerLegacyStartAuthorization> {
const record = object(value, 'legacy start authorization');
exact(
record,
[
'activationDigest',
'applicationConfigDigest',
'authorizationDigest',
'commitmentFileDigest',
'cutoverId',
'expectedInstanceHeadDigest',
'generation',
'instanceId',
'managerKind',
'preparationDigest',
'profile',
'reconciliationEvidenceDigest',
'requestedAtMs',
'schema',
'schemaVersion',
'state',
'targetDescriptorDigest',
],
'legacy start authorization',
);
const { authorizationDigest, ...payload } = record;
if (
record.schema !== AUTHORIZATION_SCHEMA ||
record.schemaVersion !== 1 ||
record.state !== 'legacy_restart_requested' ||
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) ||
(record.managerKind !== 'systemd' && record.managerKind !== 'openrc') ||
typeof record.expectedInstanceHeadDigest !== 'string' ||
!DIGEST_PATTERN.test(record.expectedInstanceHeadDigest) ||
typeof record.preparationDigest !== 'string' ||
!DIGEST_PATTERN.test(record.preparationDigest) ||
typeof record.reconciliationEvidenceDigest !== 'string' ||
!DIGEST_PATTERN.test(record.reconciliationEvidenceDigest) ||
typeof record.applicationConfigDigest !== 'string' ||
!DIGEST_PATTERN.test(record.applicationConfigDigest) ||
typeof record.commitmentFileDigest !== 'string' ||
!DIGEST_PATTERN.test(record.commitmentFileDigest) ||
typeof record.targetDescriptorDigest !== 'string' ||
!DIGEST_PATTERN.test(record.targetDescriptorDigest) ||
!Number.isSafeInteger(record.requestedAtMs) ||
(record.requestedAtMs as number) < 0 ||
typeof authorizationDigest !== 'string' ||
!DIGEST_PATTERN.test(authorizationDigest) ||
digest(payload) !== authorizationDigest
) {
configurationError('legacy start authorization drifted');
}
return record as unknown as Readonly<LocalServiceManagerLegacyStartAuthorization>;
}
export function localServiceManagerRollbackObservationDigest(
value: Omit<LocalServiceManagerRollbackObservation, 'observationDigest'>,
): string {
return digest(value);
}
export function normalizeLocalServiceManagerRollbackObservation(
value: unknown,
): Readonly<LocalServiceManagerRollbackObservation> {
const observation = object(value, 'legacy rollback observation');
exact(
observation,
[
'activeState',
'enabledState',
'fragmentPath',
'loadState',
'mainPid',
'managerKind',
'observationDigest',
'observedAtMs',
'serviceName',
'subState',
],
'legacy rollback observation',
);
const payload = Object.freeze({
managerKind: observation.managerKind,
serviceName: observation.serviceName,
fragmentPath: safeAbsolutePath(observation.fragmentPath, 'fragmentPath'),
loadState: observation.loadState,
activeState: observation.activeState,
subState: observation.subState,
enabledState: observation.enabledState,
mainPid: observation.mainPid,
observedAtMs: observation.observedAtMs,
});
if (
(observation.managerKind !== 'systemd' &&
observation.managerKind !== 'openrc') ||
(observation.serviceName !== 'qinglong' &&
observation.serviceName !== 'qinglong3') ||
(observation.loadState !== 'loaded' &&
observation.loadState !== 'not-found' &&
observation.loadState !== 'unknown') ||
(observation.activeState !== 'active' &&
observation.activeState !== 'inactive' &&
observation.activeState !== 'failed' &&
observation.activeState !== 'unknown') ||
typeof observation.subState !== 'string' ||
Buffer.byteLength(observation.subState, 'utf8') > 128 ||
(observation.enabledState !== 'enabled' &&
observation.enabledState !== 'disabled' &&
observation.enabledState !== 'static' &&
observation.enabledState !== 'unknown') ||
!Number.isSafeInteger(observation.mainPid) ||
(observation.mainPid as number) < 0 ||
(observation.mainPid as number) > MAX_PID ||
!Number.isSafeInteger(observation.observedAtMs) ||
(observation.observedAtMs as number) < 0 ||
typeof observation.observationDigest !== 'string' ||
!DIGEST_PATTERN.test(observation.observationDigest) ||
digest(payload) !== observation.observationDigest
) {
configurationError('legacy rollback observation is invalid');
}
return Object.freeze({
...(payload as Omit<
LocalServiceManagerRollbackObservation,
'observationDigest'
>),
observationDigest: observation.observationDigest,
});
}
export function localServiceManagerLegacyStartOutcomeDigest(
value: Omit<LocalServiceManagerLegacyStartOutcome, 'outcomeDigest'>,
): string {
return digest(value);
}
export function normalizeLocalServiceManagerLegacyStartOutcome(
value: unknown,
): Readonly<LocalServiceManagerLegacyStartOutcome> {
const record = object(value, 'legacy start outcome');
exact(
record,
[
'activationDigest',
'authorizationDigest',
'barrierDigest',
'completedAtMs',
'cutoverId',
'generation',
'instanceId',
'legacyDescriptorDigest',
'legacyObservation',
'managerKind',
'manualReason',
'mutationDisposition',
'outcomeDigest',
'preparationDigest',
'profile',
'schema',
'schemaVersion',
'state',
'targetDescriptorDigest',
'targetObservation',
],
'legacy start outcome',
);
const legacyObservation = normalizeLocalServiceManagerRollbackObservation(
record.legacyObservation,
);
const targetObservation = normalizeLocalServiceManagerRollbackObservation(
record.targetObservation,
);
const { outcomeDigest, ...rawPayload } = record;
const payload = {
...rawPayload,
legacyObservation,
targetObservation,
};
if (
record.schema !== OUTCOME_SCHEMA ||
record.schemaVersion !== 1 ||
(record.state !== 'legacy_running' && record.state !== 'manual_required') ||
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) ||
(record.managerKind !== 'systemd' && record.managerKind !== 'openrc') ||
typeof record.preparationDigest !== 'string' ||
!DIGEST_PATTERN.test(record.preparationDigest) ||
typeof record.authorizationDigest !== 'string' ||
!DIGEST_PATTERN.test(record.authorizationDigest) ||
typeof record.barrierDigest !== 'string' ||
!DIGEST_PATTERN.test(record.barrierDigest) ||
typeof record.legacyDescriptorDigest !== 'string' ||
!DIGEST_PATTERN.test(record.legacyDescriptorDigest) ||
typeof record.targetDescriptorDigest !== 'string' ||
!DIGEST_PATTERN.test(record.targetDescriptorDigest) ||
(record.mutationDisposition !== 'executed' &&
record.mutationDisposition !== 'response-loss-inspected' &&
record.mutationDisposition !== 'replay-inspected') ||
(record.manualReason !== null &&
record.manualReason !== 'start_precondition_unproved' &&
record.manualReason !== 'authorization_material_drifted' &&
record.manualReason !== 'manager_command_failed' &&
record.manualReason !== 'manager_state_unproved' &&
record.manualReason !== 'service_descriptor_drifted') ||
(record.state === 'manual_required') !== (record.manualReason !== null) ||
legacyObservation.managerKind !== record.managerKind ||
legacyObservation.serviceName !== 'qinglong' ||
targetObservation.managerKind !== record.managerKind ||
targetObservation.serviceName !== 'qinglong3' ||
!Number.isSafeInteger(record.completedAtMs) ||
(record.completedAtMs as number) < legacyObservation.observedAtMs ||
(record.completedAtMs as number) < targetObservation.observedAtMs ||
typeof outcomeDigest !== 'string' ||
!DIGEST_PATTERN.test(outcomeDigest) ||
digest(payload) !== outcomeDigest
) {
configurationError('legacy start outcome drifted');
}
return Object.freeze({
...(payload as Omit<
LocalServiceManagerLegacyStartOutcome,
'outcomeDigest'
>),
outcomeDigest,
});
}
export function normalizeLocalServiceManagerLegacyRollbackBridgeCommand(
value: unknown,
): Readonly<LocalServiceManagerLegacyRollbackBridgeCommand> {
const command = object(value, 'legacy rollback bridge command');
exact(
command,
['operation', 'options', 'request', 'schemaVersion'],
'legacy rollback bridge command',
);
const options = object(command.options, 'options');
exact(
options,
['allowRootController', 'controllerRoot', 'deploymentRoot', 'manager'],
'options',
);
const request = object(command.request, 'request');
exact(
request,
['cutoverId', 'expectedAuthorizationDigest', 'generation'],
'request',
);
const manager = normalizedManager(options.manager);
if (
command.schemaVersion !== 1 ||
command.operation !==
'local.deployment.service-manager.legacy-rollback.execute' ||
options.allowRootController !== true ||
typeof request.cutoverId !== 'string' ||
!CUTOVER_ID_PATTERN.test(request.cutoverId) ||
!Number.isSafeInteger(request.generation) ||
(request.generation as number) < 1 ||
(request.generation as number) > MAX_GENERATION ||
typeof request.expectedAuthorizationDigest !== 'string' ||
!DIGEST_PATTERN.test(request.expectedAuthorizationDigest)
) {
configurationError('legacy rollback bridge command is invalid');
}
return Object.freeze({
schemaVersion: 1 as const,
operation:
'local.deployment.service-manager.legacy-rollback.execute' as const,
options: Object.freeze({
deploymentRoot: safeAbsolutePath(
options.deploymentRoot,
'deploymentRoot',
),
controllerRoot: safeAbsolutePath(
options.controllerRoot,
'controllerRoot',
),
allowRootController: true as const,
manager,
}),
request: Object.freeze({
cutoverId: request.cutoverId,
generation: request.generation as number,
expectedAuthorizationDigest: request.expectedAuthorizationDigest,
}),
});
}
@@ -1,5 +1,4 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
@@ -11,21 +10,26 @@ import {
import {
advanceLocalCutoverInstanceHead,
readLocalCutoverInstanceHead,
} from '../cutover/instanceLineage';
} 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';
} 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';
type LocalServiceManagerKind,
} from '../serviceBridgeContract';
import { readServiceBridgeFile } from '../serviceBridgeFiles';
import { normalizeLocalServiceManagerCutoverRecord } from '../serviceCutoverJournal';
const SCHEMA = 'qinglong3-local-service-manager-rollback-preparation';
const ZERO_DIGEST = '0'.repeat(64);
@@ -78,11 +82,14 @@ export interface LocalServiceManagerLegacyRollbackPreparation {
readonly instanceId: string;
readonly generation: number;
readonly activationDigest: string;
readonly managerKind: LocalServiceManagerKind;
readonly expectedInstanceHeadDigest: string;
readonly stoppedRecordDigest: string;
readonly stoppedManagerOutcomeDigest: string;
readonly applicationConfigDigest: string;
readonly commitmentDigest: string;
readonly commitmentFileDigest: string;
readonly targetDescriptorDigest: string;
readonly shutdownReceiptDigest: string;
readonly reconciliation: Readonly<TargetDataReconciliationEvidence>;
readonly requestedAtMs: number;
@@ -95,6 +102,7 @@ interface AdoptedPaths {
readonly targetDatabasePath: string;
readonly expectedActivationDigest: string;
readonly commitmentDigest: string;
readonly commitmentFileDigest: string;
}
function configurationError(message: string, cause?: unknown): never {
@@ -253,17 +261,20 @@ export function localServiceManagerLegacyRollbackPreparationPath(
);
}
function applicationSha256(filePath: string): string {
function privateFileSha256(
filePath: string,
uid: number,
gid: number,
label: string,
): string {
let bytes: Buffer | undefined;
try {
return crypto
.createHash('sha256')
.update(fs.readFileSync(filePath))
.digest('hex');
bytes = readServiceBridgeFile(filePath, { uid, gid, mode: 0o600 }, label);
return crypto.createHash('sha256').update(bytes).digest('hex');
} catch (error) {
return configurationError(
'application configuration cannot be hashed',
error,
);
return configurationError(`${label} cannot be hashed`, error);
} finally {
bytes?.fill(0);
}
}
@@ -272,8 +283,17 @@ function adoptedPaths(
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
expectedApplicationDigest: string,
expectedCommitmentDigest: string,
uid: number,
gid: number,
): Readonly<AdoptedPaths> {
if (applicationSha256(applicationPath) !== expectedApplicationDigest) {
if (
privateFileSha256(
applicationPath,
uid,
gid,
'application configuration',
) !== expectedApplicationDigest
) {
configurationError('adopted application configuration digest drifted');
}
const application = object(
@@ -329,6 +349,12 @@ function adoptedPaths(
),
expectedActivationDigest: command.request.expectedActivationDigest,
commitmentDigest: expectedCommitmentDigest,
commitmentFileDigest: privateFileSha256(
commitmentPath,
uid,
gid,
'legacy silence commitment',
),
});
}
@@ -336,6 +362,9 @@ function preparationRecord(
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
stopped: ReturnType<typeof normalizeLocalServiceManagerCutoverRecord>,
reconciliation: Readonly<TargetDataReconciliationEvidence>,
managerKind: LocalServiceManagerKind,
commitmentFileDigest: string,
targetDescriptorDigest: string,
): Readonly<LocalServiceManagerLegacyRollbackPreparation> {
if (
stopped.evidence.shutdownReceiptDigest === null ||
@@ -352,11 +381,14 @@ function preparationRecord(
instanceId: command.request.instanceId,
generation: command.request.generation,
activationDigest: command.request.expectedActivationDigest,
managerKind,
expectedInstanceHeadDigest: command.request.expectedInstanceHeadDigest,
stoppedRecordDigest: stopped.recordDigest,
stoppedManagerOutcomeDigest: stopped.evidence.managerOutcomeDigest,
applicationConfigDigest: stopped.evidence.applicationConfigDigest,
commitmentDigest: stopped.evidence.commitmentDigest,
commitmentFileDigest,
targetDescriptorDigest,
shutdownReceiptDigest: stopped.evidence.shutdownReceiptDigest,
reconciliation,
requestedAtMs: command.request.requestedAtMs,
@@ -377,10 +409,12 @@ export function normalizeLocalServiceManagerLegacyRollbackPreparation(
'activationDigest',
'applicationConfigDigest',
'commitmentDigest',
'commitmentFileDigest',
'cutoverId',
'expectedInstanceHeadDigest',
'generation',
'instanceId',
'managerKind',
'preparationDigest',
'profile',
'reconciliation',
@@ -391,6 +425,7 @@ export function normalizeLocalServiceManagerLegacyRollbackPreparation(
'state',
'stoppedManagerOutcomeDigest',
'stoppedRecordDigest',
'targetDescriptorDigest',
],
'service manager rollback preparation',
);
@@ -413,6 +448,7 @@ export function normalizeLocalServiceManagerLegacyRollbackPreparation(
(record.generation as number) > MAX_GENERATION ||
typeof record.activationDigest !== 'string' ||
!DIGEST_PATTERN.test(record.activationDigest) ||
(record.managerKind !== 'systemd' && record.managerKind !== 'openrc') ||
typeof record.expectedInstanceHeadDigest !== 'string' ||
!DIGEST_PATTERN.test(record.expectedInstanceHeadDigest) ||
typeof record.stoppedRecordDigest !== 'string' ||
@@ -423,6 +459,10 @@ export function normalizeLocalServiceManagerLegacyRollbackPreparation(
!DIGEST_PATTERN.test(record.applicationConfigDigest) ||
typeof record.commitmentDigest !== 'string' ||
!DIGEST_PATTERN.test(record.commitmentDigest) ||
typeof record.commitmentFileDigest !== 'string' ||
!DIGEST_PATTERN.test(record.commitmentFileDigest) ||
typeof record.targetDescriptorDigest !== 'string' ||
!DIGEST_PATTERN.test(record.targetDescriptorDigest) ||
typeof record.shutdownReceiptDigest !== 'string' ||
!DIGEST_PATTERN.test(record.shutdownReceiptDigest) ||
reconciliation.disposition !== 'rollback_candidate' ||
@@ -531,6 +571,8 @@ export function prepareLocalServiceManagerLegacyRollback(
command,
stopped.evidence.applicationConfigDigest,
stopped.evidence.commitmentDigest,
identity.uid,
identity.gid,
);
const reconciliation = readTargetDataReconciliationEvidenceForPaths(
{
@@ -555,7 +597,14 @@ export function prepareLocalServiceManagerLegacyRollback(
head.headDigest,
);
}
const preparation = preparationRecord(command, stopped, reconciliation);
const preparation = preparationRecord(
command,
stopped,
reconciliation,
intent.service.kind,
paths.commitmentFileDigest,
intent.descriptor.sha256,
);
const filePath = localServiceManagerLegacyRollbackPreparationPath(
command.options.deploymentRoot,
command.request.cutoverId,
@@ -1,6 +1,9 @@
#!/usr/bin/env node
import { runLocalServiceBridgeCommandFile } from './serviceBridge';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { runLocalServiceBridge } from './serviceBridge';
import { runLocalServiceManagerLegacyRollbackBridge } from './legacy-rollback/bridge';
const USAGE =
'Usage: ql3-service-bridge run --command-file /absolute/root-owned-command.json';
@@ -21,7 +24,15 @@ function main(argv: readonly string[]): void {
return;
}
try {
const result = runLocalServiceBridgeCommandFile(argv[2]!);
const command = readPrivateLocalCommandFile(argv[2]!);
const operation =
command && typeof command === 'object' && !Array.isArray(command)
? (command as Record<string, unknown>).operation
: undefined;
const result =
operation === 'local.deployment.service-manager.legacy-rollback.execute'
? runLocalServiceManagerLegacyRollbackBridge(command)
: runLocalServiceBridge(command);
process.stdout.write(`${JSON.stringify(result)}\n`);
if (result.state === 'manual_required') process.exitCode = 2;
} catch (error) {
@@ -11,7 +11,16 @@ const {
const {
localServiceManagerLegacyRollbackPreparationPath,
prepareLocalServiceManagerLegacyRollback,
} = require('../dist/deployment/service-manager/serviceLegacyRollback.js');
} = require('../dist/deployment/service-manager/legacy-rollback/preparation.js');
const {
authorizeLocalServiceManagerLegacyRollback,
consumeLocalServiceManagerLegacyRollback,
} = require('../dist/deployment/service-manager/legacy-rollback/consumer.js');
const {
localServiceManagerLegacyStartOutcomeDigest,
localServiceManagerLegacyStartOutcomePath,
localServiceManagerRollbackObservationDigest,
} = require('../dist/deployment/service-manager/legacy-rollback/contract.js');
const {
prepareLocalServiceManagerIntent,
} = require('../dist/deployment/service-manager/serviceManagerIntent.js');
@@ -384,6 +393,126 @@ function rollbackPrepareCommand(state, stoppedResult, head) {
};
}
function rollbackAuthorizeCommand(state, prepared) {
return {
schemaVersion: 1,
operation: 'local.deployment.service-manager.legacy-rollback.authorize',
options: {
deploymentRoot: state.root,
allowRootService: process.getuid() === 0,
},
request: {
cutoverId: state.cutoverId,
profile: 'edge',
instanceId: 'edge-router-1',
generation: 1,
expectedActivationDigest: state.activationDigest,
expectedPreparationDigest: prepared.preparationDigest,
expectedInstanceHeadDigest: prepared.instanceHeadDigest,
requestedAtMs: 1786416000500,
},
};
}
function rollbackObservation(serviceName, active, observedAtMs) {
const payload = {
managerKind: 'systemd',
serviceName,
fragmentPath: `/etc/systemd/system/${serviceName}.service`,
loadState: 'loaded',
activeState: active ? 'active' : 'inactive',
subState: active ? 'running' : 'dead',
enabledState: 'enabled',
mainPid: active ? 5723 : 0,
observedAtMs,
};
return {
...payload,
observationDigest: localServiceManagerRollbackObservationDigest(payload),
};
}
function publishLegacyStartOutcome(
state,
prepared,
authorized,
outcomeState = 'legacy_running',
) {
const preparation = JSON.parse(
fs.readFileSync(
localServiceManagerLegacyRollbackPreparationPath(
state.root,
state.cutoverId,
1,
),
'utf8',
),
);
const legacyObservation = rollbackObservation(
'qinglong',
outcomeState === 'legacy_running',
1786416000600,
);
const targetObservation = rollbackObservation(
'qinglong3',
false,
1786416000601,
);
const payload = {
schema: 'qinglong3-local-service-manager-legacy-start-outcome',
schemaVersion: 1,
state: outcomeState,
cutoverId: state.cutoverId,
profile: 'edge',
instanceId: 'edge-router-1',
generation: 1,
activationDigest: state.activationDigest,
managerKind: 'systemd',
preparationDigest: prepared.preparationDigest,
authorizationDigest: authorized.authorizationDigest,
barrierDigest: '9'.repeat(64),
legacyDescriptorDigest: 'a'.repeat(64),
targetDescriptorDigest: preparation.targetDescriptorDigest,
mutationDisposition: 'executed',
manualReason:
outcomeState === 'legacy_running' ? null : 'manager_state_unproved',
legacyObservation,
targetObservation,
completedAtMs: 1786416000610,
};
const outcome = {
...payload,
outcomeDigest: localServiceManagerLegacyStartOutcomeDigest(payload),
};
writePrivate(
localServiceManagerLegacyStartOutcomePath(state.root, state.cutoverId, 1),
outcome,
);
return outcome;
}
function rollbackConsumeCommand(state, prepared, authorized) {
return {
schemaVersion: 1,
operation: 'local.deployment.service-manager.legacy-rollback.consume',
options: {
deploymentRoot: state.root,
allowRootService: process.getuid() === 0,
},
request: {
cutoverId: state.cutoverId,
profile: 'edge',
instanceId: 'edge-router-1',
generation: 1,
expectedActivationDigest: state.activationDigest,
expectedPreparationDigest: prepared.preparationDigest,
expectedAuthorizationDigest: authorized.authorizationDigest,
expectedAuthorizationHeadDigest: authorized.instanceHeadDigest,
requestedAtMs: 1786416000700,
},
};
}
test('commits adopted service active evidence and replays from the instance head', async (t) => {
const state = fixture(t);
const prepared = prepare(
@@ -574,6 +703,92 @@ test('prepares and exactly replays lossless service-manager legacy rollback evid
assert.equal(replay.instanceHeadDigest, prepared.instanceHeadDigest);
});
test('authorizes, consumes and exactly replays service-manager legacy running evidence', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state, '039');
const prepared = prepareLocalServiceManagerLegacyRollback(
rollbackPrepareCommand(state, stoppedResult, head),
);
const authorizeCommand = rollbackAuthorizeCommand(state, prepared);
const authorized =
authorizeLocalServiceManagerLegacyRollback(authorizeCommand);
assert.equal(authorized.status, 'prepared');
assert.equal(authorized.state, 'legacy_restart_requested');
assert.equal(
authorizeLocalServiceManagerLegacyRollback(authorizeCommand).status,
'existing',
);
const requestedHead = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(requestedHead.state, 'legacy_restart_requested');
assert.equal(
requestedHead.sourceRecordDigest,
authorized.authorizationDigest,
);
const outcome = publishLegacyStartOutcome(state, prepared, authorized);
const consumeCommand = rollbackConsumeCommand(state, prepared, authorized);
const consumed = consumeLocalServiceManagerLegacyRollback(consumeCommand);
assert.equal(consumed.status, 'prepared');
assert.equal(consumed.state, 'legacy_running');
assert.equal(consumed.outcomeDigest, outcome.outcomeDigest);
assert.match(consumed.completionDigest, /^[0-9a-f]{64}$/);
const replay = consumeLocalServiceManagerLegacyRollback(consumeCommand);
assert.equal(replay.status, 'existing');
assert.equal(replay.completionDigest, consumed.completionDigest);
assert.equal(replay.instanceHeadDigest, consumed.instanceHeadDigest);
});
test('requires current rollback evidence before publishing legacy start authorization', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state, '041');
const prepared = prepareLocalServiceManagerLegacyRollback(
rollbackPrepareCommand(state, stoppedResult, head),
);
fs.writeFileSync(state.targetPath, 'target-drift-before-authorization\n', {
mode: 0o600,
});
assert.throws(
() =>
authorizeLocalServiceManagerLegacyRollback(
rollbackAuthorizeCommand(state, prepared),
),
/prepared rollback data drifted after authorization/,
);
const unchanged = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(unchanged.state, 'rollback_prepared');
assert.equal(unchanged.headDigest, prepared.instanceHeadDigest);
});
test('terminalizes an unproved root legacy start outcome', async (t) => {
const state = fixture(t);
const { stoppedResult, head } = await stopAdoptedTarget(state, '043');
const prepared = prepareLocalServiceManagerLegacyRollback(
rollbackPrepareCommand(state, stoppedResult, head),
);
const authorized = authorizeLocalServiceManagerLegacyRollback(
rollbackAuthorizeCommand(state, prepared),
);
publishLegacyStartOutcome(state, prepared, authorized, 'manual_required');
const consumed = consumeLocalServiceManagerLegacyRollback(
rollbackConsumeCommand(state, prepared, authorized),
);
assert.equal(consumed.state, 'manual_required');
const terminal = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(terminal.state, 'manual_required');
assert.equal(terminal.sourceRecordDigest, consumed.completionDigest);
});
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');
@@ -0,0 +1,144 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
localServiceManagerLegacyStartOutcomeDigest,
localServiceManagerRollbackObservationDigest,
normalizeLocalServiceManagerLegacyRollbackBridgeCommand,
normalizeLocalServiceManagerLegacyStartOutcome,
} = require('../dist/deployment/service-manager/legacy-rollback/contract.js');
function command(kind = 'systemd') {
return {
schemaVersion: 1,
operation: 'local.deployment.service-manager.legacy-rollback.execute',
options: {
deploymentRoot: '/var/lib/qinglong3',
controllerRoot: '/var/lib/qinglong3-service-bridge',
allowRootController: true,
manager:
kind === 'systemd'
? { kind, executable: '/usr/bin/systemctl' }
: {
kind,
serviceExecutable: '/sbin/rc-service',
updateExecutable: '/sbin/rc-update',
},
},
request: {
cutoverId: 'edge-router-cutover',
generation: 1,
expectedAuthorizationDigest: '1'.repeat(64),
},
};
}
function observation(serviceName, active, observedAtMs) {
const payload = {
managerKind: 'systemd',
serviceName,
fragmentPath: `/etc/systemd/system/${serviceName}.service`,
loadState: 'loaded',
activeState: active ? 'active' : 'inactive',
subState: active ? 'running' : 'dead',
enabledState: 'enabled',
mainPid: active ? 3101 : 0,
observedAtMs,
};
return {
...payload,
observationDigest: localServiceManagerRollbackObservationDigest(payload),
};
}
function outcome() {
const payload = {
schema: 'qinglong3-local-service-manager-legacy-start-outcome',
schemaVersion: 1,
state: 'legacy_running',
cutoverId: 'edge-router-cutover',
profile: 'edge',
instanceId: 'edge-router-1',
generation: 1,
activationDigest: '2'.repeat(64),
managerKind: 'systemd',
preparationDigest: '3'.repeat(64),
authorizationDigest: '4'.repeat(64),
barrierDigest: '5'.repeat(64),
legacyDescriptorDigest: '6'.repeat(64),
targetDescriptorDigest: '7'.repeat(64),
mutationDisposition: 'response-loss-inspected',
manualReason: null,
legacyObservation: observation('qinglong', true, 100),
targetObservation: observation('qinglong3', false, 101),
completedAtMs: 102,
};
return {
...payload,
outcomeDigest: localServiceManagerLegacyStartOutcomeDigest(payload),
};
}
test('normalizes exact systemd and OpenRC legacy rollback bridge commands', () => {
assert.equal(
normalizeLocalServiceManagerLegacyRollbackBridgeCommand(command()).options
.manager.kind,
'systemd',
);
assert.equal(
normalizeLocalServiceManagerLegacyRollbackBridgeCommand(command('openrc'))
.options.manager.kind,
'openrc',
);
});
test('rejects shell surface, path drift and unbound root acknowledgement', () => {
assert.throws(
() =>
normalizeLocalServiceManagerLegacyRollbackBridgeCommand({
...command(),
shell: 'systemctl start qinglong',
}),
/shape is invalid/,
);
assert.throws(
() =>
normalizeLocalServiceManagerLegacyRollbackBridgeCommand({
...command(),
options: { ...command().options, deploymentRoot: '/var/lib/../tmp' },
}),
/supervisor-safe absolute path/,
);
assert.throws(
() =>
normalizeLocalServiceManagerLegacyRollbackBridgeCommand({
...command(),
options: { ...command().options, allowRootController: false },
}),
/command is invalid/,
);
});
test('binds both service observations and all rollback outcome digests', () => {
const value = outcome();
const normalized = normalizeLocalServiceManagerLegacyStartOutcome(value);
assert.equal(normalized.state, 'legacy_running');
assert.equal(normalized.legacyObservation.serviceName, 'qinglong');
assert.equal(normalized.targetObservation.serviceName, 'qinglong3');
assert.throws(
() =>
normalizeLocalServiceManagerLegacyStartOutcome({
...value,
targetDescriptorDigest: '8'.repeat(64),
}),
/outcome drifted/,
);
assert.throws(
() =>
normalizeLocalServiceManagerLegacyStartOutcome({
...value,
legacyObservation: value.targetObservation,
}),
/outcome drifted/,
);
});