feat(ql3): add offline cluster context preflight

This commit is contained in:
whyour
2026-08-13 01:19:23 +08:00
parent ea3bbe35c3
commit a83b4c5e8d
13 changed files with 805 additions and 99 deletions
@@ -5,6 +5,7 @@ import { constants } from 'node:os';
import { resolveQingLong3ClusterProductCommand } from './productCommand';
import { QingLong3ClusterProductContextError } from './productContext';
import { validateQingLong3ClusterProductContext } from './productContext';
const FORWARDED_SIGNALS = Object.freeze([
'SIGINT',
@@ -108,7 +109,7 @@ function invoke(targetFilePath: string, argv: readonly string[]): void {
});
}
function main(argv: readonly string[]): void {
async function main(argv: readonly string[]): Promise<void> {
try {
const resolution = resolveQingLong3ClusterProductCommand(argv, __dirname);
if (resolution.kind === 'help' || resolution.kind === 'version') {
@@ -124,6 +125,13 @@ function main(argv: readonly string[]): void {
process.exitCode = 64;
return;
}
if (resolution.kind === 'context-validation') {
const result = await validateQingLong3ClusterProductContext(
resolution.contextFile,
);
process.stdout.write(`${JSON.stringify(result)}\n`);
return;
}
invoke(resolution.targetFilePath, resolution.argv);
} catch (error) {
if (
@@ -157,5 +165,5 @@ function main(argv: readonly string[]): void {
}
if (require.main === module) {
main(process.argv.slice(2));
void main(process.argv.slice(2));
}
@@ -13,6 +13,7 @@ export interface QingLong3ClusterProductCommandDefinition {
export type QingLong3ClusterProductCommandResolution =
| Readonly<{ kind: 'help'; output: string }>
| Readonly<{ kind: 'version'; output: string }>
| Readonly<{ kind: 'context-validation'; contextFile: string }>
| Readonly<{
kind: 'invoke';
command: QingLong3ClusterProductCommandDefinition;
@@ -172,6 +173,9 @@ export function qingLong3ClusterProductHelp(): string {
'Remote client commands:',
commands,
'',
'Local operator commands:',
' context validate --context=/absolute/operator-context.json',
'',
'Use `ql3-cluster-admin <command> --help` for command-specific usage.',
'Use `--context=/absolute/operator-context.json` to inject only stable client paths.',
'Command and short-lived assertion files always remain explicit per invocation.',
@@ -193,6 +197,28 @@ export function resolveQingLong3ClusterProductCommand(
output: qingLong3ClusterProductHelp(),
});
}
if (argv[0] === 'context') {
if (
argv.length !== 3 ||
argv[1] !== 'validate' ||
!argv[2]!.startsWith('--context=') ||
argv[2] === '--context='
) {
return Object.freeze({
kind: 'invalid',
code: 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID',
message: 'QingLong 3.0 Cluster product context command is invalid',
});
}
const { distRoot } = installationPaths(moduleDirectory);
for (const definition of QINGLONG3_CLUSTER_PRODUCT_COMMANDS) {
resolveInstalledTarget(distRoot, definition);
}
return Object.freeze({
kind: 'context-validation',
contextFile: argv[2]!.slice('--context='.length),
});
}
if (
argv.length === 1 &&
(argv[0] === '--version' || argv[0] === '-V' || argv[0] === 'version')
@@ -10,6 +10,10 @@ import {
import { isAbsolute } from 'node:path';
import { TextDecoder } from 'node:util';
import { validateClusterAuthenticatedManagementClientConfiguration } from '../management-support/pluginPackageManagementClient';
import type { ClusterAuthenticatedManagementClientKind } from '../management-support/pluginPackageManagementClient';
import { validateClusterPluginPackageManagementKubernetesConfiguration } from '../plugin-package/management/pluginPackageManagementKubernetesClient';
const MAXIMUM_CONTEXT_BYTES = 64 * 1024;
const MAXIMUM_PATH_BYTES = 4_096;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/u;
@@ -38,6 +42,33 @@ export interface QingLong3ClusterProductContext {
>;
}
export interface QingLong3ClusterProductContextValidation {
readonly schemaVersion: 1;
readonly component: 'qinglong3-cluster-product-cli';
readonly event: 'context_valid';
readonly commandCount: number;
readonly commands: readonly Readonly<{
name: ContextCommandName;
transport: 'https' | 'kubernetes-port-forward';
clientCertificate: 'forbidden' | 'required';
kubernetesAuthentication?: 'token' | 'client-certificate';
}>[];
readonly networkAccess: false;
readonly mutation: false;
}
const CONTEXT_COMMAND_CLIENT_KINDS: Readonly<
Record<ContextCommandName, ClusterAuthenticatedManagementClientKind>
> = Object.freeze({
package: 'package',
'package-kubernetes': 'package',
'worker-credential': 'worker-credential',
approval: 'approval',
run: 'run',
automation: 'automation',
'model-credential': 'model-credential',
});
export class QingLong3ClusterProductContextError extends TypeError {
readonly code = 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID';
@@ -243,3 +274,57 @@ export function resolveQingLong3ClusterProductContextArguments(
...argv,
]);
}
export async function validateQingLong3ClusterProductContext(
contextFile: string,
): Promise<Readonly<QingLong3ClusterProductContextValidation>> {
try {
const context = loadQingLong3ClusterProductContext(contextFile);
const commands: Array<
QingLong3ClusterProductContextValidation['commands'][number]
> = [];
for (const name of CONTEXT_COMMANDS) {
const command = context.commands[name];
if (command === undefined) continue;
const clientKind = CONTEXT_COMMAND_CLIENT_KINDS[name];
const https = validateClusterAuthenticatedManagementClientConfiguration(
command.configFile,
clientKind,
);
if (name === 'package-kubernetes') {
const kubernetes =
await validateClusterPluginPackageManagementKubernetesConfiguration(
command.kubernetesFile!,
);
commands.push(
Object.freeze({
name,
transport: kubernetes.transport,
clientCertificate: https.clientCertificate,
kubernetesAuthentication: kubernetes.authentication,
}),
);
} else {
commands.push(
Object.freeze({
name,
transport: https.transport,
clientCertificate: https.clientCertificate,
}),
);
}
}
return Object.freeze({
schemaVersion: 1,
component: 'qinglong3-cluster-product-cli',
event: 'context_valid',
commandCount: commands.length,
commands: Object.freeze(commands),
networkAccess: false,
mutation: false,
});
} catch (error) {
if (error instanceof QingLong3ClusterProductContextError) throw error;
throw new QingLong3ClusterProductContextError();
}
}