Files
qinglong/packages/ql3-cluster-admin/test/productCli.test.cjs
T

676 lines
20 KiB
JavaScript

const assert = require('node:assert/strict');
const { 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 packageRoot = path.resolve(__dirname, '..');
const moduleDirectory = path.join(packageRoot, 'dist', 'product-cli');
const cliPath = path.join(moduleDirectory, 'cli.js');
const certificateFixture = path.join(
packageRoot,
'test',
'fixtures',
'management-service-cert.pem',
);
const privateKeyFixture = path.join(
packageRoot,
'test',
'fixtures',
'management-service-key.pem',
);
const manifest = JSON.parse(
fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'),
);
const {
QINGLONG3_CLUSTER_PRODUCT_COMMANDS,
loadQingLong3ClusterProductVersion,
qingLong3ClusterProductHelp,
resolveQingLong3ClusterProductCommand,
} = require('../dist/product-cli/productCommand.js');
const {
clusterProductSignalExitCode,
forwardClusterProductSignals,
} = require('../dist/product-cli/cli.js');
const {
loadQingLong3ClusterProductContext,
resolveQingLong3ClusterProductContextArguments,
} = require('../dist/product-cli/productContext.js');
const {
validateClusterAuthenticatedManagementClientConfiguration,
} = require('../dist/management-support/pluginPackageManagementClient.js');
function runCli(args) {
return spawnSync(process.execPath, [cliPath, ...args], {
cwd: packageRoot,
encoding: 'utf8',
});
}
function privateFile(directory, name, contents) {
const filePath = path.join(directory, name);
fs.writeFileSync(filePath, contents, { mode: 0o600 });
return filePath;
}
function contextFixture(t) {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-context-')),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const runConfig = privateFile(directory, 'run-client.json', '{}');
const packageConfig = privateFile(directory, 'package-client.json', '{}');
const kubernetes = privateFile(directory, 'kubernetes.json', '{}');
const contextFile = privateFile(
directory,
'operator-context.json',
JSON.stringify({
schemaVersion: 1,
commands: {
run: { configFile: runConfig },
'package-kubernetes': {
configFile: packageConfig,
kubernetesFile: kubernetes,
},
},
}),
);
return { directory, runConfig, packageConfig, kubernetes, contextFile };
}
function validContextFixture(t) {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-valid-context-')),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const caFile = privateFile(
directory,
'management-ca.pem',
fs.readFileSync(certificateFixture),
);
const clientCertificateFile = privateFile(
directory,
'operator-certificate.pem',
fs.readFileSync(certificateFixture),
);
const clientPrivateKeyFile = privateFile(
directory,
'operator-private-key.pem',
fs.readFileSync(privateKeyFixture),
);
function config(name, managementPath, clientCertificate) {
return privateFile(
directory,
`${name}.json`,
JSON.stringify({
schemaVersion: 1,
endpoint: `https://manager.example.test:8443${managementPath}`,
servername: 'manager.example.test',
caFile,
...(clientCertificate === 'required'
? { clientCertificateFile, clientPrivateKeyFile }
: {}),
requestTimeoutMs: 1_000,
}),
);
}
const packageConfig = config(
'package-client',
'/api/v3/plugin-packages/management',
'forbidden',
);
const kubeconfigFile = privateFile(
directory,
'kubeconfig.json',
JSON.stringify({
apiVersion: 'v1',
kind: 'Config',
clusters: [
{
name: 'production',
cluster: {
server: 'https://kubernetes.example.test:6443',
'certificate-authority-data': fs
.readFileSync(certificateFixture)
.toString('base64'),
},
},
],
users: [{ name: 'operator', user: { token: 'bounded-token' } }],
contexts: [
{
name: 'production',
context: {
cluster: 'production',
user: 'operator',
namespace: 'qinglong3',
},
},
],
'current-context': 'production',
}),
);
const kubernetesFile = privateFile(
directory,
'kubernetes-client.json',
JSON.stringify({
schemaVersion: 1,
kubeconfigFile,
context: 'production',
namespace: 'qinglong3',
apiTimeoutMs: 1_000,
}),
);
const commands = {
package: { configFile: packageConfig },
'package-kubernetes': { configFile: packageConfig, kubernetesFile },
'worker-credential': {
configFile: config(
'worker-client',
'/api/v3/worker-credentials/management',
'required',
),
},
approval: {
configFile: config(
'approval-client',
'/api/v3/approvals/management',
'required',
),
},
run: {
configFile: config('run-client', '/api/v3/runs/management', 'required'),
},
automation: {
configFile: config(
'automation-client',
'/api/v3/automations/management',
'required',
),
},
'model-credential': {
configFile: config(
'provider-client',
'/api/v3/provider-credentials/management',
'required',
),
},
};
return {
directory,
contextFile: privateFile(
directory,
'operator-context.json',
JSON.stringify({ schemaVersion: 1, commands }),
),
commands,
};
}
test('catalog exposes only reviewed remote clients from the same package', () => {
assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 7);
assert.equal(
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
);
assert.equal(
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ binary }) => binary))
.size,
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
);
for (const command of QINGLONG3_CLUSTER_PRODUCT_COMMANDS) {
assert.equal(manifest.bin[command.binary], `dist/${command.target}`);
assert.equal(
fs.lstatSync(path.join(packageRoot, 'dist', command.target)).isFile(),
true,
);
assert.equal(command.binary.includes('-client'), true);
}
for (const forbidden of [
'ql3-cluster-migrate',
'ql3-plugin-package-recover',
'ql3-plugin-package-manage',
'ql3-plugin-package-execute',
'ql3-worker-credential-manage',
'ql3-worker-credential-execute',
'ql3-prompt-output-key-rotate',
'ql3-prompt-output-gc',
]) {
assert.equal(
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.some(
({ binary }) => binary === forbidden,
),
false,
);
}
});
test('help and version are bounded installation-derived product facts', () => {
const help = qingLong3ClusterProductHelp();
assert.match(help, /^Usage: ql3-cluster-admin <command> \[arguments\]/);
assert.match(help, /\n run\s+retry or stop Runs/);
assert.match(help, /Server, migration, recovery, executor and key-custody/);
assert.equal(help.includes('plugin-package-manage'), false);
assert.equal(
loadQingLong3ClusterProductVersion(moduleDirectory),
manifest.version,
);
assert.deepEqual(resolveQingLong3ClusterProductCommand([], moduleDirectory), {
kind: 'help',
output: help,
});
assert.deepEqual(
resolveQingLong3ClusterProductCommand(['--version'], moduleDirectory),
{ kind: 'version', output: manifest.version },
);
});
test('resolves only static remote-client targets and preserves opaque arguments', () => {
const argv = [
'run',
'--config=/private/client config.json',
'--literal=$() && *',
];
const result = resolveQingLong3ClusterProductCommand(argv, moduleDirectory);
assert.equal(result.kind, 'invoke');
assert.equal(result.command.binary, 'ql3-run-client');
assert.equal(
result.targetFilePath,
path.join(
packageRoot,
'dist',
'run-management',
'runManagementClientCli.js',
),
);
assert.deepEqual(result.argv, argv.slice(1));
assert.equal(Object.isFrozen(result.argv), true);
assert.equal(Object.isFrozen(result), true);
for (const candidate of [
'../../tmp/owned',
'/absolute/command',
'run/../../owned',
'run-manage',
'migrate',
'execute',
]) {
const rejected = resolveQingLong3ClusterProductCommand(
[candidate, '--help'],
moduleDirectory,
);
assert.equal(rejected.kind, 'invalid');
assert.equal(rejected.code, 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID');
}
});
test('injects only stable paths from an explicit owner-private operator context', (t) => {
const fixture = contextFixture(t);
const context = loadQingLong3ClusterProductContext(fixture.contextFile);
assert.deepEqual(context, {
schemaVersion: 1,
commands: {
run: { configFile: fixture.runConfig },
'package-kubernetes': {
configFile: fixture.packageConfig,
kubernetesFile: fixture.kubernetes,
},
},
});
assert.equal(Object.isFrozen(context), true);
assert.equal(Object.isFrozen(context.commands), true);
assert.equal(Object.isFrozen(context.commands.run), true);
const run = resolveQingLong3ClusterProductCommand(
[
'run',
`--context=${fixture.contextFile}`,
'--command=/private/command.json',
'--assertion=/private/assertion.jwt',
],
moduleDirectory,
);
assert.equal(run.kind, 'invoke');
assert.deepEqual(run.argv, [
`--config=${fixture.runConfig}`,
'--command=/private/command.json',
'--assertion=/private/assertion.jwt',
]);
const tunnel = resolveQingLong3ClusterProductContextArguments(
fixture.contextFile,
'package-kubernetes',
['--command=/private/command.json', '--assertion=/private/assertion.jwt'],
);
assert.deepEqual(tunnel, [
`--config=${fixture.packageConfig}`,
`--kubernetes=${fixture.kubernetes}`,
'--command=/private/command.json',
'--assertion=/private/assertion.jwt',
]);
});
test('operator context rejects weak files, unknown or secret fields and argument conflicts', (t) => {
const fixture = contextFixture(t);
const cases = [
{ schemaVersion: 1, commands: {} },
{ schemaVersion: 2, commands: { run: { configFile: fixture.runConfig } } },
{
schemaVersion: 1,
commands: { unknown: { configFile: fixture.runConfig } },
},
{
schemaVersion: 1,
commands: {
run: {
configFile: fixture.runConfig,
assertionFile: '/private/assertion.jwt',
},
},
},
{
schemaVersion: 1,
commands: {
run: { configFile: fixture.runConfig, privateKeyFile: '/private/key' },
},
},
{
schemaVersion: 1,
commands: {
'package-kubernetes': { configFile: fixture.packageConfig },
},
},
];
for (const [index, value] of cases.entries()) {
const filePath = privateFile(
fixture.directory,
`invalid-${index}.json`,
JSON.stringify(value),
);
assert.throws(
() => loadQingLong3ClusterProductContext(filePath),
/operator context is invalid/,
);
}
const publicContext = privateFile(
fixture.directory,
'public.json',
JSON.stringify({
schemaVersion: 1,
commands: { run: { configFile: fixture.runConfig } },
}),
);
fs.chmodSync(publicContext, 0o644);
assert.throws(() => loadQingLong3ClusterProductContext(publicContext));
const symlink = path.join(fixture.directory, 'context-link.json');
fs.symlinkSync(fixture.contextFile, symlink);
assert.throws(() => loadQingLong3ClusterProductContext(symlink));
const binaryRejected = runCli([
'run',
`--context=${publicContext}`,
'--command=/private/command.json',
'--assertion=/private/assertion.jwt',
]);
assert.equal(binaryRejected.status, 78);
assert.equal(binaryRejected.stdout, '');
assert.deepEqual(JSON.parse(binaryRejected.stderr), {
schemaVersion: 1,
component: 'qinglong3-cluster-product-cli',
code: 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID',
message: 'QingLong 3.0 Cluster operator context is invalid',
});
assert.equal(binaryRejected.stderr.includes(fixture.directory), false);
assert.throws(
() =>
resolveQingLong3ClusterProductCommand(
[
'approval',
`--context=${fixture.contextFile}`,
'--command=/private/command.json',
'--assertion=/private/assertion.jwt',
],
moduleDirectory,
),
/operator context is invalid/,
);
for (const args of [
['run', '--context'],
['run', '--context='],
[
'run',
`--context=${fixture.contextFile}`,
`--context=${fixture.contextFile}`,
],
]) {
const result = resolveQingLong3ClusterProductCommand(args, moduleDirectory);
assert.equal(result.kind, 'invalid');
}
assert.throws(
() =>
resolveQingLong3ClusterProductCommand(
['run', `--context=${fixture.contextFile}`, '--config'],
moduleDirectory,
),
/operator context is invalid/,
);
});
test('validates the complete operator context offline without operational authority', (t) => {
const fixture = validContextFixture(t);
const resolution = resolveQingLong3ClusterProductCommand(
['context', 'validate', `--context=${fixture.contextFile}`],
moduleDirectory,
);
assert.deepEqual(resolution, {
kind: 'context-validation',
contextFile: fixture.contextFile,
});
const validated = runCli([
'context',
'validate',
`--context=${fixture.contextFile}`,
]);
assert.equal(validated.status, 0);
assert.equal(validated.stderr, '');
const fact = JSON.parse(validated.stdout);
assert.deepEqual(fact, {
schemaVersion: 1,
component: 'qinglong3-cluster-product-cli',
event: 'context_valid',
commandCount: 7,
commands: [
{ name: 'package', transport: 'https', clientCertificate: 'forbidden' },
{
name: 'package-kubernetes',
transport: 'kubernetes-port-forward',
clientCertificate: 'forbidden',
kubernetesAuthentication: 'token',
},
{
name: 'worker-credential',
transport: 'https',
clientCertificate: 'required',
},
{ name: 'approval', transport: 'https', clientCertificate: 'required' },
{ name: 'run', transport: 'https', clientCertificate: 'required' },
{
name: 'automation',
transport: 'https',
clientCertificate: 'required',
},
{
name: 'model-credential',
transport: 'https',
clientCertificate: 'required',
},
],
networkAccess: false,
mutation: false,
});
assert.equal(validated.stdout.includes(fixture.directory), false);
assert.equal(validated.stdout.includes('manager.example.test'), false);
assert.equal(validated.stdout.includes('bounded-token'), false);
});
test('context validation fails closed for invalid client configuration and syntax', (t) => {
const fixture = validContextFixture(t);
fs.writeFileSync(fixture.commands.run.configFile, '{}', { mode: 0o600 });
const invalid = runCli([
'context',
'validate',
`--context=${fixture.contextFile}`,
]);
assert.equal(invalid.status, 78);
assert.equal(invalid.stdout, '');
assert.deepEqual(JSON.parse(invalid.stderr), {
schemaVersion: 1,
component: 'qinglong3-cluster-product-cli',
code: 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID',
message: 'QingLong 3.0 Cluster operator context is invalid',
});
assert.equal(invalid.stderr.includes(fixture.directory), false);
for (const argv of [
['context'],
['context', 'validate'],
['context', 'validate', '--context'],
['context', 'validate', '--context='],
['context', 'inspect', `--context=${fixture.contextFile}`],
]) {
const rejected = resolveQingLong3ClusterProductCommand(
argv,
moduleDirectory,
);
assert.equal(rejected.kind, 'invalid');
assert.equal(rejected.code, 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID');
}
});
test('configuration preflight fixes every management route to its reviewed authentication class', (t) => {
const fixture = validContextFixture(t);
assert.throws(
() =>
validateClusterAuthenticatedManagementClientConfiguration(
fixture.commands.run.configFile,
'package',
),
/configuration is invalid/,
);
assert.throws(
() =>
validateClusterAuthenticatedManagementClientConfiguration(
fixture.commands.package.configFile,
'run',
),
/configuration is invalid/,
);
assert.throws(
() =>
validateClusterAuthenticatedManagementClientConfiguration(
fixture.commands.package.configFile,
'unknown',
),
/configuration is invalid/,
);
});
test('rejects symlink targets and package manifests', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-product-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const fakePackageRoot = path.join(root, 'package');
const fakeModuleDirectory = path.join(fakePackageRoot, 'dist', 'product-cli');
const targetDirectory = path.join(fakePackageRoot, 'dist', 'run-management');
fs.mkdirSync(fakeModuleDirectory, { recursive: true });
fs.mkdirSync(targetDirectory, { recursive: true });
const external = path.join(root, 'external.js');
fs.writeFileSync(external, 'process.exit(0);\n');
fs.symlinkSync(
external,
path.join(targetDirectory, 'runManagementClientCli.js'),
);
assert.throws(
() =>
resolveQingLong3ClusterProductCommand(
['run', '--help'],
fakeModuleDirectory,
),
/unavailable/,
);
fs.writeFileSync(
path.join(fakePackageRoot, 'package.real.json'),
JSON.stringify({ name: '@qinglong/cluster-admin', version: '3.0.0' }),
);
fs.symlinkSync(
path.join(fakePackageRoot, 'package.real.json'),
path.join(fakePackageRoot, 'package.json'),
);
assert.throws(
() => loadQingLong3ClusterProductVersion(fakeModuleDirectory),
/unavailable/,
);
});
test('binary exposes help/version and delegates without a shell', () => {
const help = runCli(['--help']);
assert.equal(help.status, 0);
assert.match(help.stdout, /^Usage: ql3-cluster-admin <command>/);
assert.match(help.stdout, /--context=\/absolute\/operator-context\.json/);
assert.equal(help.stderr, '');
const version = runCli(['--version']);
assert.equal(version.status, 0);
assert.equal(version.stdout.trim(), manifest.version);
assert.equal(version.stderr, '');
const delegatedHelp = runCli(['run', '--help']);
assert.equal(delegatedHelp.status, 0);
assert.match(delegatedHelp.stdout, /^Usage: ql3-run-client /);
assert.equal(delegatedHelp.stderr, '');
const rejected = runCli(['../../tmp/not-a-command']);
assert.equal(rejected.status, 64);
assert.equal(rejected.stdout, '');
const failure = JSON.parse(rejected.stderr);
assert.equal(failure.code, 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID');
assert.equal(JSON.stringify(failure).includes('/tmp'), false);
});
test('forwards only bounded signals and removes handlers', () => {
const signalHost = new EventEmitter();
const received = [];
const child = {
exitCode: null,
signalCode: null,
kill(signal) {
received.push(signal);
return true;
},
};
const remove = forwardClusterProductSignals(child, signalHost);
signalHost.emit('SIGINT');
signalHost.emit('SIGTERM');
signalHost.emit('SIGHUP');
assert.deepEqual(received, ['SIGINT', 'SIGTERM', 'SIGHUP']);
assert.equal(clusterProductSignalExitCode('SIGINT'), 130);
assert.equal(clusterProductSignalExitCode('SIGTERM'), 143);
remove();
signalHost.emit('SIGTERM');
assert.deepEqual(received, ['SIGINT', 'SIGTERM', 'SIGHUP']);
child.exitCode = 0;
const removeTerminal = forwardClusterProductSignals(child, signalHost);
signalHost.emit('SIGTERM');
removeTerminal();
assert.deepEqual(received, ['SIGINT', 'SIGTERM', 'SIGHUP']);
});