mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add offline cluster context preflight
This commit is contained in:
+209
-67
@@ -23,14 +23,48 @@ import {
|
||||
} from '../plugin-package/management/pluginPackageManagementTransport';
|
||||
|
||||
const MANAGEMENT_PATH = '/api/v3/plugin-packages/management';
|
||||
const ALLOWED_MANAGEMENT_PATHS = new Set([
|
||||
MANAGEMENT_PATH,
|
||||
'/api/v3/worker-credentials/management',
|
||||
'/api/v3/automations/management',
|
||||
'/api/v3/approvals/management',
|
||||
'/api/v3/provider-credentials/management',
|
||||
'/api/v3/runs/management',
|
||||
]);
|
||||
export type ClusterAuthenticatedManagementClientKind =
|
||||
| 'package'
|
||||
| 'worker-credential'
|
||||
| 'automation'
|
||||
| 'approval'
|
||||
| 'model-credential'
|
||||
| 'run';
|
||||
|
||||
const MANAGEMENT_CLIENT_POLICIES: Readonly<
|
||||
Record<
|
||||
ClusterAuthenticatedManagementClientKind,
|
||||
Readonly<{
|
||||
managementPath: string;
|
||||
clientCertificate: 'forbidden' | 'required';
|
||||
}>
|
||||
>
|
||||
> = Object.freeze({
|
||||
package: Object.freeze({
|
||||
managementPath: MANAGEMENT_PATH,
|
||||
clientCertificate: 'forbidden',
|
||||
}),
|
||||
'worker-credential': Object.freeze({
|
||||
managementPath: '/api/v3/worker-credentials/management',
|
||||
clientCertificate: 'required',
|
||||
}),
|
||||
automation: Object.freeze({
|
||||
managementPath: '/api/v3/automations/management',
|
||||
clientCertificate: 'required',
|
||||
}),
|
||||
approval: Object.freeze({
|
||||
managementPath: '/api/v3/approvals/management',
|
||||
clientCertificate: 'required',
|
||||
}),
|
||||
'model-credential': Object.freeze({
|
||||
managementPath: '/api/v3/provider-credentials/management',
|
||||
clientCertificate: 'required',
|
||||
}),
|
||||
run: Object.freeze({
|
||||
managementPath: '/api/v3/runs/management',
|
||||
clientCertificate: 'required',
|
||||
}),
|
||||
});
|
||||
const MAX_CONFIG_BYTES = 16 * 1024;
|
||||
const MAX_ASSERTION_BYTES = 16 * 1024;
|
||||
const MAX_COMMAND_BYTES = 256 * 1024;
|
||||
@@ -778,68 +812,48 @@ function rawHeaderCount(rawHeaders: readonly string[], name: string): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function executeClusterAuthenticatedManagementClient<
|
||||
Command,
|
||||
Result,
|
||||
>(
|
||||
paths: ClusterPluginPackageManagementClientPaths,
|
||||
protocol: ClusterAuthenticatedManagementClientProtocol<Command, Result>,
|
||||
connectionOptions?: ClusterPluginPackageManagementClientConnectionOptions,
|
||||
): Promise<Readonly<ClusterAuthenticatedManagementClientResult<Result>>> {
|
||||
exactObject(paths, ['configFile', 'commandFile', 'assertionFile']);
|
||||
export interface ClusterAuthenticatedManagementClientConfigurationSummary {
|
||||
readonly schemaVersion: 1;
|
||||
readonly managementPath: string;
|
||||
readonly transport: 'https';
|
||||
readonly clientCertificate: 'forbidden' | 'required';
|
||||
}
|
||||
|
||||
interface PreparedClusterAuthenticatedManagementClientConfiguration {
|
||||
readonly endpoint: URL;
|
||||
readonly servername: string;
|
||||
readonly port: number;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly caBytes: Buffer;
|
||||
readonly clientCertificateBytes?: Buffer;
|
||||
readonly clientPrivateKeyBytes?: Buffer;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
function prepareClusterAuthenticatedManagementClientConfiguration(
|
||||
configFile: string,
|
||||
managementPath: string,
|
||||
clientCertificate: 'forbidden' | 'required',
|
||||
): PreparedClusterAuthenticatedManagementClientConfiguration {
|
||||
if (
|
||||
!protocol ||
|
||||
typeof protocol !== 'object' ||
|
||||
Array.isArray(protocol) ||
|
||||
Object.keys(protocol).length !== 4 ||
|
||||
Object.keys(protocol).some(
|
||||
(key) =>
|
||||
![
|
||||
'managementPath',
|
||||
'clientCertificate',
|
||||
'normalizeCommand',
|
||||
'validateResult',
|
||||
].includes(key),
|
||||
) ||
|
||||
!ALLOWED_MANAGEMENT_PATHS.has(protocol.managementPath) ||
|
||||
!['forbidden', 'required'].includes(protocol.clientCertificate) ||
|
||||
typeof protocol.normalizeCommand !== 'function' ||
|
||||
typeof protocol.validateResult !== 'function' ||
|
||||
(connectionOptions !== undefined &&
|
||||
(!connectionOptions ||
|
||||
typeof connectionOptions !== 'object' ||
|
||||
Array.isArray(connectionOptions) ||
|
||||
Object.keys(connectionOptions).length !== 1 ||
|
||||
typeof connectionOptions.connect !== 'function'))
|
||||
!Object.values(MANAGEMENT_CLIENT_POLICIES).some(
|
||||
(policy) =>
|
||||
policy.managementPath === managementPath &&
|
||||
policy.clientCertificate === clientCertificate,
|
||||
)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
let configBytes: Buffer | undefined;
|
||||
let commandBytes: Buffer | undefined;
|
||||
let assertionBytes: Buffer | undefined;
|
||||
let caBytes: Buffer | undefined;
|
||||
let clientCertificateBytes: Buffer | undefined;
|
||||
let clientPrivateKeyBytes: Buffer | undefined;
|
||||
try {
|
||||
configBytes = readCanonicalFile(
|
||||
paths.configFile,
|
||||
MAX_CONFIG_BYTES,
|
||||
'private',
|
||||
);
|
||||
commandBytes = readCanonicalFile(
|
||||
paths.commandFile,
|
||||
MAX_COMMAND_BYTES,
|
||||
'private',
|
||||
);
|
||||
assertionBytes = readCanonicalFile(
|
||||
paths.assertionFile,
|
||||
MAX_ASSERTION_BYTES,
|
||||
'private',
|
||||
);
|
||||
configBytes = readCanonicalFile(configFile, MAX_CONFIG_BYTES, 'private');
|
||||
const config = parseJson(configBytes);
|
||||
exactObject(
|
||||
config,
|
||||
protocol.clientCertificate === 'required'
|
||||
clientCertificate === 'required'
|
||||
? [
|
||||
'schemaVersion',
|
||||
'endpoint',
|
||||
@@ -864,7 +878,7 @@ export async function executeClusterAuthenticatedManagementClient<
|
||||
!DNS_NAME_PATTERN.test(config.servername) ||
|
||||
isIP(config.servername) !== 0 ||
|
||||
typeof config.caFile !== 'string' ||
|
||||
(protocol.clientCertificate === 'required' &&
|
||||
(clientCertificate === 'required' &&
|
||||
(typeof config.clientCertificateFile !== 'string' ||
|
||||
typeof config.clientPrivateKeyFile !== 'string')) ||
|
||||
!Number.isSafeInteger(config.requestTimeoutMs) ||
|
||||
@@ -874,7 +888,6 @@ export async function executeClusterAuthenticatedManagementClient<
|
||||
throw configurationFailure();
|
||||
}
|
||||
const servername = config.servername;
|
||||
const caFile = config.caFile;
|
||||
const requestTimeoutMs = config.requestTimeoutMs as number;
|
||||
let endpoint: URL;
|
||||
try {
|
||||
@@ -888,7 +901,7 @@ export async function executeClusterAuthenticatedManagementClient<
|
||||
endpoint.password !== '' ||
|
||||
endpoint.search !== '' ||
|
||||
endpoint.hash !== '' ||
|
||||
endpoint.pathname !== protocol.managementPath ||
|
||||
endpoint.pathname !== managementPath ||
|
||||
endpoint.hostname !== servername ||
|
||||
isIP(endpoint.hostname) !== 0
|
||||
) {
|
||||
@@ -898,13 +911,17 @@ export async function executeClusterAuthenticatedManagementClient<
|
||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
caBytes = readCanonicalFile(caFile, MAX_CA_BYTES, 'public-integrity');
|
||||
caBytes = readCanonicalFile(
|
||||
config.caFile as string,
|
||||
MAX_CA_BYTES,
|
||||
'public-integrity',
|
||||
);
|
||||
try {
|
||||
new X509Certificate(caBytes);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
if (protocol.clientCertificate === 'required') {
|
||||
if (clientCertificate === 'required') {
|
||||
clientCertificateBytes = readCanonicalFile(
|
||||
config.clientCertificateFile as string,
|
||||
MAX_CLIENT_CERTIFICATE_BYTES,
|
||||
@@ -931,6 +948,134 @@ export async function executeClusterAuthenticatedManagementClient<
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
endpoint,
|
||||
servername,
|
||||
port,
|
||||
requestTimeoutMs,
|
||||
caBytes,
|
||||
...(clientCertificateBytes === undefined
|
||||
? {}
|
||||
: {
|
||||
clientCertificateBytes,
|
||||
clientPrivateKeyBytes: clientPrivateKeyBytes!,
|
||||
}),
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
caBytes?.fill(0);
|
||||
clientCertificateBytes?.fill(0);
|
||||
clientPrivateKeyBytes?.fill(0);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
caBytes?.fill(0);
|
||||
clientCertificateBytes?.fill(0);
|
||||
clientPrivateKeyBytes?.fill(0);
|
||||
if (
|
||||
error instanceof ClusterPluginPackageManagementClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
} finally {
|
||||
configBytes?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateClusterAuthenticatedManagementClientConfiguration(
|
||||
configFile: string,
|
||||
kind: ClusterAuthenticatedManagementClientKind,
|
||||
): Readonly<ClusterAuthenticatedManagementClientConfigurationSummary> {
|
||||
const policy = MANAGEMENT_CLIENT_POLICIES[kind];
|
||||
if (policy === undefined) throw configurationFailure();
|
||||
const prepared = prepareClusterAuthenticatedManagementClientConfiguration(
|
||||
configFile,
|
||||
policy.managementPath,
|
||||
policy.clientCertificate,
|
||||
);
|
||||
try {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
managementPath: policy.managementPath,
|
||||
transport: 'https',
|
||||
clientCertificate: policy.clientCertificate,
|
||||
});
|
||||
} finally {
|
||||
prepared.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeClusterAuthenticatedManagementClient<
|
||||
Command,
|
||||
Result,
|
||||
>(
|
||||
paths: ClusterPluginPackageManagementClientPaths,
|
||||
protocol: ClusterAuthenticatedManagementClientProtocol<Command, Result>,
|
||||
connectionOptions?: ClusterPluginPackageManagementClientConnectionOptions,
|
||||
): Promise<Readonly<ClusterAuthenticatedManagementClientResult<Result>>> {
|
||||
exactObject(paths, ['configFile', 'commandFile', 'assertionFile']);
|
||||
if (
|
||||
!protocol ||
|
||||
typeof protocol !== 'object' ||
|
||||
Array.isArray(protocol) ||
|
||||
Object.keys(protocol).length !== 4 ||
|
||||
Object.keys(protocol).some(
|
||||
(key) =>
|
||||
![
|
||||
'managementPath',
|
||||
'clientCertificate',
|
||||
'normalizeCommand',
|
||||
'validateResult',
|
||||
].includes(key),
|
||||
) ||
|
||||
!Object.values(MANAGEMENT_CLIENT_POLICIES).some(
|
||||
(policy) =>
|
||||
policy.managementPath === protocol.managementPath &&
|
||||
policy.clientCertificate === protocol.clientCertificate,
|
||||
) ||
|
||||
typeof protocol.normalizeCommand !== 'function' ||
|
||||
typeof protocol.validateResult !== 'function' ||
|
||||
(connectionOptions !== undefined &&
|
||||
(!connectionOptions ||
|
||||
typeof connectionOptions !== 'object' ||
|
||||
Array.isArray(connectionOptions) ||
|
||||
Object.keys(connectionOptions).length !== 1 ||
|
||||
typeof connectionOptions.connect !== 'function'))
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
let commandBytes: Buffer | undefined;
|
||||
let assertionBytes: Buffer | undefined;
|
||||
let prepared:
|
||||
| PreparedClusterAuthenticatedManagementClientConfiguration
|
||||
| undefined;
|
||||
try {
|
||||
prepared = prepareClusterAuthenticatedManagementClientConfiguration(
|
||||
paths.configFile,
|
||||
protocol.managementPath,
|
||||
protocol.clientCertificate,
|
||||
);
|
||||
commandBytes = readCanonicalFile(
|
||||
paths.commandFile,
|
||||
MAX_COMMAND_BYTES,
|
||||
'private',
|
||||
);
|
||||
assertionBytes = readCanonicalFile(
|
||||
paths.assertionFile,
|
||||
MAX_ASSERTION_BYTES,
|
||||
'private',
|
||||
);
|
||||
const {
|
||||
endpoint,
|
||||
servername,
|
||||
port,
|
||||
requestTimeoutMs,
|
||||
caBytes,
|
||||
clientCertificateBytes,
|
||||
clientPrivateKeyBytes,
|
||||
} = prepared;
|
||||
const command = protocol.normalizeCommand(parseJson(commandBytes));
|
||||
const assertion = assertionBytes.toString('ascii');
|
||||
if (
|
||||
@@ -1186,12 +1331,9 @@ export async function executeClusterAuthenticatedManagementClient<
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
configBytes?.fill(0);
|
||||
commandBytes?.fill(0);
|
||||
assertionBytes?.fill(0);
|
||||
caBytes?.fill(0);
|
||||
clientCertificateBytes?.fill(0);
|
||||
clientPrivateKeyBytes?.fill(0);
|
||||
prepared?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+107
-27
@@ -49,6 +49,20 @@ interface ReviewedKubernetesClientConfig {
|
||||
readonly apiTimeoutMs: number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementKubernetesConfigurationSummary {
|
||||
readonly schemaVersion: 1;
|
||||
readonly transport: 'kubernetes-port-forward';
|
||||
readonly authentication: 'token' | 'client-certificate';
|
||||
}
|
||||
|
||||
interface PreparedKubernetesClientConfiguration {
|
||||
readonly config: Readonly<ReviewedKubernetesClientConfig>;
|
||||
readonly kubeConfig: KubernetesConfig;
|
||||
readonly kubernetes: KubernetesModule;
|
||||
readonly authentication: 'token' | 'client-certificate';
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface KubernetesPod {
|
||||
readonly metadata?: {
|
||||
readonly name?: string;
|
||||
@@ -450,6 +464,95 @@ function validateKubeConfig(
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareKubernetesClientConfiguration(
|
||||
kubernetesFile: string,
|
||||
): Promise<PreparedKubernetesClientConfiguration> {
|
||||
let kubernetesConfigBytes: Buffer | undefined;
|
||||
let kubeconfigBytes: Buffer | undefined;
|
||||
try {
|
||||
kubernetesConfigBytes = readPrivateFile(
|
||||
kubernetesFile,
|
||||
MAX_KUBERNETES_CONFIG_BYTES,
|
||||
);
|
||||
const config = normalizeConfig(parseJson(kubernetesConfigBytes));
|
||||
kubeconfigBytes = readPrivateFile(
|
||||
config.kubeconfigFile,
|
||||
MAX_KUBECONFIG_BYTES,
|
||||
);
|
||||
const rawKubeconfig = parseJson(kubeconfigBytes);
|
||||
validateRawKubeconfig(rawKubeconfig, config);
|
||||
let kubernetes: KubernetesModule;
|
||||
try {
|
||||
kubernetes = await import('@kubernetes/client-node');
|
||||
} catch (error) {
|
||||
throw new ClusterPluginPackageManagementKubernetesClientTunnelError(
|
||||
error,
|
||||
);
|
||||
}
|
||||
const kubeConfig = new kubernetes.KubeConfig();
|
||||
try {
|
||||
kubeConfig.loadFromString(decodeUtf8(kubeconfigBytes));
|
||||
validateKubeConfig(kubeConfig, config);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
}
|
||||
const rawUser = (rawKubeconfig as JsonObject).users as readonly JsonObject[];
|
||||
const authentication = Object.hasOwn(
|
||||
rawUser[0]!.user as object,
|
||||
'token',
|
||||
)
|
||||
? 'token'
|
||||
: 'client-certificate';
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
config,
|
||||
kubeConfig,
|
||||
kubernetes,
|
||||
authentication,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
kubernetesConfigBytes?.fill(0);
|
||||
kubeconfigBytes?.fill(0);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
kubernetesConfigBytes?.fill(0);
|
||||
kubeconfigBytes?.fill(0);
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError ||
|
||||
error instanceof ClusterPluginPackageManagementKubernetesClientTunnelError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateClusterPluginPackageManagementKubernetesConfiguration(
|
||||
kubernetesFile: string,
|
||||
): Promise<
|
||||
Readonly<ClusterPluginPackageManagementKubernetesConfigurationSummary>
|
||||
> {
|
||||
const prepared = await prepareKubernetesClientConfiguration(kubernetesFile);
|
||||
try {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
transport: 'kubernetes-port-forward',
|
||||
authentication: prepared.authentication,
|
||||
});
|
||||
} finally {
|
||||
prepared.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function isReviewedPod(
|
||||
value: KubernetesPod,
|
||||
namespace: string,
|
||||
@@ -681,34 +784,12 @@ export async function executeClusterPluginPackageManagementKubernetesClient(
|
||||
throw configurationFailure();
|
||||
}
|
||||
|
||||
let kubernetesConfigBytes: Buffer | undefined;
|
||||
let kubeconfigBytes: Buffer | undefined;
|
||||
let prepared: PreparedKubernetesClientConfiguration | undefined;
|
||||
try {
|
||||
kubernetesConfigBytes = readPrivateFile(
|
||||
prepared = await prepareKubernetesClientConfiguration(
|
||||
paths.kubernetesFile,
|
||||
MAX_KUBERNETES_CONFIG_BYTES,
|
||||
);
|
||||
const config = normalizeConfig(parseJson(kubernetesConfigBytes));
|
||||
kubeconfigBytes = readPrivateFile(
|
||||
config.kubeconfigFile,
|
||||
MAX_KUBECONFIG_BYTES,
|
||||
);
|
||||
const kubernetes = await import('@kubernetes/client-node');
|
||||
const kubeConfig = new kubernetes.KubeConfig();
|
||||
try {
|
||||
const kubeconfigText = decodeUtf8(kubeconfigBytes);
|
||||
validateRawKubeconfig(parseJson(kubeconfigBytes), config);
|
||||
kubeConfig.loadFromString(kubeconfigText);
|
||||
validateKubeConfig(kubeConfig, config);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
}
|
||||
const { config, kubeConfig, kubernetes } = prepared;
|
||||
const runtime = (options.createRuntime ?? productionRuntime)(
|
||||
kubeConfig,
|
||||
kubernetes,
|
||||
@@ -790,7 +871,6 @@ export async function executeClusterPluginPackageManagementKubernetesClient(
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
kubernetesConfigBytes?.fill(0);
|
||||
kubeconfigBytes?.fill(0);
|
||||
prepared?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user