mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): prove Console adopted cutover entry
This commit is contained in:
@@ -5,9 +5,15 @@ import type {
|
||||
LocalApplicationProcessSignalSource,
|
||||
} from '@qinglong/local-application/process';
|
||||
|
||||
import {
|
||||
localApiCliFailureFact,
|
||||
parseLocalApiCliCommand,
|
||||
} from './production-process/cliCommand';
|
||||
import { runProductionLocalApiCutoverProbe } from './production-process/cutoverProbeProcess';
|
||||
import { runProductionLocalApiProcess } from './production-process/processApplication';
|
||||
|
||||
const USAGE = 'Usage: ql3-local-api --config /absolute/private-config.json';
|
||||
const USAGE =
|
||||
'Usage: ql3-local-api [--cutover-probe] --config /absolute/private-config.json';
|
||||
|
||||
const nodeSignals: LocalApplicationProcessSignalSource = Object.freeze({
|
||||
subscribe(
|
||||
@@ -26,36 +32,13 @@ const nodeSignals: LocalApplicationProcessSignalSource = Object.freeze({
|
||||
},
|
||||
});
|
||||
|
||||
function configFileArgument(argv: readonly string[]): string | null {
|
||||
return argv.length === 2 && argv[0] === '--config' && argv[1]
|
||||
? argv[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly name?: unknown; readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-local-api',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const configFilePath = configFileArgument(argv);
|
||||
if (!configFilePath) {
|
||||
const command = parseLocalApiCliCommand(argv);
|
||||
if (command === null) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_LOCAL_API_CLI_USAGE_INVALID',
|
||||
@@ -66,16 +49,20 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stopResult = await runProductionLocalApiProcess({
|
||||
configFilePath,
|
||||
const options = {
|
||||
configFilePath: command.configFilePath,
|
||||
signals: nodeSignals,
|
||||
emit(event) {
|
||||
emit(event: Readonly<Record<string, unknown>>) {
|
||||
process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
},
|
||||
});
|
||||
};
|
||||
const stopResult =
|
||||
command.mode === 'cutover_probe'
|
||||
? await runProductionLocalApiCutoverProbe(options)
|
||||
: await runProductionLocalApiProcess(options);
|
||||
if (stopResult !== 'stopped') process.exitCode = 1;
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.stderr.write(`${JSON.stringify(localApiCliFailureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export type LocalApiCliCommand = Readonly<{
|
||||
configFilePath: string;
|
||||
mode: 'api' | 'cutover_probe';
|
||||
}>;
|
||||
|
||||
export function localApiCliFailureFact(
|
||||
error: unknown,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-local-api',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseLocalApiCliCommand(
|
||||
argv: readonly string[],
|
||||
): LocalApiCliCommand | null {
|
||||
if (argv.length === 2 && argv[0] === '--config' && argv[1]) {
|
||||
return Object.freeze({
|
||||
configFilePath: argv[1],
|
||||
mode: 'api' as const,
|
||||
});
|
||||
}
|
||||
if (
|
||||
argv.length === 3 &&
|
||||
argv[0] === '--cutover-probe' &&
|
||||
argv[1] === '--config' &&
|
||||
argv[2]
|
||||
) {
|
||||
return Object.freeze({
|
||||
configFilePath: argv[2],
|
||||
mode: 'cutover_probe' as const,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
runProductionLocalApplicationCutoverProbe,
|
||||
type ProductionLocalApplicationCutoverProbeOptions,
|
||||
} from '@qinglong/local-application/cutover-probe';
|
||||
|
||||
import {
|
||||
readLocalApiProcessConfig,
|
||||
type LocalApiProcessConfig,
|
||||
} from './config';
|
||||
|
||||
export type ProductionLocalApiCutoverProbeOptions =
|
||||
ProductionLocalApplicationCutoverProbeOptions;
|
||||
|
||||
export interface ProductionLocalApiCutoverProbeAdapters {
|
||||
readonly readConfig: typeof readLocalApiProcessConfig;
|
||||
readonly runApplicationProbe: typeof runProductionLocalApplicationCutoverProbe;
|
||||
}
|
||||
|
||||
function validateOptions(options: ProductionLocalApiCutoverProbeOptions): void {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.configFilePath !== 'string' ||
|
||||
typeof options.signals?.subscribe !== 'function' ||
|
||||
typeof options.emit !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Production Local API cutover probe options are invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAdapters(
|
||||
adapters: ProductionLocalApiCutoverProbeAdapters,
|
||||
): void {
|
||||
if (
|
||||
!adapters ||
|
||||
typeof adapters !== 'object' ||
|
||||
Array.isArray(adapters) ||
|
||||
typeof adapters.readConfig !== 'function' ||
|
||||
typeof adapters.runApplicationProbe !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Production Local API cutover probe adapters are invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the exact Local API entry configuration to the read-only adopted
|
||||
* Application probe. No listener, credential authority, scheduler, recovery
|
||||
* or write-capable database connection is opened in this mode.
|
||||
*/
|
||||
export async function runProductionLocalApiCutoverProbe(
|
||||
options: ProductionLocalApiCutoverProbeOptions,
|
||||
adapters: ProductionLocalApiCutoverProbeAdapters = {
|
||||
readConfig: readLocalApiProcessConfig,
|
||||
runApplicationProbe: runProductionLocalApplicationCutoverProbe,
|
||||
},
|
||||
): Promise<'stopped'> {
|
||||
validateOptions(options);
|
||||
validateAdapters(adapters);
|
||||
const config: Readonly<LocalApiProcessConfig> = adapters.readConfig(
|
||||
options.configFilePath,
|
||||
);
|
||||
return adapters.runApplicationProbe({
|
||||
configFilePath: config.applicationConfigFilePath,
|
||||
signals: options.signals,
|
||||
emit: options.emit,
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,6 @@ test('publishes bounded help without bootstrapping storage or a listener', () =>
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(
|
||||
result.stdout,
|
||||
'Usage: ql3-local-api --config /absolute/private-config.json\n',
|
||||
'Usage: ql3-local-api [--cutover-probe] --config /absolute/private-config.json\n',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
localApiCliFailureFact,
|
||||
parseLocalApiCliCommand,
|
||||
} = require('../dist/production-process/cliCommand.js');
|
||||
|
||||
test('parses only the normal API and frozen cutover-probe commands', () => {
|
||||
assert.deepEqual(parseLocalApiCliCommand(['--config', '/private/api.json']), {
|
||||
configFilePath: '/private/api.json',
|
||||
mode: 'api',
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseLocalApiCliCommand([
|
||||
'--cutover-probe',
|
||||
'--config',
|
||||
'/private/api.json',
|
||||
]),
|
||||
{
|
||||
configFilePath: '/private/api.json',
|
||||
mode: 'cutover_probe',
|
||||
},
|
||||
);
|
||||
for (const argv of [
|
||||
[],
|
||||
['--cutover-probe', '/private/api.json'],
|
||||
['--config', '/private/api.json', '--cutover-probe'],
|
||||
['--cutover-probe', '--config', ''],
|
||||
]) {
|
||||
assert.equal(parseLocalApiCliCommand(argv), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('publishes bounded Local API failure facts', () => {
|
||||
assert.deepEqual(
|
||||
localApiCliFailureFact(
|
||||
Object.assign(new Error('secret detail'), { code: 'QL3_TEST' }),
|
||||
),
|
||||
{
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-local-api',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
name: 'Error',
|
||||
code: 'QL3_TEST',
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LOCAL_API_PROCESS_CONFIG_SCHEMA,
|
||||
} = require('../dist/production-process/config.js');
|
||||
const {
|
||||
runProductionLocalApiCutoverProbe,
|
||||
} = require('../dist/production-process/cutoverProbeProcess.js');
|
||||
|
||||
test('validates the Local API entry config before delegating to the frozen Application probe', async () => {
|
||||
const signals = Object.freeze({
|
||||
subscribe() {
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const events = [];
|
||||
let calls = 0;
|
||||
const result = await runProductionLocalApiCutoverProbe(
|
||||
{
|
||||
configFilePath: '/srv/qinglong/private/api.json',
|
||||
signals,
|
||||
emit(event) {
|
||||
events.push(event);
|
||||
},
|
||||
},
|
||||
{
|
||||
readConfig(filePath) {
|
||||
assert.equal(filePath, '/srv/qinglong/private/api.json');
|
||||
return Object.freeze({
|
||||
schema: LOCAL_API_PROCESS_CONFIG_SCHEMA,
|
||||
deploymentRoot: '/srv/qinglong',
|
||||
applicationConfigFilePath: '/srv/qinglong/private/application.json',
|
||||
ownerPepperKeyringDirectory: '/srv/qinglong/private/owner-pepper',
|
||||
listener: Object.freeze({ host: '127.0.0.1', port: 5701 }),
|
||||
});
|
||||
},
|
||||
async runApplicationProbe(options) {
|
||||
calls += 1;
|
||||
assert.equal(
|
||||
options.configFilePath,
|
||||
'/srv/qinglong/private/application.json',
|
||||
);
|
||||
assert.equal(options.signals, signals);
|
||||
await options.emit({ event: 'cutover_probe_active' });
|
||||
return 'stopped';
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result, 'stopped');
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(events, [{ event: 'cutover_probe_active' }]);
|
||||
});
|
||||
|
||||
test('rejects malformed adapters before reading any authority', async () => {
|
||||
await assert.rejects(
|
||||
runProductionLocalApiCutoverProbe(
|
||||
{
|
||||
configFilePath: '/srv/qinglong/private/api.json',
|
||||
signals: Object.freeze({
|
||||
subscribe() {
|
||||
return () => {};
|
||||
},
|
||||
}),
|
||||
emit() {},
|
||||
},
|
||||
{},
|
||||
),
|
||||
/adapters are invalid/,
|
||||
);
|
||||
});
|
||||
@@ -34,6 +34,11 @@
|
||||
"types": "./dist/production-process/processApplication.d.ts",
|
||||
"require": "./dist/production-process/processApplication.js",
|
||||
"default": "./dist/production-process/processApplication.js"
|
||||
},
|
||||
"./cutover-probe": {
|
||||
"types": "./dist/production-process/cutoverProbeProcess.d.ts",
|
||||
"require": "./dist/production-process/cutoverProbeProcess.js",
|
||||
"default": "./dist/production-process/cutoverProbeProcess.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
+61
-22
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user