mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 11:27:31 +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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user