mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add read-only cluster copilot console
This commit is contained in:
@@ -17,6 +17,10 @@ const COMMANDS = Object.freeze([
|
||||
name: 'copilot-mcp',
|
||||
usage: 'Usage: ql3-copilot-mcp --config ',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'copilot-console',
|
||||
usage: 'Usage:\n ql3-copilot-console --config ',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'package',
|
||||
usage: 'Usage: ql3-plugin-package-client ',
|
||||
@@ -206,6 +210,97 @@ process.stdout.write(JSON.stringify({ schemaVersion: 1, injected: true, contextP
|
||||
}
|
||||
}
|
||||
|
||||
function runConsoleContract(image) {
|
||||
const source = String.raw`
|
||||
const { spawn } = require('node:child_process');
|
||||
const { writeFileSync } = require('node:fs');
|
||||
const { get } = require('node:http');
|
||||
const { rootCertificates } = require('node:tls');
|
||||
const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js';
|
||||
const config = '/tmp/copilot-client.json';
|
||||
const credential = '/tmp/copilot-credential';
|
||||
const session = '/tmp/copilot-session';
|
||||
writeFileSync('/tmp/ca.pem', rootCertificates[0], { mode: 0o600 });
|
||||
writeFileSync(config, JSON.stringify({ schema: 'qinglong/cluster-copilot-client-config@v1', endpoint: 'https://localhost:65535/', servername: 'localhost', caFile: '/tmp/ca.pem', requestTimeoutMs: 1000 }), { mode: 0o600 });
|
||||
writeFileSync(credential, 'ql3c_console_' + Buffer.alloc(32, 9).toString('base64url'), { mode: 0o600 });
|
||||
writeFileSync(session, 'A'.repeat(43), { mode: 0o600 });
|
||||
const child = spawn(process.execPath, [facade, 'copilot-console', '--config', config, '--credential', credential, '--session', session, '--port=0'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => finish(41), 5000);
|
||||
function finish(code) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
process.exitCode = code;
|
||||
}
|
||||
child.once('error', () => finish(42));
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
const newline = stdout.indexOf('\n');
|
||||
if (newline === -1 || settled) return;
|
||||
let started;
|
||||
try { started = JSON.parse(stdout.slice(0, newline)); } catch { finish(43); return; }
|
||||
if (started.event !== 'started' || !/^http:\/\/127\.0\.0\.1:[0-9]+$/.test(started.origin) || JSON.stringify(started.operations) !== JSON.stringify(['inspect', 'output']) || started.mutation !== false) { finish(44); return; }
|
||||
get(started.origin, (response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.once('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
if (response.statusCode !== 200 || !body.includes('Cluster field console') || !body.includes('/app.css') || !body.includes('/app.js')) { finish(45); return; }
|
||||
child.once('close', (status, signal) => {
|
||||
if (status !== 0 || signal !== null) { finish(46); return; }
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
process.stdout.write(JSON.stringify({ loopback: true, assets: true, cleanShutdown: true }));
|
||||
});
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
}).once('error', () => finish(47));
|
||||
});
|
||||
`;
|
||||
const output = docker([
|
||||
'run',
|
||||
'--rm',
|
||||
'--read-only',
|
||||
'--network',
|
||||
'none',
|
||||
'--cap-drop',
|
||||
'ALL',
|
||||
'--security-opt',
|
||||
'no-new-privileges',
|
||||
'--user',
|
||||
'10001:10001',
|
||||
'--pids-limit',
|
||||
'32',
|
||||
'--memory',
|
||||
'128m',
|
||||
'--cpus',
|
||||
'0.25',
|
||||
'--tmpfs',
|
||||
'/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
|
||||
'--entrypoint',
|
||||
'node',
|
||||
image,
|
||||
'-e',
|
||||
source,
|
||||
]);
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(output);
|
||||
} catch {
|
||||
fail('Console live result is invalid');
|
||||
}
|
||||
if (
|
||||
result?.loopback !== true ||
|
||||
result?.assets !== true ||
|
||||
result?.cleanShutdown !== true
|
||||
) {
|
||||
fail('Console live contract drifted');
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.env.QL3_CLUSTER_ADMIN_PRODUCT_LIVE !== '1') {
|
||||
fail('QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required');
|
||||
@@ -245,6 +340,7 @@ function main() {
|
||||
const version = runImage(image, ['--version']).trim();
|
||||
if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
|
||||
runOperatorContextContract(image);
|
||||
runConsoleContract(image);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -257,6 +353,8 @@ function main() {
|
||||
operatorContext: true,
|
||||
contextPreflight: true,
|
||||
contextReadiness: true,
|
||||
consoleLoopback: true,
|
||||
consoleAssets: true,
|
||||
isolation: Object.freeze({
|
||||
readOnlyRoot: true,
|
||||
network: 'none',
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const CONSOLE_ROOT = 'packages/ql3-cluster-admin/src/copilot-console';
|
||||
const ASSET_ROOT = 'packages/ql3-cluster-admin/assets/copilot-console';
|
||||
const DEPLOYMENT_ROOT = 'deploy/console/ql3-cluster-copilot';
|
||||
const REQUIRED_FILES = Object.freeze([
|
||||
CONSOLE_ROOT + '/assets.ts',
|
||||
CONSOLE_ROOT + '/cli.ts',
|
||||
CONSOLE_ROOT + '/contracts.ts',
|
||||
CONSOLE_ROOT + '/server.ts',
|
||||
ASSET_ROOT + '/index.html',
|
||||
ASSET_ROOT + '/app.css',
|
||||
ASSET_ROOT + '/app.js',
|
||||
DEPLOYMENT_ROOT + '/README.md',
|
||||
DEPLOYMENT_ROOT + '/client-config.example.json',
|
||||
'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
'scripts/ql3-cluster-admin-product-live-contract.cjs',
|
||||
]);
|
||||
|
||||
function finding(code, target, detail) {
|
||||
return Object.freeze({ code, target, detail });
|
||||
}
|
||||
|
||||
function filesBelow(root, relativeDirectory) {
|
||||
const absolute = path.join(root, relativeDirectory);
|
||||
const result = [];
|
||||
const pending = [absolute];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const candidate = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) pending.push(candidate);
|
||||
else if (entry.isFile()) result.push(path.relative(root, candidate));
|
||||
}
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
function auditClusterCopilotConsole(options = {}) {
|
||||
const root = options.root || path.resolve(__dirname, '..');
|
||||
const readFile =
|
||||
options.readFile ||
|
||||
((relativePath) => fs.readFileSync(path.join(root, relativePath), 'utf8'));
|
||||
const findings = [];
|
||||
const source = {};
|
||||
|
||||
for (const relativePath of REQUIRED_FILES) {
|
||||
try {
|
||||
source[relativePath] = readFile(relativePath);
|
||||
} catch (error) {
|
||||
findings.push(
|
||||
finding(
|
||||
'CLUSTER_COPILOT_CONSOLE_FILE_MISSING',
|
||||
relativePath,
|
||||
error instanceof Error ? error.name : 'Error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const expectFragments = (relativePath, fragments) => {
|
||||
const contents = source[relativePath];
|
||||
if (typeof contents !== 'string') return;
|
||||
for (const fragment of fragments) {
|
||||
if (!contents.includes(fragment)) {
|
||||
findings.push(
|
||||
finding(
|
||||
'CLUSTER_COPILOT_CONSOLE_CONTRACT_MISSING',
|
||||
relativePath,
|
||||
fragment,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const rejectFragments = (relativePath, fragments) => {
|
||||
const contents = source[relativePath];
|
||||
if (typeof contents !== 'string') return;
|
||||
for (const fragment of fragments) {
|
||||
if (contents.includes(fragment)) {
|
||||
findings.push(
|
||||
finding(
|
||||
'CLUSTER_COPILOT_CONSOLE_AUTHORITY_WIDENED',
|
||||
relativePath,
|
||||
fragment,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expectFragments(CONSOLE_ROOT + '/contracts.ts', [
|
||||
"export type ClusterCopilotConsoleReadOperation = 'inspect' | 'output'",
|
||||
'CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA',
|
||||
'clusterCopilotConsoleClientCommand',
|
||||
]);
|
||||
rejectFragments(CONSOLE_ROOT + '/contracts.ts', [
|
||||
"| 'diagnose'",
|
||||
"| 'cancel'",
|
||||
'mutationId',
|
||||
'traceId',
|
||||
'endpoint',
|
||||
'credential',
|
||||
]);
|
||||
expectFragments(CONSOLE_ROOT + '/server.ts', [
|
||||
"server.listen(record.port as number, '127.0.0.1'",
|
||||
'request.headers.origin !== expectedOrigin',
|
||||
"request.headers.host !== expectedOrigin.slice('http://'.length)",
|
||||
'maximumConcurrentRequests: 2',
|
||||
"request.url === '/api/v1/copilot/inspect'",
|
||||
"request.url === '/api/v1/copilot/output'",
|
||||
"default-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"'cache-control': 'no-store'",
|
||||
]);
|
||||
rejectFragments(CONSOLE_ROOT + '/server.ts', [
|
||||
"'0.0.0.0'",
|
||||
'createSecureServer',
|
||||
'WebSocket',
|
||||
'set-cookie',
|
||||
'diagnose',
|
||||
'cancel',
|
||||
'child_process',
|
||||
'node:fs',
|
||||
'node:net',
|
||||
]);
|
||||
expectFragments(CONSOLE_ROOT + '/cli.ts', [
|
||||
'--session /absolute/session',
|
||||
'readCanonicalFile(',
|
||||
"'private'",
|
||||
'validateClusterCopilotClientCredentialFile',
|
||||
"clusterCredential: 'server_only'",
|
||||
"operations: ['inspect', 'output']",
|
||||
'mutation: false',
|
||||
]);
|
||||
rejectFragments(CONSOLE_ROOT + '/cli.ts', [
|
||||
'process.env',
|
||||
'0.0.0.0',
|
||||
'diagnose',
|
||||
'cancel',
|
||||
]);
|
||||
expectFragments(ASSET_ROOT + '/index.html', [
|
||||
'故障诊断,不替你执行。',
|
||||
'只读边界',
|
||||
'显式读取诊断内容',
|
||||
'不可信模型输出',
|
||||
]);
|
||||
expectFragments(ASSET_ROOT + '/app.js', [
|
||||
'credentials: "omit"',
|
||||
'cache: "no-store"',
|
||||
'outputText.textContent = fact.result.text',
|
||||
'sessionToken = ""',
|
||||
]);
|
||||
rejectFragments(ASSET_ROOT + '/app.js', [
|
||||
'localStorage',
|
||||
'sessionStorage',
|
||||
'innerHTML',
|
||||
'eval(',
|
||||
'new Function',
|
||||
'WebSocket',
|
||||
'EventSource',
|
||||
'diagnose',
|
||||
'cancel',
|
||||
'http://',
|
||||
'https://',
|
||||
]);
|
||||
expectFragments(ASSET_ROOT + '/app.css', [
|
||||
'@media (max-width: 520px)',
|
||||
'@media (prefers-reduced-motion: reduce)',
|
||||
':focus-visible',
|
||||
]);
|
||||
expectFragments(DEPLOYMENT_ROOT + '/README.md', [
|
||||
'operator-workstation process',
|
||||
'Do not deploy it as a Kubernetes workload',
|
||||
'only `inspect` and explicit `output` reads',
|
||||
'--port=0',
|
||||
'TLS 1.3 `GET /readyz`',
|
||||
'excluded from small router Edge/Standalone artifacts',
|
||||
]);
|
||||
rejectFragments(DEPLOYMENT_ROOT + '/README.md', [
|
||||
'--host=0.0.0.0',
|
||||
'kubectl apply',
|
||||
'localStorage',
|
||||
]);
|
||||
expectFragments('deploy/containers/ql3-cluster-admin/Dockerfile', [
|
||||
'COPY --from=workspace /workspace/packages/ql3-cluster-admin/assets/copilot-console',
|
||||
'node_modules/@qinglong/cluster-admin/assets/copilot-console',
|
||||
]);
|
||||
expectFragments('scripts/ql3-cluster-admin-product-live-contract.cjs', [
|
||||
'function runConsoleContract(image)',
|
||||
"[facade, 'copilot-console'",
|
||||
"started.event !== 'started'",
|
||||
"body.includes('Cluster field console')",
|
||||
'runConsoleContract(image);',
|
||||
'consoleLoopback: true',
|
||||
'consoleAssets: true',
|
||||
]);
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(readFile('packages/ql3-cluster-admin/package.json'));
|
||||
} catch (error) {
|
||||
findings.push(
|
||||
finding(
|
||||
'CLUSTER_COPILOT_CONSOLE_PACKAGE_INVALID',
|
||||
'packages/ql3-cluster-admin/package.json',
|
||||
error instanceof Error ? error.name : 'Error',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
manifest?.bin?.['ql3-copilot-console'] !==
|
||||
'dist/copilot-console/cli.js' ||
|
||||
manifest?.exports?.['./copilot-console']?.require !==
|
||||
'./dist/copilot-console/server.js' ||
|
||||
!Array.isArray(manifest?.files) ||
|
||||
!manifest.files.includes('assets/copilot-console/*')
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'CLUSTER_COPILOT_CONSOLE_PACKAGE_INVALID',
|
||||
'packages/ql3-cluster-admin/package.json',
|
||||
'bin, export or asset packlist drifted',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let productCommand = '';
|
||||
try {
|
||||
productCommand = readFile(
|
||||
'packages/ql3-cluster-admin/src/product-cli/productCommand.ts',
|
||||
);
|
||||
} catch {}
|
||||
if (
|
||||
!productCommand.includes("name: 'copilot-console'") ||
|
||||
!productCommand.includes("binary: 'ql3-copilot-console'") ||
|
||||
!productCommand.includes("target: 'copilot-console/cli.js'")
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'CLUSTER_COPILOT_CONSOLE_PRODUCT_ENTRY_MISSING',
|
||||
'packages/ql3-cluster-admin/src/product-cli/productCommand.ts',
|
||||
'static product delegation is incomplete',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const relativePath of filesBelow(root, 'src')) {
|
||||
const contents = readFile(relativePath);
|
||||
if (
|
||||
contents.includes('ql3-copilot-console') ||
|
||||
contents.includes('cluster-copilot-console-read') ||
|
||||
contents.includes('copilot/failure-diagnoses')
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'CLUSTER_COPILOT_CONSOLE_LEGACY_UI_COUPLED',
|
||||
relativePath,
|
||||
'legacy src imports or routes the QingLong 3.0 Console',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const relativePath of filesBelow(root, 'back')) {
|
||||
const contents = readFile(relativePath);
|
||||
if (
|
||||
contents.includes('ql3-copilot-console') ||
|
||||
contents.includes('cluster-copilot-console-read')
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'CLUSTER_COPILOT_CONSOLE_LEGACY_BACKEND_COUPLED',
|
||||
relativePath,
|
||||
'legacy backend owns the QingLong 3.0 Console',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const relativePath of filesBelow(root, 'deploy/kubernetes')) {
|
||||
if (!/\.ya?ml$/u.test(relativePath)) continue;
|
||||
const contents = readFile(relativePath);
|
||||
if (contents.includes('ql3-copilot-console')) {
|
||||
findings.push(
|
||||
finding(
|
||||
'CLUSTER_COPILOT_CONSOLE_KUBERNETES_RESIDENT',
|
||||
relativePath,
|
||||
'operator-workstation Console must not be a Kubernetes workload',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'cluster-copilot-console',
|
||||
owner: '@qinglong/cluster-admin',
|
||||
lifecycle: 'operator-workstation-loopback',
|
||||
operations: Object.freeze(['inspect', 'output']),
|
||||
legacyUiCoupled: false,
|
||||
kubernetesResident: false,
|
||||
assetCount: 3,
|
||||
sourceFileCount: 4,
|
||||
findings: Object.freeze(findings),
|
||||
compatible: findings.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const report = auditClusterCopilotConsole();
|
||||
process.stdout.write(JSON.stringify(report) + '\n');
|
||||
if (!report.compatible) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { auditClusterCopilotConsole };
|
||||
@@ -3101,6 +3101,9 @@ function auditPackageScripts(packagePath, manifest, findings) {
|
||||
|
||||
function auditPackageFiles(packagePath, manifest, findings) {
|
||||
const expected = ['dist/**/*.js', 'dist/**/*.d.ts'];
|
||||
if (packagePath === 'packages/ql3-cluster-admin') {
|
||||
expected.push('assets/copilot-console/*');
|
||||
}
|
||||
if (packagePath === 'packages/ql3-local-process') expected.push('assets');
|
||||
if (packagePath === 'packages/ql3-local-sqlite') expected.push('drizzle');
|
||||
if (JSON.stringify(manifest.files) !== JSON.stringify(expected)) {
|
||||
|
||||
@@ -240,7 +240,7 @@ function expectedImageConfig(architecture, revision, image) {
|
||||
? isControlAi
|
||||
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
|
||||
: 'QingLong 3.0 PostgreSQL-backed cluster control plane'
|
||||
: 'QingLong 3.0 cluster operations and bounded stdio MCP',
|
||||
: 'QingLong 3.0 cluster operations and bounded Copilot surfaces',
|
||||
'org.opencontainers.image.licenses': 'Apache-2.0',
|
||||
'org.opencontainers.image.revision': revision,
|
||||
'org.opencontainers.image.source': 'https://github.com/whyour/qinglong',
|
||||
|
||||
Reference in New Issue
Block a user