feat(ql3): prove Console adopted cutover entry

This commit is contained in:
whyour
2026-09-01 10:12:32 +08:00
parent 50e6742b72
commit b3421ac9cf
22 changed files with 666 additions and 167 deletions
@@ -23,6 +23,11 @@ export type LocalDeploymentTargetRunOperation =
| 'local.deployment.cutover.target-start'
| 'local.deployment.cutover.target-restart';
export interface LocalDeploymentTargetApiEntry {
readonly configPath: string;
readonly expectedTargetConfigPath: string;
}
export interface LocalDeploymentTargetRunCommand {
readonly schemaVersion: 1;
readonly operation: LocalDeploymentTargetRunOperation;
@@ -49,6 +54,7 @@ export interface LocalDeploymentTargetRunCommand {
targetImage: Readonly<LocalDeploymentTargetImage>;
applicationConfigPath: string;
expectedTargetApplicationConfigPath: string;
readonly targetApi?: Readonly<LocalDeploymentTargetApiEntry>;
expectedTargetCommitmentPath: string;
generation: number;
requestedAtMs: number;
@@ -183,31 +189,49 @@ export function normalizeLocalDeploymentTargetRunCommand(
);
}
const request = object(command.request, 'request');
const requestKeys = [
'activationPath',
'applicationConfigPath',
'cutoverId',
'expectedActivationDigest',
'expectedLegacyCommitmentDigest',
'expectedLegacyContainerId',
'expectedLegacyDatabasePath',
'expectedTargetApplicationConfigPath',
'expectedTargetCommitmentPath',
'expectedTargetContainerId',
'generation',
'instanceId',
'legacySourcePath',
'manifestPath',
'profile',
'recoveryPath',
'requestedAtMs',
'targetDatabasePath',
'targetImage',
];
exact(
request,
[
'activationPath',
'applicationConfigPath',
'cutoverId',
'expectedActivationDigest',
'expectedLegacyCommitmentDigest',
'expectedLegacyContainerId',
'expectedLegacyDatabasePath',
'expectedTargetApplicationConfigPath',
'expectedTargetCommitmentPath',
'expectedTargetContainerId',
'generation',
'instanceId',
'legacySourcePath',
'manifestPath',
'profile',
'recoveryPath',
'requestedAtMs',
'targetDatabasePath',
'targetImage',
],
request.targetApi === undefined
? requestKeys
: [...requestKeys, 'targetApi'],
'request',
);
const targetApi =
request.targetApi === undefined
? undefined
: object(request.targetApi, 'targetApi');
if (targetApi !== undefined) {
exact(targetApi, ['configPath', 'expectedTargetConfigPath'], 'targetApi');
if (
targetApi.expectedTargetConfigPath ===
request.expectedTargetApplicationConfigPath
) {
throw new LocalDeploymentConfigurationError(
'target API and Application configuration paths must be distinct',
);
}
}
const generation = integer(request.generation, 'generation', 1);
if (
typeof request.cutoverId !== 'string' ||
@@ -242,7 +266,8 @@ export function normalizeLocalDeploymentTargetRunCommand(
request.recoveryPath,
request.manifestPath,
request.applicationConfigPath,
]).size !== 6
...(targetApi === undefined ? [] : [targetApi.configPath]),
]).size !== (targetApi === undefined ? 6 : 7)
) {
throw new LocalDeploymentConfigurationError(
'target run authority paths must be distinct',
@@ -301,6 +326,20 @@ export function normalizeLocalDeploymentTargetRunCommand(
request.expectedTargetApplicationConfigPath,
'expectedTargetApplicationConfigPath',
),
...(targetApi === undefined
? {}
: {
targetApi: Object.freeze({
configPath: safeAbsolutePath(
targetApi.configPath,
'targetApi.configPath',
),
expectedTargetConfigPath: safeAbsolutePath(
targetApi.expectedTargetConfigPath,
'targetApi.expectedTargetConfigPath',
),
}),
}),
expectedTargetCommitmentPath: safeAbsolutePath(
request.expectedTargetCommitmentPath,
'expectedTargetCommitmentPath',
@@ -31,6 +31,16 @@ export interface TargetApplicationBinding {
readonly targetManifestPath: string;
readonly legacyDataApplicationCommitDigest: string | null;
readonly legacyDataApplicationReceiptDigest: string | null;
readonly localApi?: Readonly<{
configDigest: string;
targetConfigPath: string;
targetDeploymentRoot: string;
targetOwnerPepperKeyringDirectory: string;
listener: Readonly<{
host: '127.0.0.1' | '::1';
port: number;
}>;
}>;
}
export interface TargetContainerEvidence {
@@ -70,6 +80,71 @@ function textDigest(value: string): string {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
function normalizedAbsolutePath(value: unknown): value is string {
return (
typeof value === 'string' &&
path.isAbsolute(value) &&
path.normalize(value) === value &&
path.parse(value).root !== value
);
}
function readTargetLocalApiBinding(
command: Readonly<LocalDeploymentTargetRunCommand>,
): TargetApplicationBinding['localApi'] {
const targetApi = command.request.targetApi;
if (targetApi === undefined) return undefined;
const config = object(
readPrivateLocalCommandFile(targetApi.configPath),
'target Local API configuration',
);
exact(
config,
[
'applicationConfigFilePath',
'deploymentRoot',
'listener',
'ownerPepperKeyringDirectory',
'schema',
],
'target Local API configuration',
);
const listener = object(config.listener, 'target Local API listener');
exact(listener, ['host', 'port'], 'target Local API listener');
const ownerPepperRelative =
normalizedAbsolutePath(config.deploymentRoot) &&
normalizedAbsolutePath(config.ownerPepperKeyringDirectory)
? path.relative(config.deploymentRoot, config.ownerPepperKeyringDirectory)
: null;
if (
config.schema !== 'qinglong/local-api-process@v1' ||
!normalizedAbsolutePath(config.deploymentRoot) ||
config.applicationConfigFilePath !==
command.request.expectedTargetApplicationConfigPath ||
!normalizedAbsolutePath(config.ownerPepperKeyringDirectory) ||
ownerPepperRelative === null ||
ownerPepperRelative.length === 0 ||
ownerPepperRelative.startsWith('..') ||
path.isAbsolute(ownerPepperRelative) ||
(listener.host !== '127.0.0.1' && listener.host !== '::1') ||
!Number.isSafeInteger(listener.port) ||
(listener.port as number) < 1_024 ||
(listener.port as number) > 65_535
) {
configurationError('target Local API configuration binding is invalid');
}
return Object.freeze({
configDigest: cutoverDigest(config),
targetConfigPath: targetApi.expectedTargetConfigPath,
targetDeploymentRoot: config.deploymentRoot,
targetOwnerPepperKeyringDirectory: config.ownerPepperKeyringDirectory,
listener: Object.freeze({
host: listener.host as '127.0.0.1' | '::1',
port: listener.port as number,
}),
});
}
function object(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
@@ -265,6 +340,7 @@ export function readTargetApplicationBinding(
'target legacy data application configuration',
)
: undefined;
const localApi = readTargetLocalApiBinding(command);
if (legacyDataApplication !== undefined) {
exact(
legacyDataApplication,
@@ -326,6 +402,7 @@ export function readTargetApplicationBinding(
legacyDataApplication === undefined
? null
: (legacyDataApplication.expectedReceiptDigest as string),
...(localApi === undefined ? {} : { localApi }),
});
}
@@ -371,12 +448,11 @@ function mappedMount(
path.join(mount.destination, relative) === targetPath
);
});
if (
matches.length !== 1 ||
matches[0]?.readWrite !== expectedReadWrite
) {
if (matches.length !== 1 || matches[0]?.readWrite !== expectedReadWrite) {
configurationError(
`${label} must have one ${expectedReadWrite ? 'read-write' : 'read-only'} bind mapping`,
`${label} must have one ${
expectedReadWrite ? 'read-write' : 'read-only'
} bind mapping`,
);
}
return matches[0]!;
@@ -540,6 +616,9 @@ export function parseTargetContainerEvidence(
'target container restart policy',
);
const config = object(container.Config, 'target container config');
const expectedEntryConfigPath =
application.localApi?.targetConfigPath ??
command.request.expectedTargetApplicationConfigPath;
const stopped =
state.Running === false &&
state.Restarting === false &&
@@ -569,7 +648,7 @@ export function parseTargetContainerEvidence(
JSON.stringify([
'--cutover-probe',
'--config',
command.request.expectedTargetApplicationConfigPath,
expectedEntryConfigPath,
]) ||
typeof container.Created !== 'string' ||
typeof container.Name !== 'string' ||
@@ -593,6 +672,28 @@ export function parseTargetContainerEvidence(
command.request.expectedTargetApplicationConfigPath,
'target application configuration',
);
const localApiBinding =
application.localApi === undefined ||
command.request.targetApi === undefined
? undefined
: Object.freeze({
configDigest: application.localApi.configDigest,
configMount: mappedMount(
mounts,
command.request.targetApi.configPath,
command.request.targetApi.expectedTargetConfigPath,
'target Local API configuration',
),
deploymentMount: mappedMount(
mounts,
command.options.deploymentRoot,
application.localApi.targetDeploymentRoot,
'target Local API deployment root',
),
listener: application.localApi.listener,
targetOwnerPepperKeyringDirectory:
application.localApi.targetOwnerPepperKeyringDirectory,
});
const activationMount = mappedMount(
mounts,
command.request.activationPath,
@@ -642,6 +743,7 @@ export function parseTargetContainerEvidence(
databaseMount,
recoveryMount,
manifestMount,
...(localApiBinding === undefined ? {} : { localApi: localApiBinding }),
}),
});
}
@@ -109,7 +109,7 @@ function targetInspection(state, options = {}) {
? []
: ['--cutover-probe']),
'--config',
state.targetApplicationConfigPath,
state.targetApiConfigPath ?? state.targetApplicationConfigPath,
],
},
HostConfig: {
@@ -157,7 +157,12 @@ function fixture(t) {
const serviceRoot = path.join(deploymentRoot, 'service');
const cutoverId = 'cutover-edge-1';
const journal = path.join(serviceRoot, 'cutovers', cutoverId);
for (const directory of [deploymentRoot, serviceRoot, path.dirname(journal), journal]) {
for (const directory of [
deploymentRoot,
serviceRoot,
path.dirname(journal),
journal,
]) {
fs.mkdirSync(directory, { mode: 0o700 });
}
const legacySourcePath = path.join(managementRoot, 'database.sqlite');
@@ -261,7 +266,10 @@ function fixture(t) {
);
state.legacyCommitmentDigest = legacy.commitmentDigest;
state.legacyCommitmentPath = path.join(journal, '0002-legacy-stopped.json');
state.applicationConfigPath = path.join(deploymentRoot, 'local-application.json');
state.applicationConfigPath = path.join(
deploymentRoot,
'local-application.json',
);
state.targetApplicationConfigPath = targetPath(
state,
state.applicationConfigPath,
@@ -359,6 +367,29 @@ function prepareAdoptedV4Baseline(state) {
return baseline;
}
function prepareLocalApiTarget(state) {
state.apiConfigPath = path.join(state.deploymentRoot, 'local-api.json');
state.targetApiConfigPath = targetPath(state, state.apiConfigPath);
state.targetApi = {
configPath: state.apiConfigPath,
expectedTargetConfigPath: state.targetApiConfigPath,
};
fs.writeFileSync(
state.apiConfigPath,
`${JSON.stringify({
schema: 'qinglong/local-api-process@v1',
deploymentRoot: targetPath(state, state.deploymentRoot),
applicationConfigFilePath: state.targetApplicationConfigPath,
ownerPepperKeyringDirectory: targetPath(
state,
path.join(state.deploymentRoot, 'owner-peppers'),
),
listener: { host: '127.0.0.1', port: 5700 },
})}\n`,
{ mode: 0o600 },
);
}
function command(state, generation = 1) {
return {
schemaVersion: 1,
@@ -392,8 +423,8 @@ function command(state, generation = 1) {
imageId: state.targetImageId,
},
applicationConfigPath: state.applicationConfigPath,
expectedTargetApplicationConfigPath:
state.targetApplicationConfigPath,
expectedTargetApplicationConfigPath: state.targetApplicationConfigPath,
...(state.targetApi === undefined ? {} : { targetApi: state.targetApi }),
expectedTargetCommitmentPath: state.targetCommitmentPath,
generation,
requestedAtMs: 2_000 + generation,
@@ -500,7 +531,8 @@ function harness(state, options = {}) {
if (args[0] === 'container' && args[1] === 'start') {
if (args[2] === state.legacyContainerId) {
if (options.leaveLegacyStopped !== true) state.legacyRunning = true;
if (options.startTargetWithLegacy === true) state.targetRunning = true;
if (options.startTargetWithLegacy === true)
state.targetRunning = true;
if (options.loseLegacyStartResponse === true) {
throw new Error('simulated lost legacy start response');
}
@@ -561,7 +593,10 @@ function harness(state, options = {}) {
test('starts an exact target once and replays the active commitment without Docker', async (t) => {
const state = fixture(t);
const controller = harness(state);
const active = await runLocalDeploymentDockerTarget(command(state), controller);
const active = await runLocalDeploymentDockerTarget(
command(state),
controller,
);
assert.equal(active.status, 'prepared');
assert.equal(active.state, 'target_active');
assert.equal(active.generation, 1);
@@ -650,6 +685,66 @@ test('starts an offline Trial Kit image only when its local reference and conten
assert.equal(active.state, 'target_active');
});
test('starts an exact Local API cutover probe with both entry and Application configs bound', async (t) => {
const state = fixture(t);
prepareLocalApiTarget(state);
state.targetImageAuthority = 'local-image-id';
state.targetImage = 'qinglong3-local-console:ci-amd64';
const active = await runLocalDeploymentDockerTarget(
command(state),
harness(state),
);
assert.equal(active.state, 'target_active');
const request = JSON.parse(
fs.readFileSync(
path.join(state.journal, '0003-target-start-decision.json'),
'utf8',
),
);
assert.match(
request.evidence.targetApplicationBindingDigest,
/^[0-9a-f]{64}$/,
);
});
test('fails closed before starting when a Local API entry points at another Application config', async (t) => {
const state = fixture(t);
prepareLocalApiTarget(state);
const config = JSON.parse(fs.readFileSync(state.apiConfigPath, 'utf8'));
config.applicationConfigFilePath = '/host/runtime/other-application.json';
fs.writeFileSync(state.apiConfigPath, `${JSON.stringify(config)}\n`, {
mode: 0o600,
});
await assert.rejects(
runLocalDeploymentDockerTarget(command(state), harness(state)),
/Local API configuration binding is invalid/,
);
});
test('rejects a Local API entry that aliases the Application target config path', async (t) => {
const state = fixture(t);
prepareLocalApiTarget(state);
state.targetApi.expectedTargetConfigPath = state.targetApplicationConfigPath;
await assert.rejects(
runLocalDeploymentDockerTarget(command(state), harness(state)),
/API and Application configuration paths must be distinct/,
);
});
test('rejects a Local API owner pepper directory equal to its deployment root', async (t) => {
const state = fixture(t);
prepareLocalApiTarget(state);
const config = JSON.parse(fs.readFileSync(state.apiConfigPath, 'utf8'));
config.ownerPepperKeyringDirectory = config.deploymentRoot;
fs.writeFileSync(state.apiConfigPath, `${JSON.stringify(config)}\n`, {
mode: 0o600,
});
await assert.rejects(
runLocalDeploymentDockerTarget(command(state), harness(state)),
/Local API configuration binding is invalid/,
);
});
test('makes an offline Trial Kit target manual-required when its inspected content ID drifted', async (t) => {
const state = fixture(t);
state.targetImageAuthority = 'local-image-id';
@@ -691,19 +786,14 @@ test('recovers a crash after the start barrier by inspection without repeating s
/simulated supervisor crash/,
);
assert.equal(
fs.existsSync(
path.join(state.journal, '0003-target-start-decision.json'),
),
fs.existsSync(path.join(state.journal, '0003-target-start-decision.json')),
true,
);
assert.equal(
fs.existsSync(path.join(state.journal, '0004-target-start-outcome.json')),
false,
);
assert.equal(
crashing.calls.filter((args) => args[1] === 'start').length,
1,
);
assert.equal(crashing.calls.filter((args) => args[1] === 'start').length, 1);
state.targetRunning = true;
startupReceipt(state, ++state.nextProcessId);
@@ -844,10 +934,7 @@ test('refuses target start when the writable target database mount is not bound'
const detached = command(state);
detached.request.targetDatabasePath = '/var/db/detached-qinglong3.sqlite';
const controller = harness(state);
const unresolved = await runLocalDeploymentDockerTarget(
detached,
controller,
);
const unresolved = await runLocalDeploymentDockerTarget(detached, controller);
assert.equal(unresolved.state, 'manual_required');
assert.equal(
controller.calls.filter((args) => args[1] === 'start').length,
@@ -904,12 +991,7 @@ test('prevents a new cutover id from bypassing a manual-required instance head',
assert.equal(dockerCalls, 0);
assert.equal(
fs.existsSync(
path.join(
state.deploymentRoot,
'service',
'cutovers',
'cutover-edge-2',
),
path.join(state.deploymentRoot, 'service', 'cutovers', 'cutover-edge-2'),
),
false,
);
@@ -932,10 +1014,7 @@ test('diagnoses and resolves manual-required through inspect-only prepare and CA
assert.ok(controller.calls.every((args) => args[1] === 'inspect'));
const prepared = runLocalDeploymentCutoverManualCommand(
manualCommand(
state,
'local.deployment.cutover.manual-resolution-prepare',
),
manualCommand(state, 'local.deployment.cutover.manual-resolution-prepare'),
harness(state, { leaveStopped: true }),
);
assert.equal(prepared.state, 'resolution_prepared');
@@ -968,17 +1047,14 @@ test('diagnoses and resolves manual-required through inspect-only prepare and CA
assert.equal(head.state, 'resolution_authorized');
assert.equal(head.previousHeadDigest, diagnosed.instanceHeadDigest);
const replay = runLocalDeploymentCutoverManualCommand(
commitCommand,
{
validateSocket() {
throw new Error('commit replay must not reopen Docker authority');
},
runDocker() {
throw new Error('commit replay must not inspect or mutate');
},
const replay = runLocalDeploymentCutoverManualCommand(commitCommand, {
validateSocket() {
throw new Error('commit replay must not reopen Docker authority');
},
);
runDocker() {
throw new Error('commit replay must not inspect or mutate');
},
});
assert.equal(replay.status, 'existing');
assert.equal(replay.instanceHeadDigest, committed.instanceHeadDigest);
@@ -1041,10 +1117,7 @@ test('rejects manual resolution commit when stopped evidence drifts', async (t)
harness(state, { leaveStopped: true, expireImmediately: true }),
);
const prepared = runLocalDeploymentCutoverManualCommand(
manualCommand(
state,
'local.deployment.cutover.manual-resolution-prepare',
),
manualCommand(state, 'local.deployment.cutover.manual-resolution-prepare'),
harness(state, { leaveStopped: true }),
);
state.targetRunning = true;
@@ -1085,10 +1158,7 @@ test('stops an active target and proves an unchanged rollback candidate', async
);
assert.equal(stopped.state, 'target_stopped');
assert.equal(stopped.reconciliation, 'rollback_candidate');
assert.equal(
controller.calls.filter((args) => args[1] === 'stop').length,
1,
);
assert.equal(controller.calls.filter((args) => args[1] === 'stop').length, 1);
const request = JSON.parse(
fs.readFileSync(
path.join(state.journal, '0005-target-stop-decision.json'),
@@ -1214,10 +1284,7 @@ test('prepares and commits an exact legacy rollback without mutating target data
);
assert.equal(prepared.state, 'rollback_prepared');
assert.match(prepared.preparationDigest, /^[0-9a-f]{64}$/);
assert.equal(
preparing.calls.filter((args) => args[1] === 'start').length,
0,
);
assert.equal(preparing.calls.filter((args) => args[1] === 'start').length, 0);
const targetBefore = fs.readFileSync(state.targetDatabasePath);
const commitCommand = rollbackCommand(
state,
@@ -1226,15 +1293,11 @@ test('prepares and commits an exact legacy rollback without mutating target data
prepared.preparationDigest,
);
const committing = harness(state);
const committed = runLocalDeploymentLegacyRollback(
commitCommand,
committing,
);
const committed = runLocalDeploymentLegacyRollback(commitCommand, committing);
assert.equal(committed.state, 'legacy_running');
assert.equal(
committing.calls.filter(
(args) =>
args[1] === 'start' && args[2] === state.legacyContainerId,
(args) => args[1] === 'start' && args[2] === state.legacyContainerId,
).length,
1,
);
@@ -1369,10 +1432,7 @@ test('does not blindly start legacy after a crash at the rollback barrier', asyn
/simulated rollback supervisor crash/,
);
const recovering = harness(state);
const result = runLocalDeploymentLegacyRollback(
commitCommand,
recovering,
);
const result = runLocalDeploymentLegacyRollback(commitCommand, recovering);
assert.equal(result.state, 'manual_required');
assert.equal(
recovering.calls.filter((args) => args[1] === 'start').length,
@@ -1410,8 +1470,7 @@ test('rechecks target stopped after the rollback barrier before starting legacy'
assert.equal(result.state, 'manual_required');
assert.equal(
controller.calls.some(
(args) =>
args[1] === 'start' && args[2] === state.legacyContainerId,
(args) => args[1] === 'start' && args[2] === state.legacyContainerId,
),
false,
);
@@ -1447,10 +1506,7 @@ test('recovers a crash after legacy start by inspection without starting twice',
/simulated crash after legacy start/,
);
const recovering = harness(state);
const result = runLocalDeploymentLegacyRollback(
commitCommand,
recovering,
);
const result = runLocalDeploymentLegacyRollback(commitCommand, recovering);
assert.equal(result.state, 'legacy_running');
assert.equal(
recovering.calls.filter((args) => args[1] === 'start').length,