diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 45af05c4..9fcf7bd6 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -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 diff --git a/deploy/containers/ql3-cluster-admin/Dockerfile b/deploy/containers/ql3-cluster-admin/Dockerfile index 410c5a73..35b2c08e 100644 --- a/deploy/containers/ql3-cluster-admin/Dockerfile +++ b/deploy/containers/ql3-cluster-admin/Dockerfile @@ -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"] diff --git a/deploy/kubernetes/ql3-cluster/README.md b/deploy/kubernetes/ql3-cluster/README.md index 3f90be5d..28fd0ca7 100644 --- a/deploy/kubernetes/ql3-cluster/README.md +++ b/deploy/kubernetes/ql3-cluster/README.md @@ -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 diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 8af5ff5e..c8147cec 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,20 @@ 最新增量证据(2026-08-12): +- D-300/ADR-0388(已接受) + Cluster operator 已获得统一 `ql3-cluster-admin ` 产品入口,能力内聚在既有 `@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、 diff --git a/docs/adr/ADR-0388-bounded-unified-cluster-product-cli.md b/docs/adr/ADR-0388-bounded-unified-cluster-product-cli.md new file mode 100644 index 00000000..29dfbe35 --- /dev/null +++ b/docs/adr/ADR-0388-bounded-unified-cluster-product-cli.md @@ -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 ` 可发现入口,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 零残留。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 5107289c..c7984aa3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | ## 规则 diff --git a/package.json b/package.json index 8c863246..c90a774d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ql3-cluster-admin/package.json b/packages/ql3-cluster-admin/package.json index 9f73502e..a90a3709 100644 --- a/packages/ql3-cluster-admin/package.json +++ b/packages/ql3-cluster-admin/package.json @@ -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", diff --git a/packages/ql3-cluster-admin/src/product-cli/cli.ts b/packages/ql3-cluster-admin/src/product-cli/cli.ts new file mode 100644 index 00000000..e8b825fa --- /dev/null +++ b/packages/ql3-cluster-admin/src/product-cli/cli.ts @@ -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> { + 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)); +} diff --git a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts new file mode 100644 index 00000000..3a5bba72 --- /dev/null +++ b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts @@ -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 [arguments]', + '', + 'Remote client commands:', + commands, + '', + 'Use `ql3-cluster-admin --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)), + }); +} diff --git a/packages/ql3-cluster-admin/test/productCli.test.cjs b/packages/ql3-cluster-admin/test/productCli.test.cjs new file mode 100644 index 00000000..49b0570b --- /dev/null +++ b/packages/ql3-cluster-admin/test/productCli.test.cjs @@ -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 \[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 /); + 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']); +}); diff --git a/scripts/ql3-cluster-admin-product-live-contract.cjs b/scripts/ql3-cluster-admin-product-live-contract.cjs new file mode 100644 index 00000000..7c8d5d51 --- /dev/null +++ b/scripts/ql3-cluster-admin-product-live-contract.cjs @@ -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 [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 }; diff --git a/scripts/ql3-cluster-deployment-audit.cjs b/scripts/ql3-cluster-deployment-audit.cjs index def6930f..9d7449e6 100644 --- a/scripts/ql3-cluster-deployment-audit.cjs +++ b/scripts/ql3-cluster-deployment-audit.cjs @@ -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, }); diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index c53a8486..a6f8c15c 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -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', diff --git a/test/back/ql3ClusterAdminProductLiveContract.test.cjs b/test/back/ql3ClusterAdminProductLiveContract.test.cjs new file mode 100644 index 00000000..d0a36460 --- /dev/null +++ b/test/back/ql3ClusterAdminProductLiveContract.test.cjs @@ -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); +}); diff --git a/test/back/ql3ClusterDeploymentAudit.test.cjs b/test/back/ql3ClusterDeploymentAudit.test.cjs index 9e2ccd24..819dfdc3 100644 --- a/test/back/ql3ClusterDeploymentAudit.test.cjs +++ b/test/back/ql3ClusterDeploymentAudit.test.cjs @@ -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, diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index e210f612..61e83a7a 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -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', diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index c6068116..3a658955 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -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', },