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
@@ -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(