feat(ql3): expose cancellation status card

This commit is contained in:
whyour
2026-08-19 09:21:50 +08:00
parent 5a979e510e
commit 095683a4cb
15 changed files with 741 additions and 32 deletions
@@ -49,6 +49,12 @@ export interface ClusterPluginPackageManagementClientPaths {
readonly assertionFile: string;
}
export interface ClusterAuthenticatedManagementCommandExecution<Command> {
readonly configFile: string;
readonly assertionFile: string;
readonly command: Command;
}
export interface ClusterPluginPackageManagementClientResult {
readonly schemaVersion: 1;
readonly requestId: string;
@@ -1119,11 +1125,23 @@ export async function executeClusterAuthenticatedManagementClient<
Command,
Result,
>(
paths: ClusterPluginPackageManagementClientPaths,
execution:
| ClusterPluginPackageManagementClientPaths
| ClusterAuthenticatedManagementCommandExecution<Command>,
protocol: ClusterAuthenticatedManagementClientProtocol<Command, Result>,
connectionOptions?: ClusterPluginPackageManagementClientConnectionOptions,
): Promise<Readonly<ClusterAuthenticatedManagementClientResult<Result>>> {
exactObject(paths, ['configFile', 'commandFile', 'assertionFile']);
const inlineCommand =
execution !== null &&
typeof execution === 'object' &&
!Array.isArray(execution) &&
Object.hasOwn(execution, 'command');
exactObject(
execution,
inlineCommand
? ['configFile', 'command', 'assertionFile']
: ['configFile', 'commandFile', 'assertionFile'],
);
if (
!protocol ||
typeof protocol !== 'object' ||
@@ -1160,17 +1178,19 @@ export async function executeClusterAuthenticatedManagementClient<
| undefined;
try {
prepared = prepareClusterAuthenticatedManagementClientConfiguration(
paths.configFile,
execution.configFile,
protocol.managementPath,
protocol.clientCertificate,
);
commandBytes = readCanonicalFile(
paths.commandFile,
MAX_COMMAND_BYTES,
'private',
);
if (!inlineCommand) {
commandBytes = readCanonicalFile(
(execution as ClusterPluginPackageManagementClientPaths).commandFile,
MAX_COMMAND_BYTES,
'private',
);
}
assertionBytes = readCanonicalFile(
paths.assertionFile,
execution.assertionFile,
MAX_ASSERTION_BYTES,
'private',
);
@@ -1183,7 +1203,12 @@ export async function executeClusterAuthenticatedManagementClient<
clientCertificateBytes,
clientPrivateKeyBytes,
} = prepared;
const command = protocol.normalizeCommand(parseJson(commandBytes));
const command = protocol.normalizeCommand(
inlineCommand
? (execution as ClusterAuthenticatedManagementCommandExecution<Command>)
.command
: parseJson(commandBytes!),
);
const assertion = assertionBytes.toString('ascii');
if (
assertionBytes.some((byte) => byte > 0x7f) ||
@@ -87,7 +87,7 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc
name: 'run',
binary: 'ql3-run-client',
target: 'run-management/runManagementClientCli.js',
description: 'retry or stop Runs under strong authentication',
description: 'observe, retry or stop Runs under strong authentication',
}),
Object.freeze({
name: 'automation',
@@ -0,0 +1,127 @@
import { randomUUID } from 'node:crypto';
import type { ClusterRunManagementClientResult } from './runManagementClient';
import {
RUN_CANCELLATION_DISPATCH_SUMMARY_REQUEST_SCHEMA,
normalizeClusterRunManagementCommand,
type ClusterRunManagementCancellationSummaryCommand,
type ClusterRunManagementCancellationSummaryTransportResult,
} from './runManagementTransport';
export const RUN_CANCELLATION_STATUS_SCHEMA =
'qinglong/run-cancellation-status@v1' as const;
export type RunCancellationStatusExitCode = 0 | 10 | 20;
export type RunCancellationStatusSeverity = 'ok' | 'warning' | 'critical';
type CancellationSummary =
ClusterRunManagementCancellationSummaryTransportResult['summary'];
export interface RunCancellationStatusObservation {
readonly schemaVersion: 1;
readonly schema: typeof RUN_CANCELLATION_STATUS_SCHEMA;
readonly component: 'qinglong3-run-management-client';
readonly event: 'cancellation_status_observed';
readonly requestId: string;
readonly projectId: string;
readonly observedAtMs: number;
readonly assessment: CancellationSummary['assessment'];
readonly operatorAction: CancellationSummary['operatorAction'];
readonly severity: RunCancellationStatusSeverity;
readonly exitCode: RunCancellationStatusExitCode;
readonly dispatches: CancellationSummary['dispatches'];
readonly signals: CancellationSummary['signals'];
readonly blockingResults: CancellationSummary['blockingResults'];
readonly oldestBlockedAtMs?: number;
}
export function createRunCancellationStatusCommand(
projectId: string,
createUuid: () => string = randomUUID,
): Readonly<ClusterRunManagementCancellationSummaryCommand> {
const requestId = createUuid();
const auditEventId = createUuid();
let failureAuditEventId = createUuid();
for (
let attempts = 0;
failureAuditEventId === auditEventId && attempts < 3;
attempts += 1
) {
failureAuditEventId = createUuid();
}
return normalizeClusterRunManagementCommand({
schemaVersion: 1,
operation: 'run.cancellation.summary',
request: {
projectId,
requestId,
auditEventId,
failureAuditEventId,
body: { schema: RUN_CANCELLATION_DISPATCH_SUMMARY_REQUEST_SCHEMA },
},
}) as Readonly<ClusterRunManagementCancellationSummaryCommand>;
}
export function projectRunCancellationStatus(
result: Readonly<ClusterRunManagementClientResult>,
): Readonly<RunCancellationStatusObservation> {
if (result.result.operation !== 'run.cancellation.summary') {
throw new TypeError('Run cancellation status requires a summary result');
}
const summary = result.result.summary;
const severity: RunCancellationStatusSeverity =
summary.assessment === 'clear'
? 'ok'
: summary.assessment === 'converging'
? 'warning'
: 'critical';
const exitCode: RunCancellationStatusExitCode =
severity === 'ok' ? 0 : severity === 'warning' ? 10 : 20;
return Object.freeze({
schemaVersion: 1,
schema: RUN_CANCELLATION_STATUS_SCHEMA,
component: 'qinglong3-run-management-client',
event: 'cancellation_status_observed',
requestId: result.requestId,
projectId: summary.projectId,
observedAtMs: summary.observedAtMs,
assessment: summary.assessment,
operatorAction: summary.operatorAction,
severity,
exitCode,
dispatches: summary.dispatches,
signals: summary.signals,
blockingResults: summary.blockingResults,
...(summary.oldestBlockedAtMs === undefined
? {}
: { oldestBlockedAtMs: summary.oldestBlockedAtMs }),
});
}
function label(value: string): string {
return value.replaceAll('_', ' ').toUpperCase();
}
export function formatRunCancellationStatusCard(
status: Readonly<RunCancellationStatusObservation>,
): string {
const dispatch = status.dispatches;
const blocking = status.blockingResults;
return [
'QingLong 3.0 / Cancellation Availability',
`PROJECT ${status.projectId}`,
`ASSESSMENT ${label(status.assessment)}`,
`ACTION ${label(status.operatorAction)}`,
`ALERT ${status.severity.toUpperCase()} (exit ${status.exitCode})`,
`OBSERVED ${new Date(status.observedAtMs).toISOString()}`,
`DISPATCHES total=${dispatch.total} pending=${dispatch.pending} leased=${dispatch.leased} retry_wait=${dispatch.retryWait} dispatched=${dispatch.dispatched} blocked=${dispatch.blocked}`,
`SIGNALS due=${status.signals.due} expired_lease=${status.signals.expiredLease}`,
`BLOCKING identity_mismatch=${blocking.identityMismatch} pid_mismatch=${blocking.pidMismatch} unsupported=${blocking.unsupported} invalid=${blocking.invalid}`,
`OLDEST_BLOCK ${
status.oldestBlockedAtMs === undefined
? '-'
: new Date(status.oldestBlockedAtMs).toISOString()
}`,
`REQUEST ${status.requestId}`,
].join('\n');
}
@@ -14,6 +14,7 @@ import {
import {
ClusterPluginPackageManagementClientRequestError,
executeClusterAuthenticatedManagementClient,
type ClusterAuthenticatedManagementCommandExecution,
type ClusterAuthenticatedManagementClientResult,
type ClusterPluginPackageManagementClientConnectionOptions,
type ClusterPluginPackageManagementClientPaths,
@@ -35,6 +36,8 @@ export type ClusterRunManagementClientConnectionOptions =
ClusterPluginPackageManagementClientConnectionOptions;
export type ClusterRunManagementClientResult =
ClusterAuthenticatedManagementClientResult<ClusterRunManagementTransportResult>;
export type ClusterRunManagementCommandExecution =
ClusterAuthenticatedManagementCommandExecution<ClusterRunManagementCommand>;
function invalid(): never {
throw new ClusterPluginPackageManagementClientRequestError();
@@ -427,3 +430,14 @@ export function executeClusterRunManagementClient(
connectionOptions,
);
}
export function executeClusterRunManagementCommand(
execution: ClusterRunManagementCommandExecution,
connectionOptions?: ClusterRunManagementClientConnectionOptions,
): Promise<Readonly<ClusterRunManagementClientResult>> {
return executeClusterAuthenticatedManagementClient(
execution,
PROTOCOL,
connectionOptions,
);
}
@@ -1,16 +1,73 @@
#!/usr/bin/env node
import { ClusterPluginPackageManagementClientRemoteError } from '../management-support/pluginPackageManagementClient';
import { executeClusterRunManagementClient } from './runManagementClient';
import {
executeClusterRunManagementClient,
executeClusterRunManagementCommand,
} from './runManagementClient';
import {
createRunCancellationStatusCommand,
formatRunCancellationStatusCard,
projectRunCancellationStatus,
} from './runCancellationStatus';
const USAGE =
'Usage: ql3-run-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt';
const USAGE = [
'Usage: ql3-run-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt',
' ql3-run-client status --config=/absolute/client.json --assertion=/absolute/assertion.jwt --project=PROJECT [--format=text|json]',
'',
'Status exit codes: 0=clear, 10=converging, 20=attention_required.',
].join('\n');
const PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
function argumentsFrom(argv: readonly string[]): Readonly<{
configFile: string;
commandFile: string;
assertionFile: string;
}> | null {
type RunManagementClientArguments =
| Readonly<{
kind: 'command';
configFile: string;
commandFile: string;
assertionFile: string;
}>
| Readonly<{
kind: 'status';
configFile: string;
assertionFile: string;
projectId: string;
format: 'text' | 'json';
}>;
function argumentsFrom(
argv: readonly string[],
): Readonly<RunManagementClientArguments> | null {
const statusCount = argv.filter((argument) => argument === 'status').length;
if (statusCount > 0) {
if (statusCount !== 1 || argv.length < 4 || argv.length > 5) return null;
const values = new Map<string, string>();
for (const argument of argv) {
if (argument === 'status') continue;
const match = /^--(config|assertion|project|format)=(.+)$/.exec(argument);
if (!match || values.has(match[1]!)) return null;
values.set(match[1]!, match[2]!);
}
if (
!values.has('config') ||
!values.get('config')!.startsWith('/') ||
!values.has('assertion') ||
!values.get('assertion')!.startsWith('/') ||
!values.has('project') ||
!PROJECT_ID.test(values.get('project')!) ||
(values.has('format') &&
values.get('format') !== 'text' &&
values.get('format') !== 'json')
) {
return null;
}
return Object.freeze({
kind: 'status',
configFile: values.get('config')!,
assertionFile: values.get('assertion')!,
projectId: values.get('project')!,
format: (values.get('format') ?? 'text') as 'text' | 'json',
});
}
if (argv.length !== 3) return null;
const values = new Map<string, string>();
for (const argument of argv) {
@@ -18,8 +75,14 @@ function argumentsFrom(argv: readonly string[]): Readonly<{
if (!match || values.has(match[1]!)) return null;
values.set(match[1]!, match[2]!);
}
if (!values.has('config') || !values.has('command') || !values.has('assertion')) return null;
if (
!values.has('config') ||
!values.has('command') ||
!values.has('assertion')
)
return null;
return Object.freeze({
kind: 'command',
configFile: values.get('config')!,
commandFile: values.get('command')!,
assertionFile: values.get('assertion')!,
@@ -32,13 +95,18 @@ function failureFact(error: unknown): Readonly<Record<string, unknown>> {
schemaVersion: 1,
component: 'qinglong3-run-management-client',
event: 'command_failed',
code: typeof candidate?.code === 'string' ? candidate.code : 'QL3_RUN_MANAGEMENT_CLIENT_FAILED',
code:
typeof candidate?.code === 'string'
? candidate.code
: 'QL3_RUN_MANAGEMENT_CLIENT_FAILED',
...(error instanceof ClusterPluginPackageManagementClientRemoteError
? {
statusCode: error.statusCode,
responseCode: error.responseCode,
requestId: error.requestId,
...(error.retryAfterSeconds === null ? {} : { retryAfterSeconds: error.retryAfterSeconds }),
...(error.retryAfterSeconds === null
? {}
: { retryAfterSeconds: error.retryAfterSeconds }),
}
: {}),
});
@@ -51,13 +119,43 @@ async function run(argv: readonly string[]): Promise<void> {
}
const paths = argumentsFrom(argv);
if (!paths) {
process.stderr.write(`${JSON.stringify({ schemaVersion: 1, component: 'qinglong3-run-management-client', event: 'usage_invalid', code: 'QL3_RUN_MANAGEMENT_CLIENT_USAGE_INVALID' })}\n`);
process.stderr.write(
`${JSON.stringify({
schemaVersion: 1,
component: 'qinglong3-run-management-client',
event: 'usage_invalid',
code: 'QL3_RUN_MANAGEMENT_CLIENT_USAGE_INVALID',
})}\n`,
);
process.exitCode = 64;
return;
}
try {
if (paths.kind === 'status') {
const result = await executeClusterRunManagementCommand({
configFile: paths.configFile,
assertionFile: paths.assertionFile,
command: createRunCancellationStatusCommand(paths.projectId),
});
const status = projectRunCancellationStatus(result);
process.stdout.write(
paths.format === 'json'
? `${JSON.stringify(status)}\n`
: `${formatRunCancellationStatusCard(status)}\n`,
);
process.exitCode = status.exitCode;
return;
}
const result = await executeClusterRunManagementClient(paths);
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, component: 'qinglong3-run-management-client', event: 'command_completed', requestId: result.requestId, result: result.result })}\n`);
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
component: 'qinglong3-run-management-client',
event: 'command_completed',
requestId: result.requestId,
result: result.result,
})}\n`,
);
} catch (error) {
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
process.exitCode = 1;