mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add unified cluster operator cli
This commit is contained in:
@@ -548,6 +548,12 @@ jobs:
|
||||
echo "unexpected image contract: ${actual}" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Run the bounded Cluster Admin product facade
|
||||
if: matrix.image == 'admin'
|
||||
env:
|
||||
IMAGE: qinglong3-cluster-admin:ci-${{ matrix.image_arch }}
|
||||
QL3_CLUSTER_ADMIN_PRODUCT_LIVE: '1'
|
||||
run: node scripts/ql3-cluster-admin-product-live-contract.cjs --image="${IMAGE}"
|
||||
- name: Generate the reviewed application SBOM
|
||||
run: >-
|
||||
node scripts/ql3-cluster-image-sbom.cjs
|
||||
|
||||
@@ -93,4 +93,4 @@ COPY --from=assembled --chown=10001:10001 /opt/qinglong ./
|
||||
|
||||
USER 10001:10001
|
||||
|
||||
ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/recovery/pluginPackageRecoveryCli.js"]
|
||||
ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js"]
|
||||
|
||||
@@ -39,6 +39,16 @@ roots resolve to 84 external packages and it adds only `runtime-core`,
|
||||
database role therefore exist only in an ephemeral Job image, never in
|
||||
`cluster-control`.
|
||||
|
||||
Its default entrypoint is the bounded `ql3-cluster-admin` product facade. The
|
||||
facade exposes only seven operator-facing remote clients: `package`,
|
||||
`package-kubernetes`, `worker-credential`, `approval`, `run`, `automation` and
|
||||
`model-credential`. It delegates with the current Node executable, an exact
|
||||
same-image target and `shell=false`; arguments remain opaque. Server
|
||||
`*-manage` processes, migration, recovery, executors, Prompt output key custody
|
||||
and garbage collection remain separate explicit binaries and Kubernetes
|
||||
operations. Existing Jobs continue to name their exact binary and do not
|
||||
inherit facade authority.
|
||||
|
||||
The runtime image installs only the 43 external packages reachable from the
|
||||
five exact production roots in its production-only lock plus `runtime-core`,
|
||||
`cluster-postgres` and `cluster-control`: 46 runtime components in total. The
|
||||
|
||||
@@ -11,6 +11,20 @@
|
||||
|
||||
最新增量证据(2026-08-12):
|
||||
|
||||
- D-300/ADR-0388(已接受)
|
||||
Cluster operator 已获得统一 `ql3-cluster-admin <command>` 产品入口,能力内聚在既有 `@qinglong/cluster-admin/product-cli`,不新增
|
||||
package 或依赖。catalog 只允许 Package(直连/Kubernetes tunnel)、Worker Credential、Approval、Run、Automation、Model
|
||||
Credential 七个现有强认证远程 client;服务端 `*-manage`、migration、recovery、executor、AI migration 与 Prompt output key
|
||||
custody/GC 继续使用隔离 binary 和显式 Kubernetes operation。dispatcher 使用冻结同制品 target、当前 Node、`shell=false` 与 opaque
|
||||
argv,并复验 canonical non-symlink target/package manifest,未知或损坏安装只输出低敏错误。Admin image 默认入口改为 facade,但现有
|
||||
Job/Deployment 的 24 个 Admin image 引用均显式覆盖 command,authority 与生命周期不变;该规则已进入部署审计与突变测试。Local/Edge、
|
||||
Cluster Control 和 Worker 制品不加载此能力。facade 6/6、deployment 49/49、CI/release contract 48/48;真实 arm64 Admin image 为
|
||||
330,444,072 bytes,在 `10001:10001`、read-only root、network none、capabilities none、0.25 CPU、128 MiB/32 PIDs 下 7/7 委派通过。
|
||||
Cluster Admin 完整 package 为 291 pass/2 条件 skip,18-package clean build/test 退出 0,backend 为 1,184 pass/2 skip。workspace
|
||||
保持 18 package,Cluster Admin 96 source 中 95 nested/1 root,无 single-source/shallow package,五项边界审计零 finding。14 个 Local
|
||||
Profile artifact 全部 compatible,最小 Edge 2,467,343 bytes/295 files/53 modules,最大 Standalone MCP 7,168,978 bytes/778 files/
|
||||
213 modules,证明路由设备制品不受影响。PostgreSQL 18.4 arm64 HA 123 项 gate 全绿、timeline `1→2`,证据 SHA-256 为
|
||||
`f5df5998d505b6d5af552e627bd3f83983bc347d08d94da5a2d73fde46eab0a4`,离线审计及 Docker 资源清理均通过。完整回归见 ADR-0388。
|
||||
- D-299/ADR-0387(已接受)
|
||||
Local `run.cancel` 已从“HTTP durable intent 与 `/proc` stop 分别验证”推进为同一次真实 Linux 组合门。门先用既有离线 pack/prune
|
||||
生成可选 `edge/standalone-application-api` 最终制品,再在锁定 Node 24 arm64 Linux 容器内以 non-root、read-only root、network none、
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# ADR-0388:有界统一 Cluster Operator 产品 CLI
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-12
|
||||
- 关联 RFC:QL-RFC-0001 D-300
|
||||
- 前置决策:ADR-0127、ADR-0249、ADR-0259、ADR-0264、ADR-0271、ADR-0356、ADR-0365、ADR-0383
|
||||
|
||||
## 上下文
|
||||
|
||||
Local 部署者已有 `ql3 <command>` 可发现入口,Cluster operator 却仍需记忆二十多个 `ql3-*-client|manage|execute` binary。直接把所有 Cluster Admin 命令挂到一个 facade 会模糊远程客户端、常驻管理服务、数据库 migration、recovery、executor 与 key-custody 的 authority;新建 CLI package 又会恢复只有少量转发表文件的微包。
|
||||
|
||||
Cluster Admin 已是独立、短生命周期、非路由设备的发布镜像和 package。统一 operator UX 应留在该边界内,并且只能聚合调用方已经持有认证材料的远程客户端,不得借命令名称把服务端或数据库写 authority 交给普通工作站。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 在既有 `@qinglong/cluster-admin` 的 `product-cli/` 领域目录增加 `ql3-cluster-admin` binary,不新增 workspace package、第三方依赖、数据库对象、角色、连接、listener、timer、watcher、cache 或 sidecar。
|
||||
2. facade 只暴露七个固定远程客户端:`package`、`package-kubernetes`、`worker-credential`、`approval`、`run`、`automation`、`model-credential`。每项映射到同一安装中已经发布的一个 `*-client` binary。
|
||||
3. `*-manage` 服务进程、migration、Plugin recovery、Worker/Package executor、provider test executor、AI migration、Prompt output rotation/retirement/recovery/GC 不进入 catalog;既有 binary 均保留,Kubernetes operation 继续显式指定精确入口。
|
||||
4. dispatcher 只使用冻结 catalog、当前 `process.execPath` 和参数数组,固定 `shell=false`,不查询 `PATH`。目标必须是当前 package canonical `dist/` 内的 non-symlink regular file;package manifest 必须是 canonical non-symlink、64 KiB 内且 name/version 合法。子命令参数作为 opaque argv 原样转发。
|
||||
5. facade 只转发 `SIGINT|SIGTERM|SIGHUP`,保持 child exit code 或 `128+signal`;未知命令与安装损坏只返回不含用户输入、路径或认证材料的低敏 JSON。
|
||||
6. Cluster Admin image 默认 ENTRYPOINT 改为 facade,使无参数运行提供可发现帮助。所有生产 Job/Deployment 已显式覆盖 command,因此不改变其 authority 或生命周期。CI 在 amd64/arm64 Admin matrix 中以 non-root、read-only root、network none、capabilities none、128 MiB/32 PIDs 运行真实镜像门。
|
||||
7. Local/Edge Profile、Local image、Cluster Control image 与 Worker image均不安装或加载该 facade。package boundary 仍为 18,Cluster Admin 根仍只有既有 migration binary;两个新增 source 全部位于 `product-cli/`。
|
||||
|
||||
## 不采用方案
|
||||
|
||||
- **把所有 Cluster binary 暴露为子命令**:可发现性不能消除服务、执行和密钥 authority 隔离。
|
||||
- **删除原 binary**:会破坏 Kubernetes manifest、自动化脚本和已有运维协议。
|
||||
- **通过 shell、PATH 或用户路径查找命令**:允许环境劫持、通配符和参数重解释。
|
||||
- **复用 Local `ql3` package**:会让 Edge/Standalone 安装 Kubernetes/PostgreSQL 管理依赖,并混合两种部署模型。
|
||||
- **新建 Cluster CLI package**:只有同镜像静态路由职责,不形成新的部署、authority 或重依赖边界。
|
||||
|
||||
## 验收门
|
||||
|
||||
- catalog、help/version、opaque argv、unknown/path traversal、symlink target/manifest、真实 7-client help delegation、退出码与有界信号转发;
|
||||
- package manifest 和 Admin Dockerfile 的正反向部署审计,CI/release audit 必须要求 native image live gate;
|
||||
- Cluster Admin 完整 package、backend、18-package clean build/test、package/dependency/deployment/image release boundary 全绿;
|
||||
- 真实 arm64 Admin image 在受限 envelope 下通过,并记录 image bytes;PostgreSQL HA 不回归;
|
||||
- Local 14 Profile artifact 与默认 Local image 不因 Cluster facade 变化。
|
||||
|
||||
## 当前证据
|
||||
|
||||
- facade 6/6、Cluster deployment 49/49、CI/release contract 48/48;部署审计复核全部 24 个 Admin image 引用都显式覆盖 `command`,并以突变测试锁定。真实 arm64 Admin image 为 330,444,072 bytes,`10001:10001`、只读根、无网络、无 capabilities、0.25 CPU、128 MiB/32 PIDs 下 7/7 客户端 help 委派成功。
|
||||
- Cluster Admin 完整 package 为 293 tests、291 pass、2 条件 skip、0 fail;18-package clean build/test 退出 0;backend 为 1,186 tests、1,184 pass、2 skip、0 fail。workspace 保持 18 package;Cluster Admin 为 96 source,其中 95 nested、1 个既有 root binary entry,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;package/dependency/deployment/image release/Local image audit 零 finding。
|
||||
- 14 个 Local Profile artifact 全部 compatible,最小 Edge 为 2,467,343 bytes/295 files/53 modules、最终运行 RSS 11,091,968 bytes;最大 Standalone MCP 为 7,168,978 bytes/778 files/213 modules、RSS 38,420,480 bytes,证明 Cluster facade 不进入路由设备与本机 Profile。
|
||||
- PostgreSQL 18.4 arm64 physical HA 123 项 gate 全绿、timeline `1→2`,报告 SHA-256 `f5df5998d505b6d5af552e627bd3f83983bc347d08d94da5a2d73fde46eab0a4`;离线证据审计零 finding,测试容器、network 与 volume 零残留。
|
||||
@@ -391,6 +391,7 @@
|
||||
| [ADR-0385](./ADR-0385-strong-local-run-stop-product-entry.md) | 强认证的 Local Run Stop 产品入口与原子审计 | Accepted |
|
||||
| [ADR-0386](./ADR-0386-cluster-run-management-kubernetes-live-evidence.md) | Cluster Run Management Kubernetes 多节点实证 | Accepted |
|
||||
| [ADR-0387](./ADR-0387-local-api-cancellation-linux-live-evidence.md) | Local API Cancellation Linux 组合实证门 | Accepted |
|
||||
| [ADR-0388](./ADR-0388-bounded-unified-cluster-product-cli.md) | 有界统一 Cluster Operator 产品 CLI | Accepted |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"test:vault-transit-custody-live:ql3": "pnpm --filter @qinglong/ai build && pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-vault-transit-prompt-output-custody-live-contract.cjs",
|
||||
"test:postgres-backup-prompt-output-recovery-live:ql3": "pnpm --filter @qinglong/ai build && pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-postgres-prompt-output-recovery-live-contract.cjs",
|
||||
"audit:cluster-deployment:ql3": "node scripts/ql3-cluster-deployment-audit.cjs",
|
||||
"test:cluster-admin-product-live:ql3": "node scripts/ql3-cluster-admin-product-live-contract.cjs",
|
||||
"audit:provider-credential-management-deployment:ql3": "node scripts/ql3-provider-credential-management-deployment-audit.cjs",
|
||||
"audit:provider-credential-test-deployment:ql3": "node scripts/ql3-provider-credential-test-deployment-audit.cjs",
|
||||
"audit:worker-deployment:ql3": "node scripts/ql3-worker-deployment-audit.cjs",
|
||||
|
||||
@@ -342,6 +342,7 @@
|
||||
"test:automation-integration": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/automationManagementPostgres.integration.test.cjs"
|
||||
},
|
||||
"bin": {
|
||||
"ql3-cluster-admin": "dist/product-cli/cli.js",
|
||||
"ql3-plugin-package-recover": "dist/plugin-package/recovery/pluginPackageRecoveryCli.js",
|
||||
"ql3-plugin-package-manage": "dist/plugin-package/management/pluginPackageManagementCli.js",
|
||||
"ql3-plugin-package-client": "dist/plugin-package/management/pluginPackageManagementClientCli.js",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { constants } from 'node:os';
|
||||
|
||||
import { resolveQingLong3ClusterProductCommand } from './productCommand';
|
||||
|
||||
const FORWARDED_SIGNALS = Object.freeze([
|
||||
'SIGINT',
|
||||
'SIGTERM',
|
||||
'SIGHUP',
|
||||
] as const);
|
||||
|
||||
export interface QingLong3ClusterProductSignalChild {
|
||||
readonly exitCode: number | null;
|
||||
readonly signalCode: NodeJS.Signals | null;
|
||||
kill(signal: NodeJS.Signals): boolean;
|
||||
}
|
||||
|
||||
export interface QingLong3ClusterProductSignalHost {
|
||||
on(signal: NodeJS.Signals, handler: () => void): unknown;
|
||||
off(signal: NodeJS.Signals, handler: () => void): unknown;
|
||||
}
|
||||
|
||||
export function clusterProductSignalExitCode(
|
||||
signal: NodeJS.Signals | null,
|
||||
): number {
|
||||
if (signal === null) return 1;
|
||||
const number = constants.signals[signal];
|
||||
return typeof number === 'number' ? 128 + number : 1;
|
||||
}
|
||||
|
||||
function lowSensitivityFailure(
|
||||
code: string,
|
||||
message: string,
|
||||
): Readonly<Record<string, string | number>> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-product-cli',
|
||||
code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
export function forwardClusterProductSignals(
|
||||
child: QingLong3ClusterProductSignalChild,
|
||||
signalHost: QingLong3ClusterProductSignalHost = process,
|
||||
): () => void {
|
||||
const handlers = FORWARDED_SIGNALS.map((signal) => {
|
||||
const handler = (): void => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill(signal);
|
||||
}
|
||||
};
|
||||
signalHost.on(signal, handler);
|
||||
return Object.freeze({ signal, handler });
|
||||
});
|
||||
return () => {
|
||||
for (const { signal, handler } of handlers) {
|
||||
signalHost.off(signal, handler);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function invoke(targetFilePath: string, argv: readonly string[]): void {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawn(process.execPath, [targetFilePath, ...argv], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
'QL3_CLUSTER_PRODUCT_COMMAND_START_FAILED',
|
||||
'QingLong 3.0 Cluster product command could not start',
|
||||
),
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const removeSignalHandlers = forwardClusterProductSignals(child);
|
||||
let settled = false;
|
||||
const settle = (exitCode: number): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
process.exitCode = exitCode;
|
||||
};
|
||||
child.once('error', () => {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
'QL3_CLUSTER_PRODUCT_COMMAND_START_FAILED',
|
||||
'QingLong 3.0 Cluster product command could not start',
|
||||
),
|
||||
)}\n`,
|
||||
);
|
||||
settle(1);
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
settle(code ?? clusterProductSignalExitCode(signal));
|
||||
});
|
||||
}
|
||||
|
||||
function main(argv: readonly string[]): void {
|
||||
try {
|
||||
const resolution = resolveQingLong3ClusterProductCommand(argv, __dirname);
|
||||
if (resolution.kind === 'help' || resolution.kind === 'version') {
|
||||
process.stdout.write(`${resolution.output}\n`);
|
||||
return;
|
||||
}
|
||||
if (resolution.kind === 'invalid') {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(resolution.code, resolution.message),
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
invoke(resolution.targetFilePath, resolution.argv);
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
'QL3_CLUSTER_PRODUCT_CLI_INSTALLATION_INVALID',
|
||||
'QingLong 3.0 Cluster product command installation is invalid',
|
||||
),
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export interface QingLong3ClusterProductCommandDefinition {
|
||||
readonly name: string;
|
||||
readonly binary: string;
|
||||
readonly target: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export type QingLong3ClusterProductCommandResolution =
|
||||
| Readonly<{ kind: 'help'; output: string }>
|
||||
| Readonly<{ kind: 'version'; output: string }>
|
||||
| Readonly<{
|
||||
kind: 'invoke';
|
||||
command: QingLong3ClusterProductCommandDefinition;
|
||||
targetFilePath: string;
|
||||
argv: readonly string[];
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: 'invalid';
|
||||
code: 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID';
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
const PACKAGE_NAME = '@qinglong/cluster-admin';
|
||||
const MAXIMUM_PACKAGE_MANIFEST_BYTES = 64 * 1024;
|
||||
const SEMVER_PATTERN =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProductCommandDefinition[] =
|
||||
Object.freeze([
|
||||
Object.freeze({
|
||||
name: 'package',
|
||||
binary: 'ql3-plugin-package-client',
|
||||
target: 'plugin-package/management/pluginPackageManagementClientCli.js',
|
||||
description: 'manage Plugin Packages through the authenticated API',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'package-kubernetes',
|
||||
binary: 'ql3-plugin-package-client-kubernetes',
|
||||
target:
|
||||
'plugin-package/management/pluginPackageManagementKubernetesClientCli.js',
|
||||
description: 'manage Plugin Packages through a bounded Kubernetes tunnel',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'worker-credential',
|
||||
binary: 'ql3-worker-credential-client',
|
||||
target: 'worker-credential/workerCredentialManagementClientCli.js',
|
||||
description: 'manage Worker credentials through the authenticated API',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'approval',
|
||||
binary: 'ql3-approval-client',
|
||||
target: 'approval-management/approvalManagementClientCli.js',
|
||||
description: 'inspect and decide human approvals',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'run',
|
||||
binary: 'ql3-run-client',
|
||||
target: 'run-management/runManagementClientCli.js',
|
||||
description: 'retry or stop Runs under strong authentication',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'automation',
|
||||
binary: 'ql3-automation-client',
|
||||
target: 'automation-management/automationManagementClientCli.js',
|
||||
description: 'manage Task and Trigger definitions',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'model-credential',
|
||||
binary: 'ql3-provider-credential-client',
|
||||
target:
|
||||
'model-provider-credential/modelProviderCredentialManagementClientCli.js',
|
||||
description: 'manage model provider credentials',
|
||||
}),
|
||||
]);
|
||||
|
||||
function installationPaths(moduleDirectory: string): Readonly<{
|
||||
distRoot: string;
|
||||
packageRoot: string;
|
||||
packageManifestPath: string;
|
||||
}> {
|
||||
const distRoot = resolve(moduleDirectory, '..');
|
||||
const packageRoot = resolve(distRoot, '..');
|
||||
return Object.freeze({
|
||||
distRoot,
|
||||
packageRoot,
|
||||
packageManifestPath: resolve(packageRoot, 'package.json'),
|
||||
});
|
||||
}
|
||||
|
||||
function isInside(parent: string, candidate: string): boolean {
|
||||
const pathFromParent = relative(parent, candidate);
|
||||
return (
|
||||
pathFromParent !== '' &&
|
||||
pathFromParent !== '..' &&
|
||||
!pathFromParent.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(pathFromParent)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInstalledTarget(
|
||||
distRoot: string,
|
||||
definition: QingLong3ClusterProductCommandDefinition,
|
||||
): string {
|
||||
const lexicalTarget = resolve(distRoot, definition.target);
|
||||
if (!isInside(distRoot, lexicalTarget)) {
|
||||
throw new Error('Cluster product command target escapes package dist root');
|
||||
}
|
||||
const targetStatus = lstatSync(lexicalTarget, { throwIfNoEntry: false });
|
||||
if (
|
||||
targetStatus === undefined ||
|
||||
!targetStatus.isFile() ||
|
||||
targetStatus.isSymbolicLink()
|
||||
) {
|
||||
throw new Error('Cluster product command target is unavailable');
|
||||
}
|
||||
const canonicalDistRoot = realpathSync(distRoot);
|
||||
const canonicalTarget = realpathSync(lexicalTarget);
|
||||
if (!isInside(canonicalDistRoot, canonicalTarget)) {
|
||||
throw new Error(
|
||||
'Cluster product command target escapes canonical package root',
|
||||
);
|
||||
}
|
||||
return lexicalTarget;
|
||||
}
|
||||
|
||||
export function loadQingLong3ClusterProductVersion(
|
||||
moduleDirectory: string,
|
||||
): string {
|
||||
const { packageRoot, packageManifestPath } =
|
||||
installationPaths(moduleDirectory);
|
||||
const status = lstatSync(packageManifestPath, { throwIfNoEntry: false });
|
||||
if (
|
||||
status === undefined ||
|
||||
!status.isFile() ||
|
||||
status.isSymbolicLink() ||
|
||||
status.size <= 0 ||
|
||||
status.size > MAXIMUM_PACKAGE_MANIFEST_BYTES ||
|
||||
realpathSync(packageManifestPath) !==
|
||||
resolve(realpathSync(packageRoot), 'package.json')
|
||||
) {
|
||||
throw new Error('Cluster product package manifest is unavailable');
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(packageManifestPath, 'utf8')) as {
|
||||
readonly name?: unknown;
|
||||
readonly version?: unknown;
|
||||
};
|
||||
if (
|
||||
manifest.name !== PACKAGE_NAME ||
|
||||
typeof manifest.version !== 'string' ||
|
||||
!SEMVER_PATTERN.test(manifest.version)
|
||||
) {
|
||||
throw new Error('Cluster product package identity is invalid');
|
||||
}
|
||||
return manifest.version;
|
||||
}
|
||||
|
||||
export function qingLong3ClusterProductHelp(): string {
|
||||
const longestName = Math.max(
|
||||
...QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name.length),
|
||||
);
|
||||
const commands = QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(
|
||||
({ name, description }) => ` ${name.padEnd(longestName)} ${description}`,
|
||||
).join('\n');
|
||||
return [
|
||||
'Usage: ql3-cluster-admin <command> [arguments]',
|
||||
'',
|
||||
'Remote client commands:',
|
||||
commands,
|
||||
'',
|
||||
'Use `ql3-cluster-admin <command> --help` for command-specific usage.',
|
||||
'Server, migration, recovery, executor and key-custody authorities remain isolated.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function resolveQingLong3ClusterProductCommand(
|
||||
argv: readonly string[],
|
||||
moduleDirectory: string,
|
||||
): QingLong3ClusterProductCommandResolution {
|
||||
if (
|
||||
argv.length === 0 ||
|
||||
(argv.length === 1 &&
|
||||
(argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help'))
|
||||
) {
|
||||
return Object.freeze({
|
||||
kind: 'help',
|
||||
output: qingLong3ClusterProductHelp(),
|
||||
});
|
||||
}
|
||||
if (
|
||||
argv.length === 1 &&
|
||||
(argv[0] === '--version' || argv[0] === '-V' || argv[0] === 'version')
|
||||
) {
|
||||
return Object.freeze({
|
||||
kind: 'version',
|
||||
output: loadQingLong3ClusterProductVersion(moduleDirectory),
|
||||
});
|
||||
}
|
||||
const command = QINGLONG3_CLUSTER_PRODUCT_COMMANDS.find(
|
||||
(candidate) => candidate.name === argv[0],
|
||||
);
|
||||
if (command === undefined) {
|
||||
return Object.freeze({
|
||||
kind: 'invalid',
|
||||
code: 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID',
|
||||
message: 'unknown QingLong 3.0 Cluster product command',
|
||||
});
|
||||
}
|
||||
const { distRoot } = installationPaths(moduleDirectory);
|
||||
return Object.freeze({
|
||||
kind: 'invoke',
|
||||
command,
|
||||
targetFilePath: resolveInstalledTarget(distRoot, command),
|
||||
argv: Object.freeze(argv.slice(1)),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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 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');
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [cliPath, ...args], {
|
||||
cwd: packageRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
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('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.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']);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
'use strict';
|
||||
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const IMAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,255}$/u;
|
||||
const ENTRYPOINT = [
|
||||
'node',
|
||||
'/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js',
|
||||
];
|
||||
const COMMANDS = Object.freeze([
|
||||
Object.freeze({
|
||||
name: 'package',
|
||||
usage: 'Usage: ql3-plugin-package-client ',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'package-kubernetes',
|
||||
usage: 'Usage: ql3-plugin-package-client-kubernetes ',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'worker-credential',
|
||||
usage: 'Usage: ql3-worker-credential-client ',
|
||||
}),
|
||||
Object.freeze({ name: 'approval', usage: 'Usage: ql3-approval-client ' }),
|
||||
Object.freeze({ name: 'run', usage: 'Usage: ql3-run-client ' }),
|
||||
Object.freeze({ name: 'automation', usage: 'Usage: ql3-automation-client ' }),
|
||||
Object.freeze({
|
||||
name: 'model-credential',
|
||||
usage: 'Usage: ql3-provider-credential-client ',
|
||||
}),
|
||||
]);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`ql3 Cluster Admin product live contract failed: ${message}`);
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
if (argv.length !== 1) fail('exactly one --image argument is required');
|
||||
const match = /^--image=(.+)$/u.exec(argv[0]);
|
||||
if (!match || !IMAGE_PATTERN.test(match[1]))
|
||||
fail('image argument is invalid');
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function docker(args, options = {}) {
|
||||
return execFileSync('docker', args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function runImage(image, args) {
|
||||
return 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',
|
||||
image,
|
||||
...args,
|
||||
]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.env.QL3_CLUSTER_ADMIN_PRODUCT_LIVE !== '1') {
|
||||
fail('QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required');
|
||||
}
|
||||
const image = parseArguments(process.argv.slice(2));
|
||||
const inspected = JSON.parse(docker(['image', 'inspect', image]));
|
||||
if (!Array.isArray(inspected) || inspected.length !== 1) {
|
||||
fail('image inspection shape is invalid');
|
||||
}
|
||||
const fact = inspected[0];
|
||||
if (
|
||||
fact?.Os !== 'linux' ||
|
||||
(fact?.Architecture !== 'amd64' && fact?.Architecture !== 'arm64') ||
|
||||
fact?.Config?.User !== '10001:10001' ||
|
||||
JSON.stringify(fact?.Config?.Entrypoint) !== JSON.stringify(ENTRYPOINT) ||
|
||||
!Number.isSafeInteger(fact?.Size) ||
|
||||
fact.Size <= 0
|
||||
) {
|
||||
fail('image platform, identity, entrypoint or size contract drifted');
|
||||
}
|
||||
|
||||
const help = runImage(image, ['--help']);
|
||||
if (
|
||||
!help.startsWith('Usage: ql3-cluster-admin <command> [arguments]\n') ||
|
||||
!help.includes(
|
||||
'Server, migration, recovery, executor and key-custody authorities remain isolated.',
|
||||
)
|
||||
) {
|
||||
fail('product help contract drifted');
|
||||
}
|
||||
for (const { name, usage } of COMMANDS) {
|
||||
const output = runImage(image, [name, '--help']);
|
||||
if (!output.startsWith(usage)) {
|
||||
fail(`${name} delegation contract drifted`);
|
||||
}
|
||||
}
|
||||
const version = runImage(image, ['--version']).trim();
|
||||
if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
image,
|
||||
architecture: fact.Architecture,
|
||||
user: fact.Config.User,
|
||||
imageBytes: fact.Size,
|
||||
commandCount: COMMANDS.length,
|
||||
isolation: Object.freeze({
|
||||
readOnlyRoot: true,
|
||||
network: 'none',
|
||||
capabilities: 'none',
|
||||
noNewPrivileges: true,
|
||||
pids: 32,
|
||||
memoryBytes: 128 * 1024 * 1024,
|
||||
cpus: 0.25,
|
||||
}),
|
||||
compatible: true,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : 'unknown failure'}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { parseArguments };
|
||||
@@ -68,6 +68,68 @@ function yamlDocuments(readFile, filePath) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function kubernetesYamlFiles(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const filePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) return kubernetesYamlFiles(filePath);
|
||||
return entry.isFile() && /\.ya?ml$/u.test(entry.name) ? [filePath] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function podSpecFor(document) {
|
||||
if (document?.kind === 'CronJob') {
|
||||
return document.spec?.jobTemplate?.spec?.template?.spec;
|
||||
}
|
||||
if (
|
||||
['DaemonSet', 'Deployment', 'Job', 'StatefulSet'].includes(document?.kind)
|
||||
) {
|
||||
return document.spec?.template?.spec;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function assertClusterAdminImageCommands(readFile, root, findings) {
|
||||
const kubernetesRoot = path.join(root, 'deploy/kubernetes/ql3-cluster');
|
||||
let references = 0;
|
||||
for (const filePath of kubernetesYamlFiles(kubernetesRoot)) {
|
||||
for (const document of yamlDocuments(readFile, filePath)) {
|
||||
const podSpec = podSpecFor(document);
|
||||
for (const section of ['initContainers', 'containers']) {
|
||||
for (const container of podSpec?.[section] ?? []) {
|
||||
if (container?.image !== 'qinglong3-cluster-admin:3.0.0-alpha.0') {
|
||||
continue;
|
||||
}
|
||||
references += 1;
|
||||
if (
|
||||
!Array.isArray(container.command) ||
|
||||
container.command.length === 0
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_CLUSTER_ADMIN_IMAGE_COMMAND_IMPLICIT',
|
||||
`${path.relative(root, filePath)} ${document.kind}/${
|
||||
document.metadata?.name ?? 'unnamed'
|
||||
} ${section}/${
|
||||
container.name ?? 'unnamed'
|
||||
} must explicitly override the Cluster Admin image command`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (references === 0) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_CLUSTER_ADMIN_IMAGE_REFERENCE_MISSING',
|
||||
'The Kubernetes deployment must contain reviewed Cluster Admin image references',
|
||||
),
|
||||
);
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
function namedResource(resources, kind, name) {
|
||||
return resources.find(
|
||||
(resource) => resource?.kind === kind && resource?.metadata?.name === name,
|
||||
@@ -294,6 +356,7 @@ function assertExactExternalClosure(readFile, root, findings) {
|
||||
path.join(root, 'packages/ql3-cluster-admin/package.json'),
|
||||
);
|
||||
if (
|
||||
adminManifest.bin?.['ql3-cluster-admin'] !== 'dist/product-cli/cli.js' ||
|
||||
adminManifest.bin?.['ql3-plugin-package-recover'] !==
|
||||
'dist/plugin-package/recovery/pluginPackageRecoveryCli.js' ||
|
||||
adminManifest.bin?.['ql3-plugin-package-manage'] !==
|
||||
@@ -335,7 +398,7 @@ function assertExactExternalClosure(readFile, root, findings) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_CLUSTER_PLUGIN_RECOVERY_ENTRYPOINT_MISSING',
|
||||
'cluster-admin must publish the reviewed Package and Worker management and executor entrypoints',
|
||||
'cluster-admin must publish the reviewed product facade, Package and Worker management and executor entrypoints',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -465,7 +528,7 @@ function assertDockerfile(readFile, root, findings) {
|
||||
'/workspace/packages/ql3-cluster-postgres/dist',
|
||||
'/workspace/packages/ql3-cluster-admin/dist',
|
||||
'USER 10001:10001',
|
||||
'ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/recovery/pluginPackageRecoveryCli.js"]',
|
||||
'ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js"]',
|
||||
];
|
||||
for (const value of adminRequired) {
|
||||
if (!adminDockerfile.includes(value)) {
|
||||
@@ -5014,10 +5077,16 @@ function auditClusterDeployment(options = {}) {
|
||||
const root = path.resolve(options.root ?? path.join(__dirname, '..'));
|
||||
const readFile = options.readFile ?? fs.readFileSync;
|
||||
const findings = [];
|
||||
let clusterAdminImageReferences = 0;
|
||||
try {
|
||||
assertExactExternalClosure(readFile, root, findings);
|
||||
assertDockerfile(readFile, root, findings);
|
||||
assertKubernetes(readFile, root, findings);
|
||||
clusterAdminImageReferences = assertClusterAdminImageCommands(
|
||||
readFile,
|
||||
root,
|
||||
findings,
|
||||
);
|
||||
assertClusterAiComponent(readFile, root, findings);
|
||||
assertPluginPackageManagementDeployment(readFile, root, findings);
|
||||
assertWorkerCredentialManagementDeployment(readFile, root, findings);
|
||||
@@ -5071,6 +5140,7 @@ function auditClusterDeployment(options = {}) {
|
||||
clusterAi: 'optional-projected-authority',
|
||||
clusterAiPromptOutput: 'optional-read-only-projected-keyring',
|
||||
imageReleasePins: 'independent-fail-closed-digests',
|
||||
clusterAdminImageReferences,
|
||||
findings: Object.freeze(findings),
|
||||
compatible: findings.length === 0,
|
||||
});
|
||||
|
||||
@@ -274,6 +274,11 @@ function auditClusterImageCiWorkflow(source) {
|
||||
/unexpected image contract/,
|
||||
'cluster image CI must verify architecture and runtime user',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/name: Run the bounded Cluster Admin product facade\s+if: matrix\.image == 'admin'\s+env:\s+IMAGE: qinglong3-cluster-admin:ci-\$\{\{ matrix\.image_arch \}\}\s+QL3_CLUSTER_ADMIN_PRODUCT_LIVE: '1'\s+run: node scripts\/ql3-cluster-admin-product-live-contract\.cjs --image="\$\{IMAGE\}"/,
|
||||
'native admin image CI must run the bounded product facade contract',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/^ image-oci:\s*$/m,
|
||||
@@ -314,6 +319,7 @@ function auditClusterImageCiWorkflow(source) {
|
||||
images: ['control', 'control-ai', 'admin', 'local'],
|
||||
nativeArchitectures: ['amd64', 'arm64'],
|
||||
runtimeInventory: true,
|
||||
clusterAdminProductFacade: true,
|
||||
ociAttestations: true,
|
||||
osVulnerabilityScan: {
|
||||
scanner: 'trivy@0.70.0',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
parseArguments,
|
||||
} = require('../../scripts/ql3-cluster-admin-product-live-contract.cjs');
|
||||
|
||||
const script = path.resolve(
|
||||
__dirname,
|
||||
'../../scripts/ql3-cluster-admin-product-live-contract.cjs',
|
||||
);
|
||||
|
||||
test('accepts one bounded image reference', () => {
|
||||
assert.equal(
|
||||
parseArguments(['--image=registry.example.com/qinglong/admin@sha256:abc']),
|
||||
'registry.example.com/qinglong/admin@sha256:abc',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects missing, duplicate, unknown, empty or unsafe image arguments', () => {
|
||||
for (const argv of [
|
||||
[],
|
||||
['--image=one', '--image=two'],
|
||||
['--profile=admin'],
|
||||
['--image='],
|
||||
['--image=admin image'],
|
||||
[`--image=${'a'.repeat(257)}`],
|
||||
]) {
|
||||
assert.throws(() => parseArguments(argv), /live contract failed/);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed before Docker without explicit opt-in', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[script, '--image=missing:latest'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, QL3_CLUSTER_ADMIN_PRODUCT_LIVE: '0' },
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(result.stderr, /QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required/);
|
||||
assert.equal(result.stderr.includes('spawn'), false);
|
||||
});
|
||||
@@ -29,6 +29,7 @@ test('accepts the exact locked non-root multi-replica cluster deployment', () =>
|
||||
'optional-read-only-projected-keyring',
|
||||
);
|
||||
assert.equal(report.promptOutputKeyRotation, 'caller-driven-staged-material');
|
||||
assert.equal(report.clusterAdminImageReferences, 24);
|
||||
assert.deepEqual(report.workspacePackages, [
|
||||
'@qinglong/runtime-core',
|
||||
'@qinglong/cluster-postgres',
|
||||
@@ -41,6 +42,65 @@ test('accepts the exact locked non-root multi-replica cluster deployment', () =>
|
||||
]);
|
||||
});
|
||||
|
||||
test('requires every Cluster Admin Kubernetes workload to override the image command', () => {
|
||||
const report = auditClusterDeployment({
|
||||
root: ROOT,
|
||||
readFile: intercept(
|
||||
'deploy/kubernetes/ql3-cluster/operations/approval-management/base/deployment.yaml',
|
||||
(source) =>
|
||||
source.replace(
|
||||
' command:\n - node\n - /opt/qinglong/node_modules/@qinglong/cluster-admin/dist/approval-management/approvalManagementCli.js\n',
|
||||
'',
|
||||
),
|
||||
),
|
||||
});
|
||||
assert.equal(report.compatible, false);
|
||||
assert.equal(report.clusterAdminImageReferences, 24);
|
||||
assert.equal(
|
||||
report.findings.some(
|
||||
({ code }) => code === 'QL3_CLUSTER_ADMIN_IMAGE_COMMAND_IMPLICIT',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires the bounded Cluster product facade and image entrypoint', () => {
|
||||
const missingBinary = auditClusterDeployment({
|
||||
root: ROOT,
|
||||
readFile: intercept('packages/ql3-cluster-admin/package.json', (source) => {
|
||||
const manifest = JSON.parse(source);
|
||||
delete manifest.bin['ql3-cluster-admin'];
|
||||
return JSON.stringify(manifest);
|
||||
}),
|
||||
});
|
||||
assert.equal(missingBinary.compatible, false);
|
||||
assert.equal(
|
||||
missingBinary.findings.some(
|
||||
({ code }) => code === 'QL3_CLUSTER_PLUGIN_RECOVERY_ENTRYPOINT_MISSING',
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const legacyEntrypoint = auditClusterDeployment({
|
||||
root: ROOT,
|
||||
readFile: intercept(
|
||||
'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
(source) =>
|
||||
source.replace(
|
||||
'dist/product-cli/cli.js',
|
||||
'dist/plugin-package/recovery/pluginPackageRecoveryCli.js',
|
||||
),
|
||||
),
|
||||
});
|
||||
assert.equal(legacyEntrypoint.compatible, false);
|
||||
assert.equal(
|
||||
legacyEntrypoint.findings.some(
|
||||
({ code }) => code === 'QL3_CLUSTER_ADMIN_DOCKERFILE_CONTRACT_MISSING',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps Cluster AI optional with projected authority and an independent digest', () => {
|
||||
const defaultEnabled = auditClusterDeployment({
|
||||
root: ROOT,
|
||||
|
||||
@@ -26,6 +26,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
images: ['control', 'control-ai', 'admin', 'local'],
|
||||
nativeArchitectures: ['amd64', 'arm64'],
|
||||
runtimeInventory: true,
|
||||
clusterAdminProductFacade: true,
|
||||
ociAttestations: true,
|
||||
osVulnerabilityScan: {
|
||||
scanner: 'trivy@0.70.0',
|
||||
@@ -98,6 +99,17 @@ test('rejects removal of the native arm64 image gate', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the native Cluster Admin product facade gate', () => {
|
||||
const mutated = ciSource.replace(
|
||||
"QL3_CLUSTER_ADMIN_PRODUCT_LIVE: '1'",
|
||||
"QL3_CLUSTER_ADMIN_PRODUCT_LIVE: '0'",
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/bounded product facade contract/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the native cluster-admin image gate', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'image_arch: arm64\n image: admin',
|
||||
|
||||
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 94,
|
||||
sourceFiles: 96,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 61,
|
||||
nestedSourceFiles: 93,
|
||||
nestedSourceFiles: 95,
|
||||
rootSourceFileRoles: {
|
||||
'modelInvocationMigrationCli.ts': 'binary_entry',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user