feat(ql3): add read-only cluster context readiness

This commit is contained in:
whyour
2026-08-13 02:14:17 +08:00
parent a83b4c5e8d
commit a6ad636251
18 changed files with 1529 additions and 391 deletions
@@ -4,8 +4,12 @@ import { spawn, type ChildProcess } from 'node:child_process';
import { constants } from 'node:os';
import { resolveQingLong3ClusterProductCommand } from './productCommand';
import { QingLong3ClusterProductContextError } from './productContext';
import { validateQingLong3ClusterProductContext } from './productContext';
import {
probeQingLong3ClusterProductContext,
QingLong3ClusterProductContextError,
QingLong3ClusterProductContextProbeError,
validateQingLong3ClusterProductContext,
} from './productContext';
const FORWARDED_SIGNALS = Object.freeze([
'SIGINT',
@@ -132,6 +136,14 @@ async function main(argv: readonly string[]): Promise<void> {
process.stdout.write(`${JSON.stringify(result)}\n`);
return;
}
if (resolution.kind === 'context-probe') {
const result = await probeQingLong3ClusterProductContext(
resolution.contextFile,
);
process.stdout.write(`${JSON.stringify(result)}\n`);
if (!result.allReady) process.exitCode = 69;
return;
}
invoke(resolution.targetFilePath, resolution.argv);
} catch (error) {
if (
@@ -152,6 +164,24 @@ async function main(argv: readonly string[]): Promise<void> {
process.exitCode = 78;
return;
}
if (
error instanceof QingLong3ClusterProductContextProbeError ||
(error !== null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'QL3_CLUSTER_PRODUCT_CONTEXT_PROBE_FAILED')
) {
process.stderr.write(
`${JSON.stringify(
lowSensitivityFailure(
'QL3_CLUSTER_PRODUCT_CONTEXT_PROBE_FAILED',
'QingLong 3.0 Cluster operator context probe failed',
),
)}\n`,
);
process.exitCode = 69;
return;
}
process.stderr.write(
`${JSON.stringify(
lowSensitivityFailure(
@@ -14,6 +14,7 @@ export type QingLong3ClusterProductCommandResolution =
| Readonly<{ kind: 'help'; output: string }>
| Readonly<{ kind: 'version'; output: string }>
| Readonly<{ kind: 'context-validation'; contextFile: string }>
| Readonly<{ kind: 'context-probe'; contextFile: string }>
| Readonly<{
kind: 'invoke';
command: QingLong3ClusterProductCommandDefinition;
@@ -175,6 +176,7 @@ export function qingLong3ClusterProductHelp(): string {
'',
'Local operator commands:',
' context validate --context=/absolute/operator-context.json',
' context probe --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.',
@@ -200,7 +202,7 @@ export function resolveQingLong3ClusterProductCommand(
if (argv[0] === 'context') {
if (
argv.length !== 3 ||
argv[1] !== 'validate' ||
(argv[1] !== 'validate' && argv[1] !== 'probe') ||
!argv[2]!.startsWith('--context=') ||
argv[2] === '--context='
) {
@@ -215,7 +217,7 @@ export function resolveQingLong3ClusterProductCommand(
resolveInstalledTarget(distRoot, definition);
}
return Object.freeze({
kind: 'context-validation',
kind: argv[1] === 'validate' ? 'context-validation' : 'context-probe',
contextFile: argv[2]!.slice('--context='.length),
});
}
@@ -10,9 +10,15 @@ import {
import { isAbsolute } from 'node:path';
import { TextDecoder } from 'node:util';
import { validateClusterAuthenticatedManagementClientConfiguration } from '../management-support/pluginPackageManagementClient';
import {
validateClusterAuthenticatedManagementClientConfiguration,
} from '../management-support/pluginPackageManagementClient';
import type { ClusterAuthenticatedManagementClientKind } from '../management-support/pluginPackageManagementClient';
import { validateClusterPluginPackageManagementKubernetesConfiguration } from '../plugin-package/management/pluginPackageManagementKubernetesClient';
import { probeClusterAuthenticatedManagementClientReadiness } from '../management-support/managementReadinessProbe';
import {
probeClusterPluginPackageManagementKubernetesReadiness,
validateClusterPluginPackageManagementKubernetesConfiguration,
} from '../plugin-package/management/pluginPackageManagementKubernetesClient';
const MAXIMUM_CONTEXT_BYTES = 64 * 1024;
const MAXIMUM_PATH_BYTES = 4_096;
@@ -57,6 +63,22 @@ export interface QingLong3ClusterProductContextValidation {
readonly mutation: false;
}
export interface QingLong3ClusterProductContextProbe {
readonly schemaVersion: 1;
readonly component: 'qinglong3-cluster-product-cli';
readonly event: 'context_probed';
readonly commandCount: number;
readonly commands: readonly Readonly<{
name: ContextCommandName;
transport: 'https' | 'kubernetes-port-forward';
status: 'ready' | 'not_ready';
}>[];
readonly allReady: boolean;
readonly requestMethod: 'GET';
readonly requestPath: '/readyz';
readonly mutation: false;
}
const CONTEXT_COMMAND_CLIENT_KINDS: Readonly<
Record<ContextCommandName, ClusterAuthenticatedManagementClientKind>
> = Object.freeze({
@@ -78,6 +100,15 @@ export class QingLong3ClusterProductContextError extends TypeError {
}
}
export class QingLong3ClusterProductContextProbeError extends Error {
readonly code = 'QL3_CLUSTER_PRODUCT_CONTEXT_PROBE_FAILED';
constructor() {
super('QingLong 3.0 Cluster operator context probe failed');
this.name = 'QingLong3ClusterProductContextProbeError';
}
}
function invalid(): never {
throw new QingLong3ClusterProductContextError();
}
@@ -328,3 +359,62 @@ export async function validateQingLong3ClusterProductContext(
throw new QingLong3ClusterProductContextError();
}
}
export async function probeQingLong3ClusterProductContext(
contextFile: string,
): Promise<Readonly<QingLong3ClusterProductContextProbe>> {
try {
await validateQingLong3ClusterProductContext(contextFile);
const context = loadQingLong3ClusterProductContext(contextFile);
const commands: Array<
QingLong3ClusterProductContextProbe['commands'][number]
> = [];
for (const name of CONTEXT_COMMANDS) {
const command = context.commands[name];
if (command === undefined) continue;
const result =
name === 'package-kubernetes'
? await probeClusterPluginPackageManagementKubernetesReadiness(
command.configFile,
command.kubernetesFile!,
)
: await probeClusterAuthenticatedManagementClientReadiness(
command.configFile,
CONTEXT_COMMAND_CLIENT_KINDS[name],
);
commands.push(
Object.freeze({
name,
transport:
name === 'package-kubernetes'
? 'kubernetes-port-forward'
: result.transport,
status: result.ready ? 'ready' : 'not_ready',
}),
);
}
return Object.freeze({
schemaVersion: 1,
component: 'qinglong3-cluster-product-cli',
event: 'context_probed',
commandCount: commands.length,
commands: Object.freeze(commands),
allReady: commands.every(({ status }) => status === 'ready'),
requestMethod: 'GET',
requestPath: '/readyz',
mutation: false,
});
} catch (error) {
if (error instanceof QingLong3ClusterProductContextError) throw error;
if (
error !== null &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string' &&
error.code.endsWith('CONFIG_INVALID')
) {
throw new QingLong3ClusterProductContextError();
}
throw new QingLong3ClusterProductContextProbeError();
}
}