mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add read-only cluster context readiness
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
/** Package-private configuration preparation shared by management clients. */
|
||||
import { createPrivateKey, X509Certificate } from 'node:crypto';
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
} from 'node:fs';
|
||||
import { isIP } from 'node:net';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { TextDecoder } from 'node:util';
|
||||
|
||||
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: '/api/v3/plugin-packages/management',
|
||||
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 MAXIMUM_CONFIG_BYTES = 16 * 1024;
|
||||
const MAXIMUM_CA_BYTES = 256 * 1024;
|
||||
const MAXIMUM_CLIENT_CERTIFICATE_BYTES = 256 * 1024;
|
||||
const MAXIMUM_CLIENT_PRIVATE_KEY_BYTES = 256 * 1024;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const DNS_NAME_PATTERN =
|
||||
/^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export interface ClusterAuthenticatedManagementClientConfigurationSummary {
|
||||
readonly schemaVersion: 1;
|
||||
readonly managementPath: string;
|
||||
readonly transport: 'https';
|
||||
readonly clientCertificate: 'forbidden' | 'required';
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementClientConfigurationError extends TypeError {
|
||||
readonly code = 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_CONFIG_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Plugin Package management client configuration is invalid');
|
||||
this.name = 'ClusterPluginPackageManagementClientConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
function configurationFailure(): ClusterPluginPackageManagementClientConfigurationError {
|
||||
return new ClusterPluginPackageManagementClientConfigurationError();
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
): asserts value is JsonObject {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (typeof process.getuid !== 'function') throw configurationFailure();
|
||||
const uid = process.getuid();
|
||||
if (!Number.isSafeInteger(uid) || uid < 0) throw configurationFailure();
|
||||
return uid;
|
||||
}
|
||||
|
||||
export function readCanonicalFile(
|
||||
filePath: string,
|
||||
maximumBytes: number,
|
||||
mode: 'private' | 'public-integrity',
|
||||
): Buffer {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
!isAbsolute(filePath) ||
|
||||
filePath.length > 4_096 ||
|
||||
CONTROL_PATTERN.test(filePath)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
let before;
|
||||
try {
|
||||
before = lstatSync(filePath);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
before.size < 1 ||
|
||||
before.size > maximumBytes ||
|
||||
realpathSync(filePath) !== filePath
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterPluginPackageManagementClientConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
}
|
||||
const uid = currentUid();
|
||||
const permissions = before.mode & 0o777;
|
||||
if (
|
||||
(mode === 'private' && (before.uid !== uid || permissions !== 0o600)) ||
|
||||
(mode === 'public-integrity' && before.uid !== uid && before.uid !== 0) ||
|
||||
(mode === 'public-integrity' && (permissions & 0o022) !== 0)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
|
||||
let descriptor = -1;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
descriptor = openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY |
|
||||
((constants as unknown as Readonly<Record<string, number>>).O_CLOEXEC ??
|
||||
0) |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fstatSync(descriptor);
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.uid !== before.uid ||
|
||||
opened.mode !== before.mode ||
|
||||
opened.size !== before.size
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
bytes = Buffer.alloc(opened.size);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const count = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (count < 1) throw configurationFailure();
|
||||
offset += count;
|
||||
}
|
||||
const after = fstatSync(descriptor);
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.uid !== opened.uid ||
|
||||
after.mode !== opened.mode ||
|
||||
after.size !== opened.size
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
bytes?.fill(0);
|
||||
if (error instanceof ClusterPluginPackageManagementClientConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
} finally {
|
||||
if (descriptor >= 0) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer): unknown {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
export function isReviewedClusterAuthenticatedManagementClientProtocol(
|
||||
managementPath: string,
|
||||
clientCertificate: 'forbidden' | 'required',
|
||||
): boolean {
|
||||
return Object.values(MANAGEMENT_CLIENT_POLICIES).some(
|
||||
(policy) =>
|
||||
policy.managementPath === managementPath &&
|
||||
policy.clientCertificate === clientCertificate,
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareClusterAuthenticatedManagementClientConfiguration(
|
||||
configFile: string,
|
||||
managementPath: string,
|
||||
clientCertificate: 'forbidden' | 'required',
|
||||
): PreparedClusterAuthenticatedManagementClientConfiguration {
|
||||
if (
|
||||
!isReviewedClusterAuthenticatedManagementClientProtocol(
|
||||
managementPath,
|
||||
clientCertificate,
|
||||
)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
let configBytes: Buffer | undefined;
|
||||
let caBytes: Buffer | undefined;
|
||||
let clientCertificateBytes: Buffer | undefined;
|
||||
let clientPrivateKeyBytes: Buffer | undefined;
|
||||
try {
|
||||
configBytes = readCanonicalFile(
|
||||
configFile,
|
||||
MAXIMUM_CONFIG_BYTES,
|
||||
'private',
|
||||
);
|
||||
const config = parseJson(configBytes);
|
||||
exactObject(
|
||||
config,
|
||||
clientCertificate === 'required'
|
||||
? [
|
||||
'schemaVersion',
|
||||
'endpoint',
|
||||
'servername',
|
||||
'caFile',
|
||||
'clientCertificateFile',
|
||||
'clientPrivateKeyFile',
|
||||
'requestTimeoutMs',
|
||||
]
|
||||
: [
|
||||
'schemaVersion',
|
||||
'endpoint',
|
||||
'servername',
|
||||
'caFile',
|
||||
'requestTimeoutMs',
|
||||
],
|
||||
);
|
||||
if (
|
||||
config.schemaVersion !== 1 ||
|
||||
typeof config.endpoint !== 'string' ||
|
||||
typeof config.servername !== 'string' ||
|
||||
!DNS_NAME_PATTERN.test(config.servername) ||
|
||||
isIP(config.servername) !== 0 ||
|
||||
typeof config.caFile !== 'string' ||
|
||||
(clientCertificate === 'required' &&
|
||||
(typeof config.clientCertificateFile !== 'string' ||
|
||||
typeof config.clientPrivateKeyFile !== 'string')) ||
|
||||
!Number.isSafeInteger(config.requestTimeoutMs) ||
|
||||
(config.requestTimeoutMs as number) < 1_000 ||
|
||||
(config.requestTimeoutMs as number) > 30_000
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const servername = config.servername;
|
||||
const requestTimeoutMs = config.requestTimeoutMs as number;
|
||||
let endpoint: URL;
|
||||
try {
|
||||
endpoint = new URL(config.endpoint);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
if (
|
||||
endpoint.protocol !== 'https:' ||
|
||||
endpoint.username !== '' ||
|
||||
endpoint.password !== '' ||
|
||||
endpoint.search !== '' ||
|
||||
endpoint.hash !== '' ||
|
||||
endpoint.pathname !== managementPath ||
|
||||
endpoint.hostname !== servername ||
|
||||
isIP(endpoint.hostname) !== 0
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const port = endpoint.port === '' ? 443 : Number(endpoint.port);
|
||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
caBytes = readCanonicalFile(
|
||||
config.caFile as string,
|
||||
MAXIMUM_CA_BYTES,
|
||||
'public-integrity',
|
||||
);
|
||||
try {
|
||||
new X509Certificate(caBytes);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
if (clientCertificate === 'required') {
|
||||
clientCertificateBytes = readCanonicalFile(
|
||||
config.clientCertificateFile as string,
|
||||
MAXIMUM_CLIENT_CERTIFICATE_BYTES,
|
||||
'public-integrity',
|
||||
);
|
||||
clientPrivateKeyBytes = readCanonicalFile(
|
||||
config.clientPrivateKeyFile as string,
|
||||
MAXIMUM_CLIENT_PRIVATE_KEY_BYTES,
|
||||
'private',
|
||||
);
|
||||
try {
|
||||
const certificate = new X509Certificate(clientCertificateBytes);
|
||||
const privateKey = createPrivateKey(clientPrivateKeyBytes);
|
||||
if (!certificate.checkPrivateKey(privateKey)) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterPluginPackageManagementClientConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
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 prepareClusterAuthenticatedManagementClientKindConfiguration(
|
||||
configFile: string,
|
||||
kind: ClusterAuthenticatedManagementClientKind,
|
||||
): PreparedClusterAuthenticatedManagementClientConfiguration {
|
||||
const policy = MANAGEMENT_CLIENT_POLICIES[kind];
|
||||
if (policy === undefined) throw configurationFailure();
|
||||
return prepareClusterAuthenticatedManagementClientConfiguration(
|
||||
configFile,
|
||||
policy.managementPath,
|
||||
policy.clientCertificate,
|
||||
);
|
||||
}
|
||||
|
||||
export function validateClusterAuthenticatedManagementClientConfiguration(
|
||||
configFile: string,
|
||||
kind: ClusterAuthenticatedManagementClientKind,
|
||||
): Readonly<ClusterAuthenticatedManagementClientConfigurationSummary> {
|
||||
const policy = MANAGEMENT_CLIENT_POLICIES[kind];
|
||||
if (policy === undefined) throw configurationFailure();
|
||||
const prepared =
|
||||
prepareClusterAuthenticatedManagementClientKindConfiguration(
|
||||
configFile,
|
||||
kind,
|
||||
);
|
||||
try {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
managementPath: policy.managementPath,
|
||||
transport: 'https',
|
||||
clientCertificate: policy.clientCertificate,
|
||||
});
|
||||
} finally {
|
||||
prepared.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/** Bounded read-only readiness probe for reviewed cluster management clients. */
|
||||
import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
|
||||
import { Duplex } from 'node:stream';
|
||||
import { connect as tlsConnect } from 'node:tls';
|
||||
import { TextDecoder } from 'node:util';
|
||||
|
||||
import {
|
||||
ClusterPluginPackageManagementClientRequestError,
|
||||
type ClusterAuthenticatedManagementClientKind,
|
||||
type ClusterPluginPackageManagementClientConnectionOptions,
|
||||
type ClusterPluginPackageManagementClientRawConnection,
|
||||
} from './pluginPackageManagementClient';
|
||||
import {
|
||||
ClusterPluginPackageManagementClientConfigurationError,
|
||||
prepareClusterAuthenticatedManagementClientKindConfiguration,
|
||||
type PreparedClusterAuthenticatedManagementClientConfiguration,
|
||||
} from './managementClientConfiguration';
|
||||
|
||||
const MAXIMUM_RESPONSE_BYTES = 1_024;
|
||||
|
||||
export interface ClusterAuthenticatedManagementClientReadiness {
|
||||
readonly schemaVersion: 1;
|
||||
readonly transport: 'https';
|
||||
readonly ready: boolean;
|
||||
}
|
||||
|
||||
function configurationFailure(): ClusterPluginPackageManagementClientConfigurationError {
|
||||
return new ClusterPluginPackageManagementClientConfigurationError();
|
||||
}
|
||||
|
||||
function rawHeaderCount(rawHeaders: readonly string[], name: string): number {
|
||||
let count = 0;
|
||||
for (let index = 0; index < rawHeaders.length; index += 2) {
|
||||
if (rawHeaders[index]?.toLowerCase() === name) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function readinessEnvelope(
|
||||
value: unknown,
|
||||
): Readonly<{ schemaVersion: unknown; status: unknown }> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ClusterPluginPackageManagementClientRequestError();
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
if (
|
||||
keys.length !== 2 ||
|
||||
keys[0] !== 'schemaVersion' ||
|
||||
keys[1] !== 'status'
|
||||
) {
|
||||
throw new ClusterPluginPackageManagementClientRequestError();
|
||||
}
|
||||
return value as Readonly<{ schemaVersion: unknown; status: unknown }>;
|
||||
}
|
||||
|
||||
function connectionOptionsValid(
|
||||
value: ClusterPluginPackageManagementClientConnectionOptions | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
(!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 1 &&
|
||||
typeof value.connect === 'function')
|
||||
);
|
||||
}
|
||||
|
||||
export async function probeClusterAuthenticatedManagementClientReadiness(
|
||||
configFile: string,
|
||||
kind: ClusterAuthenticatedManagementClientKind,
|
||||
connectionOptions?: ClusterPluginPackageManagementClientConnectionOptions,
|
||||
): Promise<Readonly<ClusterAuthenticatedManagementClientReadiness>> {
|
||||
if (!connectionOptionsValid(connectionOptions)) throw configurationFailure();
|
||||
let prepared:
|
||||
| PreparedClusterAuthenticatedManagementClientConfiguration
|
||||
| undefined;
|
||||
let rawConnection:
|
||||
| ClusterPluginPackageManagementClientRawConnection
|
||||
| undefined;
|
||||
let connectionAgent: HttpsAgent | undefined;
|
||||
try {
|
||||
prepared = prepareClusterAuthenticatedManagementClientKindConfiguration(
|
||||
configFile,
|
||||
kind,
|
||||
);
|
||||
const {
|
||||
endpoint,
|
||||
servername,
|
||||
port,
|
||||
requestTimeoutMs,
|
||||
caBytes,
|
||||
clientCertificateBytes,
|
||||
clientPrivateKeyBytes,
|
||||
} = prepared;
|
||||
if (connectionOptions) {
|
||||
rawConnection = await connectionOptions.connect(
|
||||
Object.freeze({ hostname: endpoint.hostname, port }),
|
||||
);
|
||||
if (
|
||||
!rawConnection ||
|
||||
typeof rawConnection !== 'object' ||
|
||||
!(rawConnection.stream instanceof Duplex) ||
|
||||
typeof rawConnection.close !== 'function'
|
||||
) {
|
||||
throw new ClusterPluginPackageManagementClientRequestError();
|
||||
}
|
||||
const establishedConnection = rawConnection;
|
||||
connectionAgent = new HttpsAgent({
|
||||
keepAlive: false,
|
||||
maxSockets: 1,
|
||||
maxFreeSockets: 0,
|
||||
});
|
||||
connectionAgent.createConnection = (_options, callback) => {
|
||||
const socket = tlsConnect({
|
||||
socket: establishedConnection.stream,
|
||||
ca: caBytes,
|
||||
...(clientCertificateBytes === undefined
|
||||
? {}
|
||||
: {
|
||||
cert: clientCertificateBytes,
|
||||
key: clientPrivateKeyBytes,
|
||||
}),
|
||||
servername,
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
if (callback) {
|
||||
let reported = false;
|
||||
socket.once('secureConnect', () => {
|
||||
if (reported) return;
|
||||
reported = true;
|
||||
callback(null, socket);
|
||||
});
|
||||
socket.once('error', (error) => {
|
||||
if (reported) return;
|
||||
reported = true;
|
||||
callback(error, socket);
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
};
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
const clearChunks = () => {
|
||||
for (const chunk of chunks) chunk.fill(0);
|
||||
};
|
||||
const finish = (
|
||||
error: unknown,
|
||||
result?: Readonly<ClusterAuthenticatedManagementClientReadiness>,
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (error) reject(error);
|
||||
else resolve(result!);
|
||||
};
|
||||
const request = httpsRequest(
|
||||
{
|
||||
protocol: 'https:',
|
||||
hostname: endpoint.hostname,
|
||||
port,
|
||||
path: '/readyz',
|
||||
method: 'GET',
|
||||
servername,
|
||||
ca: caBytes,
|
||||
...(clientCertificateBytes === undefined
|
||||
? {}
|
||||
: {
|
||||
cert: clientCertificateBytes,
|
||||
key: clientPrivateKeyBytes,
|
||||
}),
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
rejectUnauthorized: true,
|
||||
agent: connectionAgent ?? false,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'accept-encoding': 'identity',
|
||||
connection: 'close',
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
response.once('aborted', () => {
|
||||
clearChunks();
|
||||
finish(new ClusterPluginPackageManagementClientRequestError());
|
||||
});
|
||||
response.once('error', (error) => {
|
||||
clearChunks();
|
||||
finish(
|
||||
new ClusterPluginPackageManagementClientRequestError(error),
|
||||
);
|
||||
});
|
||||
response.on('data', (chunk: Buffer | string) => {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
length += bytes.length;
|
||||
if (length > MAXIMUM_RESPONSE_BYTES) {
|
||||
const error =
|
||||
new ClusterPluginPackageManagementClientRequestError();
|
||||
bytes.fill(0);
|
||||
clearChunks();
|
||||
response.destroy();
|
||||
request.destroy(error);
|
||||
finish(error);
|
||||
return;
|
||||
}
|
||||
chunks.push(bytes);
|
||||
});
|
||||
response.once('end', () => {
|
||||
try {
|
||||
if (
|
||||
rawHeaderCount(response.rawHeaders, 'content-type') !== 1 ||
|
||||
response.headers['content-type'] !==
|
||||
'application/json; charset=utf-8' ||
|
||||
response.headers['content-encoding'] !== undefined ||
|
||||
rawHeaderCount(response.rawHeaders, 'content-length') > 1 ||
|
||||
(response.headers['content-length'] !== undefined &&
|
||||
(!/^(?:0|[1-9][0-9]*)$/.test(
|
||||
response.headers['content-length'],
|
||||
) ||
|
||||
Number(response.headers['content-length']) !== length))
|
||||
) {
|
||||
throw new ClusterPluginPackageManagementClientRequestError();
|
||||
}
|
||||
const bytes = Buffer.concat(chunks, length);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
|
||||
);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
clearChunks();
|
||||
}
|
||||
const record = readinessEnvelope(parsed);
|
||||
const ready =
|
||||
response.statusCode === 200 && record.status === 'ready';
|
||||
const notReady =
|
||||
response.statusCode === 503 &&
|
||||
record.status === 'not_ready';
|
||||
if (record.schemaVersion !== 1 || (!ready && !notReady)) {
|
||||
throw new ClusterPluginPackageManagementClientRequestError();
|
||||
}
|
||||
finish(
|
||||
undefined,
|
||||
Object.freeze({
|
||||
schemaVersion: 1,
|
||||
transport: 'https',
|
||||
ready,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
finish(
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementClientRequestError
|
||||
? error
|
||||
: new ClusterPluginPackageManagementClientRequestError(
|
||||
error,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
request.setTimeout(requestTimeoutMs, () => {
|
||||
request.destroy(
|
||||
new ClusterPluginPackageManagementClientRequestError(),
|
||||
);
|
||||
});
|
||||
request.once('error', (error) => {
|
||||
clearChunks();
|
||||
finish(
|
||||
error instanceof ClusterPluginPackageManagementClientRequestError
|
||||
? error
|
||||
: new ClusterPluginPackageManagementClientRequestError(error),
|
||||
);
|
||||
});
|
||||
request.end();
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ClusterPluginPackageManagementClientConfigurationError ||
|
||||
error instanceof ClusterPluginPackageManagementClientRequestError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterPluginPackageManagementClientRequestError(error);
|
||||
} finally {
|
||||
connectionAgent?.destroy();
|
||||
try {
|
||||
await rawConnection?.close();
|
||||
} catch {
|
||||
// Probe outcome remains authoritative after bounded resource cleanup.
|
||||
}
|
||||
prepared?.dispose();
|
||||
}
|
||||
}
|
||||
+23
-376
@@ -1,20 +1,28 @@
|
||||
/** Shared one-shot authenticated client boundary for cluster management planes. */
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
} from 'node:fs';
|
||||
import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { Duplex } from 'node:stream';
|
||||
import { connect as tlsConnect } from 'node:tls';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import { createPrivateKey, X509Certificate } from 'node:crypto';
|
||||
import {
|
||||
ClusterPluginPackageManagementClientConfigurationError,
|
||||
isReviewedClusterAuthenticatedManagementClientProtocol,
|
||||
prepareClusterAuthenticatedManagementClientConfiguration,
|
||||
readCanonicalFile,
|
||||
validateClusterAuthenticatedManagementClientConfiguration,
|
||||
type ClusterAuthenticatedManagementClientConfigurationSummary,
|
||||
type ClusterAuthenticatedManagementClientKind,
|
||||
type PreparedClusterAuthenticatedManagementClientConfiguration,
|
||||
} from './managementClientConfiguration';
|
||||
|
||||
export {
|
||||
ClusterPluginPackageManagementClientConfigurationError,
|
||||
readCanonicalFile,
|
||||
validateClusterAuthenticatedManagementClientConfiguration,
|
||||
};
|
||||
export type {
|
||||
ClusterAuthenticatedManagementClientConfigurationSummary,
|
||||
ClusterAuthenticatedManagementClientKind,
|
||||
};
|
||||
|
||||
import {
|
||||
normalizeClusterPluginPackageManagementCommand,
|
||||
@@ -23,59 +31,11 @@ import {
|
||||
} from '../plugin-package/management/pluginPackageManagementTransport';
|
||||
|
||||
const MANAGEMENT_PATH = '/api/v3/plugin-packages/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;
|
||||
const MAX_CA_BYTES = 256 * 1024;
|
||||
const MAX_CLIENT_CERTIFICATE_BYTES = 256 * 1024;
|
||||
const MAX_CLIENT_PRIVATE_KEY_BYTES = 256 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 128 * 1024;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const PACKAGE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const DNS_NAME_PATTERN =
|
||||
/^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
|
||||
const ASSERTION_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
@@ -123,15 +83,6 @@ export interface ClusterPluginPackageManagementClientConnectionOptions {
|
||||
): Promise<ClusterPluginPackageManagementClientRawConnection>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementClientConfigurationError extends TypeError {
|
||||
readonly code = 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_CONFIG_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Plugin Package management client configuration is invalid');
|
||||
this.name = 'ClusterPluginPackageManagementClientConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementClientRequestError extends Error {
|
||||
readonly code = 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_REQUEST_FAILED';
|
||||
|
||||
@@ -176,114 +127,6 @@ function exactObject(
|
||||
}
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (typeof process.getuid !== 'function') throw configurationFailure();
|
||||
const uid = process.getuid();
|
||||
if (!Number.isSafeInteger(uid) || uid < 0) throw configurationFailure();
|
||||
return uid;
|
||||
}
|
||||
|
||||
export function readCanonicalFile(
|
||||
filePath: string,
|
||||
maximumBytes: number,
|
||||
mode: 'private' | 'public-integrity',
|
||||
): Buffer {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
!isAbsolute(filePath) ||
|
||||
filePath.length > 4_096 ||
|
||||
CONTROL_PATTERN.test(filePath)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
let before;
|
||||
try {
|
||||
before = lstatSync(filePath);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
before.size < 1 ||
|
||||
before.size > maximumBytes ||
|
||||
realpathSync(filePath) !== filePath
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ClusterPluginPackageManagementClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
}
|
||||
const uid = currentUid();
|
||||
const permissions = before.mode & 0o777;
|
||||
if (
|
||||
(mode === 'private' && (before.uid !== uid || permissions !== 0o600)) ||
|
||||
(mode === 'public-integrity' && before.uid !== uid && before.uid !== 0) ||
|
||||
(mode === 'public-integrity' && (permissions & 0o022) !== 0)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
|
||||
let descriptor = -1;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
descriptor = openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY |
|
||||
((constants as unknown as Readonly<Record<string, number>>).O_CLOEXEC ??
|
||||
0) |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fstatSync(descriptor);
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.uid !== before.uid ||
|
||||
opened.mode !== before.mode ||
|
||||
opened.size !== before.size
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
bytes = Buffer.alloc(opened.size);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const count = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (count < 1) throw configurationFailure();
|
||||
offset += count;
|
||||
}
|
||||
const after = fstatSync(descriptor);
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.uid !== opened.uid ||
|
||||
after.mode !== opened.mode ||
|
||||
after.size !== opened.size
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
bytes?.fill(0);
|
||||
if (
|
||||
error instanceof ClusterPluginPackageManagementClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw configurationFailure();
|
||||
} finally {
|
||||
if (descriptor >= 0) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUtf8(bytes: Buffer): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
@@ -812,201 +655,6 @@ function rawHeaderCount(rawHeaders: readonly string[], name: string): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
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 (
|
||||
!Object.values(MANAGEMENT_CLIENT_POLICIES).some(
|
||||
(policy) =>
|
||||
policy.managementPath === managementPath &&
|
||||
policy.clientCertificate === clientCertificate,
|
||||
)
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
let configBytes: Buffer | undefined;
|
||||
let caBytes: Buffer | undefined;
|
||||
let clientCertificateBytes: Buffer | undefined;
|
||||
let clientPrivateKeyBytes: Buffer | undefined;
|
||||
try {
|
||||
configBytes = readCanonicalFile(configFile, MAX_CONFIG_BYTES, 'private');
|
||||
const config = parseJson(configBytes);
|
||||
exactObject(
|
||||
config,
|
||||
clientCertificate === 'required'
|
||||
? [
|
||||
'schemaVersion',
|
||||
'endpoint',
|
||||
'servername',
|
||||
'caFile',
|
||||
'clientCertificateFile',
|
||||
'clientPrivateKeyFile',
|
||||
'requestTimeoutMs',
|
||||
]
|
||||
: [
|
||||
'schemaVersion',
|
||||
'endpoint',
|
||||
'servername',
|
||||
'caFile',
|
||||
'requestTimeoutMs',
|
||||
],
|
||||
);
|
||||
if (
|
||||
config.schemaVersion !== 1 ||
|
||||
typeof config.endpoint !== 'string' ||
|
||||
typeof config.servername !== 'string' ||
|
||||
!DNS_NAME_PATTERN.test(config.servername) ||
|
||||
isIP(config.servername) !== 0 ||
|
||||
typeof config.caFile !== 'string' ||
|
||||
(clientCertificate === 'required' &&
|
||||
(typeof config.clientCertificateFile !== 'string' ||
|
||||
typeof config.clientPrivateKeyFile !== 'string')) ||
|
||||
!Number.isSafeInteger(config.requestTimeoutMs) ||
|
||||
(config.requestTimeoutMs as number) < 1_000 ||
|
||||
(config.requestTimeoutMs as number) > 30_000
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const servername = config.servername;
|
||||
const requestTimeoutMs = config.requestTimeoutMs as number;
|
||||
let endpoint: URL;
|
||||
try {
|
||||
endpoint = new URL(config.endpoint);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
if (
|
||||
endpoint.protocol !== 'https:' ||
|
||||
endpoint.username !== '' ||
|
||||
endpoint.password !== '' ||
|
||||
endpoint.search !== '' ||
|
||||
endpoint.hash !== '' ||
|
||||
endpoint.pathname !== managementPath ||
|
||||
endpoint.hostname !== servername ||
|
||||
isIP(endpoint.hostname) !== 0
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const port = endpoint.port === '' ? 443 : Number(endpoint.port);
|
||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
caBytes = readCanonicalFile(
|
||||
config.caFile as string,
|
||||
MAX_CA_BYTES,
|
||||
'public-integrity',
|
||||
);
|
||||
try {
|
||||
new X509Certificate(caBytes);
|
||||
} catch {
|
||||
throw configurationFailure();
|
||||
}
|
||||
if (clientCertificate === 'required') {
|
||||
clientCertificateBytes = readCanonicalFile(
|
||||
config.clientCertificateFile as string,
|
||||
MAX_CLIENT_CERTIFICATE_BYTES,
|
||||
'public-integrity',
|
||||
);
|
||||
clientPrivateKeyBytes = readCanonicalFile(
|
||||
config.clientPrivateKeyFile as string,
|
||||
MAX_CLIENT_PRIVATE_KEY_BYTES,
|
||||
'private',
|
||||
);
|
||||
try {
|
||||
const certificate = new X509Certificate(clientCertificateBytes);
|
||||
const privateKey = createPrivateKey(clientPrivateKeyBytes);
|
||||
if (!certificate.checkPrivateKey(privateKey)) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementClientConfigurationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
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,
|
||||
@@ -1030,10 +678,9 @@ export async function executeClusterAuthenticatedManagementClient<
|
||||
'validateResult',
|
||||
].includes(key),
|
||||
) ||
|
||||
!Object.values(MANAGEMENT_CLIENT_POLICIES).some(
|
||||
(policy) =>
|
||||
policy.managementPath === protocol.managementPath &&
|
||||
policy.clientCertificate === protocol.clientCertificate,
|
||||
!isReviewedClusterAuthenticatedManagementClientProtocol(
|
||||
protocol.managementPath,
|
||||
protocol.clientCertificate,
|
||||
) ||
|
||||
typeof protocol.normalizeCommand !== 'function' ||
|
||||
typeof protocol.validateResult !== 'function' ||
|
||||
|
||||
+95
@@ -16,6 +16,10 @@ import {
|
||||
type ClusterPluginPackageManagementClientRawConnection,
|
||||
type ClusterPluginPackageManagementClientResult,
|
||||
} from '../../management-support/pluginPackageManagementClient';
|
||||
import {
|
||||
probeClusterAuthenticatedManagementClientReadiness,
|
||||
type ClusterAuthenticatedManagementClientReadiness,
|
||||
} from '../../management-support/managementReadinessProbe';
|
||||
|
||||
const MAX_KUBERNETES_CONFIG_BYTES = 16 * 1024;
|
||||
const MAX_KUBECONFIG_BYTES = 256 * 1024;
|
||||
@@ -874,3 +878,94 @@ export async function executeClusterPluginPackageManagementKubernetesClient(
|
||||
prepared?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeClusterPluginPackageManagementKubernetesReadiness(
|
||||
configFile: string,
|
||||
kubernetesFile: string,
|
||||
options: ClusterPluginPackageManagementKubernetesClientOptions = {},
|
||||
): Promise<Readonly<ClusterAuthenticatedManagementClientReadiness>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'createRuntime') ||
|
||||
(options.createRuntime !== undefined &&
|
||||
typeof options.createRuntime !== 'function')
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
let prepared: PreparedKubernetesClientConfiguration | undefined;
|
||||
try {
|
||||
prepared = await prepareKubernetesClientConfiguration(kubernetesFile);
|
||||
const { config, kubeConfig, kubernetes } = prepared;
|
||||
const runtime = (options.createRuntime ?? productionRuntime)(
|
||||
kubeConfig,
|
||||
kubernetes,
|
||||
);
|
||||
if (
|
||||
!runtime ||
|
||||
typeof runtime !== 'object' ||
|
||||
typeof runtime.pods?.listNamespacedPod !== 'function' ||
|
||||
typeof runtime.openPortForward !== 'function'
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const expectedHostname = `${MANAGEMENT_NAME}.${config.namespace}.svc`;
|
||||
return await probeClusterAuthenticatedManagementClientReadiness(
|
||||
configFile,
|
||||
'package',
|
||||
{
|
||||
async connect(target) {
|
||||
if (
|
||||
target.hostname !== expectedHostname ||
|
||||
target.port !== MANAGEMENT_PORT
|
||||
) {
|
||||
throw configurationFailure();
|
||||
}
|
||||
const list = await deadline(
|
||||
runtime.pods.listNamespacedPod({
|
||||
namespace: config.namespace,
|
||||
labelSelector: MANAGEMENT_LABEL_SELECTOR,
|
||||
limit: 3,
|
||||
timeoutSeconds: Math.ceil(config.apiTimeoutMs / 1_000),
|
||||
watch: false,
|
||||
}),
|
||||
config.apiTimeoutMs,
|
||||
);
|
||||
const podName = selectManagementPod(list, config.namespace);
|
||||
return await deadline(
|
||||
runtime.openPortForward({
|
||||
namespace: config.namespace,
|
||||
podName,
|
||||
port: MANAGEMENT_PORT,
|
||||
}),
|
||||
config.apiTimeoutMs,
|
||||
async (connection) => {
|
||||
await connection.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ClusterPluginPackageManagementClientRequestError &&
|
||||
error.cause instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientTunnelError
|
||||
) {
|
||||
throw error.cause;
|
||||
}
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementKubernetesClientConfigurationError ||
|
||||
error instanceof ClusterPluginPackageManagementKubernetesClientTunnelError ||
|
||||
error instanceof ClusterPluginPackageManagementClientConfigurationError ||
|
||||
error instanceof ClusterPluginPackageManagementClientRequestError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterPluginPackageManagementKubernetesClientTunnelError(error);
|
||||
} finally {
|
||||
prepared?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ const {
|
||||
ClusterPluginPackageManagementClientRequestError,
|
||||
executeClusterPluginPackageManagementClient,
|
||||
} = require('@qinglong/cluster-admin/plugin-package-management-client');
|
||||
const publicClientModule = require('@qinglong/cluster-admin/plugin-package-management-client');
|
||||
const {
|
||||
probeClusterAuthenticatedManagementClientReadiness,
|
||||
} = require('../dist/management-support/managementReadinessProbe.js');
|
||||
|
||||
const CA_CERT = resolve(
|
||||
__dirname,
|
||||
@@ -33,12 +37,35 @@ const SERVER_CERT = resolve(
|
||||
__dirname,
|
||||
'../../ql3-cluster-control/test/fixtures/mtls/server-cert.pem',
|
||||
);
|
||||
const CLIENT_KEY = resolve(
|
||||
__dirname,
|
||||
'../../ql3-cluster-control/test/fixtures/mtls/client-key.pem',
|
||||
);
|
||||
const CLIENT_CERT = resolve(
|
||||
__dirname,
|
||||
'../../ql3-cluster-control/test/fixtures/mtls/client-cert.pem',
|
||||
);
|
||||
const CLIENT_CLI = resolve(
|
||||
__dirname,
|
||||
'../dist/plugin-package/management/pluginPackageManagementClientCli.js',
|
||||
);
|
||||
const ASSERTION = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJvcGVyYXRvciJ9.c2ln';
|
||||
|
||||
test('keeps owned TLS preparation out of the public client subpath', () => {
|
||||
assert.equal(
|
||||
publicClientModule.prepareClusterAuthenticatedManagementClientConfiguration,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
publicClientModule.prepareClusterAuthenticatedManagementClientKindConfiguration,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
publicClientModule.probeClusterAuthenticatedManagementClientReadiness,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
function inspectCommand(operation = 'plugin-package.inspect') {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
@@ -392,13 +419,14 @@ function createClientFiles(port, command = inspectCommand()) {
|
||||
};
|
||||
}
|
||||
|
||||
async function startServer(handler) {
|
||||
async function startServer(handler, options = {}) {
|
||||
const server = createServer(
|
||||
{
|
||||
key: readFileSync(SERVER_KEY),
|
||||
cert: readFileSync(SERVER_CERT),
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
...options,
|
||||
},
|
||||
handler,
|
||||
);
|
||||
@@ -475,6 +503,142 @@ test('sends one TLS 1.3 management command and validates the low-sensitive resul
|
||||
}
|
||||
});
|
||||
|
||||
test('probes only the fixed TLS readiness endpoint without management authority', async () => {
|
||||
const received = [];
|
||||
let ready = true;
|
||||
const fixture = await startServer((request, response) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(chunk));
|
||||
request.once('end', () => {
|
||||
received.push({
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
authorization: request.headers.authorization,
|
||||
contentType: request.headers['content-type'],
|
||||
bodyBytes: Buffer.concat(chunks).length,
|
||||
protocol: request.socket.getProtocol(),
|
||||
});
|
||||
sendJson(response, ready ? 200 : 503, {
|
||||
schemaVersion: 1,
|
||||
status: ready ? 'ready' : 'not_ready',
|
||||
});
|
||||
});
|
||||
});
|
||||
const files = createClientFiles(fixture.port);
|
||||
try {
|
||||
assert.deepEqual(
|
||||
await probeClusterAuthenticatedManagementClientReadiness(
|
||||
files.paths.configFile,
|
||||
'package',
|
||||
),
|
||||
{ schemaVersion: 1, transport: 'https', ready: true },
|
||||
);
|
||||
ready = false;
|
||||
assert.deepEqual(
|
||||
await probeClusterAuthenticatedManagementClientReadiness(
|
||||
files.paths.configFile,
|
||||
'package',
|
||||
),
|
||||
{ schemaVersion: 1, transport: 'https', ready: false },
|
||||
);
|
||||
assert.deepEqual(received, [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/readyz',
|
||||
authorization: undefined,
|
||||
contentType: undefined,
|
||||
bodyBytes: 0,
|
||||
protocol: 'TLSv1.3',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/readyz',
|
||||
authorization: undefined,
|
||||
contentType: undefined,
|
||||
bodyBytes: 0,
|
||||
protocol: 'TLSv1.3',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
rmSync(files.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('presents the reviewed client certificate for mTLS readiness', async () => {
|
||||
let authorized = false;
|
||||
const fixture = await startServer(
|
||||
(request, response) => {
|
||||
authorized = request.socket.authorized;
|
||||
sendJson(response, 200, { schemaVersion: 1, status: 'ready' });
|
||||
},
|
||||
{
|
||||
ca: readFileSync(CA_CERT),
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
);
|
||||
const files = createClientFiles(fixture.port);
|
||||
const clientKeyFile = join(files.directory, 'client-key.pem');
|
||||
privateWrite(clientKeyFile, readFileSync(CLIENT_KEY, 'utf8'));
|
||||
privateWrite(files.paths.configFile, {
|
||||
schemaVersion: 1,
|
||||
endpoint: `https://localhost:${fixture.port}/api/v3/runs/management`,
|
||||
servername: 'localhost',
|
||||
caFile: CA_CERT,
|
||||
clientCertificateFile: CLIENT_CERT,
|
||||
clientPrivateKeyFile: clientKeyFile,
|
||||
requestTimeoutMs: 1_000,
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(
|
||||
await probeClusterAuthenticatedManagementClientReadiness(
|
||||
files.paths.configFile,
|
||||
'run',
|
||||
),
|
||||
{ schemaVersion: 1, transport: 'https', ready: true },
|
||||
);
|
||||
assert.equal(authorized, true);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
rmSync(files.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readiness probe rejects unreviewed status and bounded response drift', async () => {
|
||||
let behavior = 'wrong-status';
|
||||
const fixture = await startServer((_request, response) => {
|
||||
if (behavior === 'wrong-status') {
|
||||
sendJson(response, 200, { schemaVersion: 1, status: 'live' });
|
||||
return;
|
||||
}
|
||||
if (behavior === 'redirect') {
|
||||
sendJson(response, 302, { schemaVersion: 1, status: 'ready' });
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
});
|
||||
response.end(Buffer.alloc(1_025, 0x61));
|
||||
});
|
||||
const files = createClientFiles(fixture.port);
|
||||
try {
|
||||
for (const next of ['wrong-status', 'redirect', 'oversized']) {
|
||||
behavior = next;
|
||||
await assert.rejects(
|
||||
probeClusterAuthenticatedManagementClientReadiness(
|
||||
files.paths.configFile,
|
||||
'package',
|
||||
),
|
||||
ClusterPluginPackageManagementClientRequestError,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await fixture.close();
|
||||
rmSync(files.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('permits and validates exactly the fourteen public management operations', async () => {
|
||||
const received = [];
|
||||
const fixture = await startServer((request, response) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
ClusterPluginPackageManagementKubernetesClientTunnelError,
|
||||
executeClusterPluginPackageManagementKubernetesClient,
|
||||
openClusterPluginPackageManagementPortForward,
|
||||
probeClusterPluginPackageManagementKubernetesReadiness,
|
||||
} = require(
|
||||
'@qinglong/cluster-admin/plugin-package-management-kubernetes-client'
|
||||
);
|
||||
@@ -302,6 +303,85 @@ test('uses one ready Pod tunnel and preserves end-to-end TLS 1.3 hostname verifi
|
||||
}
|
||||
});
|
||||
|
||||
test('probes readiness through one reviewed Pod tunnel without an assertion or command', async () => {
|
||||
const requests = [];
|
||||
const server = await startServer((request, response) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(chunk));
|
||||
request.once('end', () => {
|
||||
requests.push({
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
authorization: request.headers.authorization,
|
||||
bodyBytes: Buffer.concat(chunks).length,
|
||||
protocol: request.socket.getProtocol(),
|
||||
servername: request.socket.servername,
|
||||
});
|
||||
sendJson(response, { schemaVersion: 1, status: 'ready' });
|
||||
});
|
||||
});
|
||||
const files = createClientFiles();
|
||||
const calls = { lists: 0, tunnels: 0, closes: 0 };
|
||||
try {
|
||||
const result =
|
||||
await probeClusterPluginPackageManagementKubernetesReadiness(
|
||||
files.paths.configFile,
|
||||
files.paths.kubernetesFile,
|
||||
{
|
||||
createRuntime() {
|
||||
return {
|
||||
pods: {
|
||||
async listNamespacedPod() {
|
||||
calls.lists += 1;
|
||||
return {
|
||||
items: [
|
||||
readyPod(
|
||||
'ql3-plugin-package-management-aaaaa-11111',
|
||||
),
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
async openPortForward() {
|
||||
calls.tunnels += 1;
|
||||
const stream = connectTcp({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
});
|
||||
return {
|
||||
stream,
|
||||
close() {
|
||||
calls.closes += 1;
|
||||
stream.end();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
schemaVersion: 1,
|
||||
transport: 'https',
|
||||
ready: true,
|
||||
});
|
||||
assert.deepEqual(calls, { lists: 1, tunnels: 1, closes: 1 });
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/readyz',
|
||||
authorization: undefined,
|
||||
bodyBytes: 0,
|
||||
protocol: 'TLSv1.3',
|
||||
servername: SERVICE_HOST,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await server.close();
|
||||
rmSync(files.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects ambient, executable, proxied, insecure, and file-backed kubeconfig authority', async () => {
|
||||
const cases = [
|
||||
kubeconfig({ cluster: { 'insecure-skip-tls-verify': true } }),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { createServer } = require('node:https');
|
||||
|
||||
const packageRoot = path.resolve(__dirname, '..');
|
||||
const moduleDirectory = path.join(packageRoot, 'dist', 'product-cli');
|
||||
@@ -21,6 +22,18 @@ const privateKeyFixture = path.join(
|
||||
'fixtures',
|
||||
'management-service-key.pem',
|
||||
);
|
||||
const localhostCertificateFixture = path.resolve(
|
||||
packageRoot,
|
||||
'../ql3-cluster-control/test/fixtures/mtls/server-cert.pem',
|
||||
);
|
||||
const localhostCaFixture = path.resolve(
|
||||
packageRoot,
|
||||
'../ql3-cluster-control/test/fixtures/mtls/ca-cert.pem',
|
||||
);
|
||||
const localhostPrivateKeyFixture = path.resolve(
|
||||
packageRoot,
|
||||
'../ql3-cluster-control/test/fixtures/mtls/server-key.pem',
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'),
|
||||
);
|
||||
@@ -49,6 +62,65 @@ function runCli(args) {
|
||||
});
|
||||
}
|
||||
|
||||
function runCliAsync(args) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(process.execPath, [cliPath, ...args], {
|
||||
cwd: packageRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||
child.once('error', reject);
|
||||
child.once('close', (status, signal) => {
|
||||
resolvePromise({
|
||||
status,
|
||||
signal,
|
||||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||
stderr: Buffer.concat(stderr).toString('utf8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startReadinessServer(status) {
|
||||
const server = createServer(
|
||||
{
|
||||
key: fs.readFileSync(localhostPrivateKeyFixture),
|
||||
cert: fs.readFileSync(localhostCertificateFixture),
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
},
|
||||
(request, response) => {
|
||||
assert.equal(request.method, 'GET');
|
||||
assert.equal(request.url, '/readyz');
|
||||
assert.equal(request.headers.authorization, undefined);
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({ schemaVersion: 1, status: status.value }),
|
||||
);
|
||||
response.writeHead(status.value === 'ready' ? 200 : 503, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'content-length': String(body.length),
|
||||
});
|
||||
response.end(body);
|
||||
},
|
||||
);
|
||||
await new Promise((resolvePromise, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolvePromise);
|
||||
});
|
||||
return {
|
||||
port: server.address().port,
|
||||
close: () =>
|
||||
new Promise((resolvePromise, reject) => {
|
||||
server.close((error) =>
|
||||
error ? reject(error) : resolvePromise(),
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function privateFile(directory, name, contents) {
|
||||
const filePath = path.join(directory, name);
|
||||
fs.writeFileSync(filePath, contents, { mode: 0o600 });
|
||||
@@ -521,6 +593,84 @@ test('validates the complete operator context offline without operational author
|
||||
assert.equal(validated.stdout.includes('bounded-token'), false);
|
||||
});
|
||||
|
||||
test('probes a context with fixed read-only readiness semantics and exit status', async (t) => {
|
||||
const status = { value: 'ready' };
|
||||
const server = await startReadinessServer(status);
|
||||
t.after(() => server.close());
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-probe-context-')),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const caFile = privateFile(
|
||||
directory,
|
||||
'ca.pem',
|
||||
fs.readFileSync(localhostCaFixture),
|
||||
);
|
||||
const configFile = privateFile(
|
||||
directory,
|
||||
'package.json',
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
endpoint: `https://localhost:${server.port}/api/v3/plugin-packages/management`,
|
||||
servername: 'localhost',
|
||||
caFile,
|
||||
requestTimeoutMs: 1_000,
|
||||
}),
|
||||
);
|
||||
const contextFile = privateFile(
|
||||
directory,
|
||||
'operator-context.json',
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
commands: { package: { configFile } },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveQingLong3ClusterProductCommand(
|
||||
['context', 'probe', `--context=${contextFile}`],
|
||||
moduleDirectory,
|
||||
),
|
||||
{ kind: 'context-probe', contextFile },
|
||||
);
|
||||
|
||||
const ready = await runCliAsync([
|
||||
'context',
|
||||
'probe',
|
||||
`--context=${contextFile}`,
|
||||
]);
|
||||
assert.equal(
|
||||
ready.status,
|
||||
0,
|
||||
JSON.stringify({ stdout: ready.stdout, stderr: ready.stderr }),
|
||||
);
|
||||
assert.equal(ready.stderr, '');
|
||||
assert.deepEqual(JSON.parse(ready.stdout), {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-product-cli',
|
||||
event: 'context_probed',
|
||||
commandCount: 1,
|
||||
commands: [{ name: 'package', transport: 'https', status: 'ready' }],
|
||||
allReady: true,
|
||||
requestMethod: 'GET',
|
||||
requestPath: '/readyz',
|
||||
mutation: false,
|
||||
});
|
||||
assert.equal(ready.stdout.includes(directory), false);
|
||||
assert.equal(ready.stdout.includes('localhost'), false);
|
||||
|
||||
status.value = 'not_ready';
|
||||
const notReady = await runCliAsync([
|
||||
'context',
|
||||
'probe',
|
||||
`--context=${contextFile}`,
|
||||
]);
|
||||
assert.equal(notReady.status, 69);
|
||||
assert.equal(notReady.stderr, '');
|
||||
const fact = JSON.parse(notReady.stdout);
|
||||
assert.equal(fact.allReady, false);
|
||||
assert.equal(fact.commands[0].status, 'not_ready');
|
||||
});
|
||||
|
||||
test('context validation fails closed for invalid client configuration and syntax', (t) => {
|
||||
const fixture = validContextFixture(t);
|
||||
fs.writeFileSync(fixture.commands.run.configFile, '{}', { mode: 0o600 });
|
||||
@@ -544,6 +694,8 @@ test('context validation fails closed for invalid client configuration and synta
|
||||
['context', 'validate'],
|
||||
['context', 'validate', '--context'],
|
||||
['context', 'validate', '--context='],
|
||||
['context', 'probe'],
|
||||
['context', 'probe', '--context='],
|
||||
['context', 'inspect', `--context=${fixture.contextFile}`],
|
||||
]) {
|
||||
const rejected = resolveQingLong3ClusterProductCommand(
|
||||
|
||||
Reference in New Issue
Block a user