mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): add read-only cluster context readiness
This commit is contained in:
@@ -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