mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add unified cluster operator cli
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { constants } from 'node:os';
|
||||
|
||||
import { resolveQingLong3ClusterProductCommand } from './productCommand';
|
||||
|
||||
const FORWARDED_SIGNALS = Object.freeze([
|
||||
'SIGINT',
|
||||
'SIGTERM',
|
||||
'SIGHUP',
|
||||
] as const);
|
||||
|
||||
export interface QingLong3ClusterProductSignalChild {
|
||||
readonly exitCode: number | null;
|
||||
readonly signalCode: NodeJS.Signals | null;
|
||||
kill(signal: NodeJS.Signals): boolean;
|
||||
}
|
||||
|
||||
export interface QingLong3ClusterProductSignalHost {
|
||||
on(signal: NodeJS.Signals, handler: () => void): unknown;
|
||||
off(signal: NodeJS.Signals, handler: () => void): unknown;
|
||||
}
|
||||
|
||||
export function clusterProductSignalExitCode(
|
||||
signal: NodeJS.Signals | null,
|
||||
): number {
|
||||
if (signal === null) return 1;
|
||||
const number = constants.signals[signal];
|
||||
return typeof number === 'number' ? 128 + number : 1;
|
||||
}
|
||||
|
||||
function lowSensitivityFailure(
|
||||
code: string,
|
||||
message: string,
|
||||
): Readonly<Record<string, string | number>> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-product-cli',
|
||||
code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
export function forwardClusterProductSignals(
|
||||
child: QingLong3ClusterProductSignalChild,
|
||||
signalHost: QingLong3ClusterProductSignalHost = process,
|
||||
): () => void {
|
||||
const handlers = FORWARDED_SIGNALS.map((signal) => {
|
||||
const handler = (): void => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill(signal);
|
||||
}
|
||||
};
|
||||
signalHost.on(signal, handler);
|
||||
return Object.freeze({ signal, handler });
|
||||
});
|
||||
return () => {
|
||||
for (const { signal, handler } of handlers) {
|
||||
signalHost.off(signal, handler);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function invoke(targetFilePath: string, argv: readonly string[]): void {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawn(process.execPath, [targetFilePath, ...argv], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
'QL3_CLUSTER_PRODUCT_COMMAND_START_FAILED',
|
||||
'QingLong 3.0 Cluster product command could not start',
|
||||
),
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const removeSignalHandlers = forwardClusterProductSignals(child);
|
||||
let settled = false;
|
||||
const settle = (exitCode: number): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
process.exitCode = exitCode;
|
||||
};
|
||||
child.once('error', () => {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
'QL3_CLUSTER_PRODUCT_COMMAND_START_FAILED',
|
||||
'QingLong 3.0 Cluster product command could not start',
|
||||
),
|
||||
)}\n`,
|
||||
);
|
||||
settle(1);
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
settle(code ?? clusterProductSignalExitCode(signal));
|
||||
});
|
||||
}
|
||||
|
||||
function main(argv: readonly string[]): void {
|
||||
try {
|
||||
const resolution = resolveQingLong3ClusterProductCommand(argv, __dirname);
|
||||
if (resolution.kind === 'help' || resolution.kind === 'version') {
|
||||
process.stdout.write(`${resolution.output}\n`);
|
||||
return;
|
||||
}
|
||||
if (resolution.kind === 'invalid') {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(resolution.code, resolution.message),
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
invoke(resolution.targetFilePath, resolution.argv);
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
'QL3_CLUSTER_PRODUCT_CLI_INSTALLATION_INVALID',
|
||||
'QingLong 3.0 Cluster product command installation is invalid',
|
||||
),
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export interface QingLong3ClusterProductCommandDefinition {
|
||||
readonly name: string;
|
||||
readonly binary: string;
|
||||
readonly target: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export type QingLong3ClusterProductCommandResolution =
|
||||
| Readonly<{ kind: 'help'; output: string }>
|
||||
| Readonly<{ kind: 'version'; output: string }>
|
||||
| Readonly<{
|
||||
kind: 'invoke';
|
||||
command: QingLong3ClusterProductCommandDefinition;
|
||||
targetFilePath: string;
|
||||
argv: readonly string[];
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: 'invalid';
|
||||
code: 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID';
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
const PACKAGE_NAME = '@qinglong/cluster-admin';
|
||||
const MAXIMUM_PACKAGE_MANIFEST_BYTES = 64 * 1024;
|
||||
const SEMVER_PATTERN =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProductCommandDefinition[] =
|
||||
Object.freeze([
|
||||
Object.freeze({
|
||||
name: 'package',
|
||||
binary: 'ql3-plugin-package-client',
|
||||
target: 'plugin-package/management/pluginPackageManagementClientCli.js',
|
||||
description: 'manage Plugin Packages through the authenticated API',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'package-kubernetes',
|
||||
binary: 'ql3-plugin-package-client-kubernetes',
|
||||
target:
|
||||
'plugin-package/management/pluginPackageManagementKubernetesClientCli.js',
|
||||
description: 'manage Plugin Packages through a bounded Kubernetes tunnel',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'worker-credential',
|
||||
binary: 'ql3-worker-credential-client',
|
||||
target: 'worker-credential/workerCredentialManagementClientCli.js',
|
||||
description: 'manage Worker credentials through the authenticated API',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'approval',
|
||||
binary: 'ql3-approval-client',
|
||||
target: 'approval-management/approvalManagementClientCli.js',
|
||||
description: 'inspect and decide human approvals',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'run',
|
||||
binary: 'ql3-run-client',
|
||||
target: 'run-management/runManagementClientCli.js',
|
||||
description: 'retry or stop Runs under strong authentication',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'automation',
|
||||
binary: 'ql3-automation-client',
|
||||
target: 'automation-management/automationManagementClientCli.js',
|
||||
description: 'manage Task and Trigger definitions',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'model-credential',
|
||||
binary: 'ql3-provider-credential-client',
|
||||
target:
|
||||
'model-provider-credential/modelProviderCredentialManagementClientCli.js',
|
||||
description: 'manage model provider credentials',
|
||||
}),
|
||||
]);
|
||||
|
||||
function installationPaths(moduleDirectory: string): Readonly<{
|
||||
distRoot: string;
|
||||
packageRoot: string;
|
||||
packageManifestPath: string;
|
||||
}> {
|
||||
const distRoot = resolve(moduleDirectory, '..');
|
||||
const packageRoot = resolve(distRoot, '..');
|
||||
return Object.freeze({
|
||||
distRoot,
|
||||
packageRoot,
|
||||
packageManifestPath: resolve(packageRoot, 'package.json'),
|
||||
});
|
||||
}
|
||||
|
||||
function isInside(parent: string, candidate: string): boolean {
|
||||
const pathFromParent = relative(parent, candidate);
|
||||
return (
|
||||
pathFromParent !== '' &&
|
||||
pathFromParent !== '..' &&
|
||||
!pathFromParent.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(pathFromParent)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInstalledTarget(
|
||||
distRoot: string,
|
||||
definition: QingLong3ClusterProductCommandDefinition,
|
||||
): string {
|
||||
const lexicalTarget = resolve(distRoot, definition.target);
|
||||
if (!isInside(distRoot, lexicalTarget)) {
|
||||
throw new Error('Cluster product command target escapes package dist root');
|
||||
}
|
||||
const targetStatus = lstatSync(lexicalTarget, { throwIfNoEntry: false });
|
||||
if (
|
||||
targetStatus === undefined ||
|
||||
!targetStatus.isFile() ||
|
||||
targetStatus.isSymbolicLink()
|
||||
) {
|
||||
throw new Error('Cluster product command target is unavailable');
|
||||
}
|
||||
const canonicalDistRoot = realpathSync(distRoot);
|
||||
const canonicalTarget = realpathSync(lexicalTarget);
|
||||
if (!isInside(canonicalDistRoot, canonicalTarget)) {
|
||||
throw new Error(
|
||||
'Cluster product command target escapes canonical package root',
|
||||
);
|
||||
}
|
||||
return lexicalTarget;
|
||||
}
|
||||
|
||||
export function loadQingLong3ClusterProductVersion(
|
||||
moduleDirectory: string,
|
||||
): string {
|
||||
const { packageRoot, packageManifestPath } =
|
||||
installationPaths(moduleDirectory);
|
||||
const status = lstatSync(packageManifestPath, { throwIfNoEntry: false });
|
||||
if (
|
||||
status === undefined ||
|
||||
!status.isFile() ||
|
||||
status.isSymbolicLink() ||
|
||||
status.size <= 0 ||
|
||||
status.size > MAXIMUM_PACKAGE_MANIFEST_BYTES ||
|
||||
realpathSync(packageManifestPath) !==
|
||||
resolve(realpathSync(packageRoot), 'package.json')
|
||||
) {
|
||||
throw new Error('Cluster product package manifest is unavailable');
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(packageManifestPath, 'utf8')) as {
|
||||
readonly name?: unknown;
|
||||
readonly version?: unknown;
|
||||
};
|
||||
if (
|
||||
manifest.name !== PACKAGE_NAME ||
|
||||
typeof manifest.version !== 'string' ||
|
||||
!SEMVER_PATTERN.test(manifest.version)
|
||||
) {
|
||||
throw new Error('Cluster product package identity is invalid');
|
||||
}
|
||||
return manifest.version;
|
||||
}
|
||||
|
||||
export function qingLong3ClusterProductHelp(): string {
|
||||
const longestName = Math.max(
|
||||
...QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name.length),
|
||||
);
|
||||
const commands = QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(
|
||||
({ name, description }) => ` ${name.padEnd(longestName)} ${description}`,
|
||||
).join('\n');
|
||||
return [
|
||||
'Usage: ql3-cluster-admin <command> [arguments]',
|
||||
'',
|
||||
'Remote client commands:',
|
||||
commands,
|
||||
'',
|
||||
'Use `ql3-cluster-admin <command> --help` for command-specific usage.',
|
||||
'Server, migration, recovery, executor and key-custody authorities remain isolated.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function resolveQingLong3ClusterProductCommand(
|
||||
argv: readonly string[],
|
||||
moduleDirectory: string,
|
||||
): QingLong3ClusterProductCommandResolution {
|
||||
if (
|
||||
argv.length === 0 ||
|
||||
(argv.length === 1 &&
|
||||
(argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help'))
|
||||
) {
|
||||
return Object.freeze({
|
||||
kind: 'help',
|
||||
output: qingLong3ClusterProductHelp(),
|
||||
});
|
||||
}
|
||||
if (
|
||||
argv.length === 1 &&
|
||||
(argv[0] === '--version' || argv[0] === '-V' || argv[0] === 'version')
|
||||
) {
|
||||
return Object.freeze({
|
||||
kind: 'version',
|
||||
output: loadQingLong3ClusterProductVersion(moduleDirectory),
|
||||
});
|
||||
}
|
||||
const command = QINGLONG3_CLUSTER_PRODUCT_COMMANDS.find(
|
||||
(candidate) => candidate.name === argv[0],
|
||||
);
|
||||
if (command === undefined) {
|
||||
return Object.freeze({
|
||||
kind: 'invalid',
|
||||
code: 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID',
|
||||
message: 'unknown QingLong 3.0 Cluster product command',
|
||||
});
|
||||
}
|
||||
const { distRoot } = installationPaths(moduleDirectory);
|
||||
return Object.freeze({
|
||||
kind: 'invoke',
|
||||
command,
|
||||
targetFilePath: resolveInstalledTarget(distRoot, command),
|
||||
argv: Object.freeze(argv.slice(1)),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user