diff --git a/deploy/kubernetes/ql3-cluster/README.md b/deploy/kubernetes/ql3-cluster/README.md index 28fd0ca7..76382625 100644 --- a/deploy/kubernetes/ql3-cluster/README.md +++ b/deploy/kubernetes/ql3-cluster/README.md @@ -49,6 +49,26 @@ and garbage collection remain separate explicit binaries and Kubernetes operations. Existing Jobs continue to name their exact binary and do not inherit facade authority. +Workstation operators may pass an explicit owner-private context to reuse only +stable client paths: + +```sh +chmod 600 /secure/qinglong3/operator-context.json +ql3-cluster-admin run \ + --context=/secure/qinglong3/operator-context.json \ + --command=/secure/qinglong3/run-command.json \ + --assertion=/secure/qinglong3/strong-user-assertion.jwt +``` + +Start from `operator-context.example.json`, copy it outside the repository and +replace only the required entries. The context and every referenced file must +be canonical, owned by the current UID and mode `0600`. It may contain only a +`configFile` per command and, for `package-kubernetes`, one `kubernetesFile`. +Short-lived assertions, per-operation command files, private keys and their +contents are deliberately forbidden. There is no home-directory, environment +or ambient Kubernetes context discovery. Existing explicit `--config` calls +remain supported when `--context` is absent. + 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/deploy/kubernetes/ql3-cluster/operator-context.example.json b/deploy/kubernetes/ql3-cluster/operator-context.example.json new file mode 100644 index 00000000..327fd698 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/operator-context.example.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "commands": { + "package": { + "configFile": "/secure/qinglong3/plugin-package-client.json" + }, + "package-kubernetes": { + "configFile": "/secure/qinglong3/plugin-package-client.json", + "kubernetesFile": "/secure/qinglong3/plugin-package-kubernetes.json" + }, + "worker-credential": { + "configFile": "/secure/qinglong3/worker-credential-client.json" + }, + "approval": { + "configFile": "/secure/qinglong3/approval-client.json" + }, + "run": { + "configFile": "/secure/qinglong3/run-client.json" + }, + "automation": { + "configFile": "/secure/qinglong3/automation-client.json" + }, + "model-credential": { + "configFile": "/secure/qinglong3/model-credential-client.json" + } + } +} diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index c8147cec..89e8cc5c 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -6,11 +6,25 @@ - 目标版本:QingLong 3.x - 作者:QingLong Maintainers - 创建日期:2026-07-17 -- 最后更新:2026-08-12 +- 最后更新:2026-08-13 - 讨论范围:架构与演进路线,不包含最终 UI 视觉方案 -最新增量证据(2026-08-12): +最新增量证据(2026-08-13): +- D-301/ADR-0389(已接受) + `ql3-cluster-admin` 现在可通过显式 `--context=/absolute/operator-context.json` 复用七个远程 client 的稳定路径,同时保持每次 + command 与短生命周期强 assertion 必须显式传入。schema v1 只允许 catalog command → `configFile`,Kubernetes tunnel 精确多一个 + `kubernetesFile`;assertion、command、私钥、token、endpoint 内容和默认命令字段全部禁止。context 及引用文件必须 absolute、canonical、 + 当前 UID、non-symlink regular、精确 `0600`;reader 使用 `O_NOFOLLOW|O_CLOEXEC`、descriptor 前后 identity/size 复验、64 KiB/4,096-byte + 上限与 buffer 清零。无 home/XDG/env/ambient Kubernetes 发现,context 与显式 config/tunnel 参数冲突失败关闭;旧 opaque argv 保持兼容。 + 实现继续位于既有 `cluster-admin/product-cli`,不新增 package、依赖、listener、timer、watcher、cache、Secret 或 workload,Local/Edge、 + Cluster Control 与 Worker 零导入。专项 70/70;真实 arm64 Admin image 为 330,453,309 bytes,较 D-300 增加 9,237 bytes,并在 + `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下验证路径注入和敏感字段拒绝。 + workspace 保持 18 package,Cluster Admin 97 source 中 96 nested/1 root,无 single-source/shallow package。Cluster Admin 完整 package 为 + 293 pass/2 条件 skip,18-package clean build/test 退出 0,backend 为 1,186 pass/2 skip;五项边界审计零 finding。14 个 Local Profile + artifact 全部 compatible,最小 Edge 2,467,343 bytes、最大 Standalone MCP 7,168,978 bytes,均与 D-300 对应制品字节数一致。 + PostgreSQL 18.4 arm64 HA 123 项 gate 全绿、timeline `1→2`,证据 SHA-256 为 + `55707a4b59483a2281e0a338e06336eef0ce2ba5126efbdfe4dad6a88a466157`,离线审计及 Docker 资源清理通过。完整回归见 ADR-0389。 - 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 diff --git a/docs/adr/ADR-0389-owner-private-cluster-operator-context.md b/docs/adr/ADR-0389-owner-private-cluster-operator-context.md new file mode 100644 index 00000000..1f132f68 --- /dev/null +++ b/docs/adr/ADR-0389-owner-private-cluster-operator-context.md @@ -0,0 +1,43 @@ +# ADR-0389:Owner-private Cluster Operator Context + +- 状态:Accepted +- 日期:2026-08-13 +- 关联 RFC:QL-RFC-0001 D-301 +- 前置决策:ADR-0191、ADR-0192、ADR-0250、ADR-0388 + +## 上下文 + +ADR-0388 统一了七个 operator-facing Cluster client 的命令发现与调度,但六个直连 client 仍重复要求 `--config/--command/--assertion`,Kubernetes tunnel 额外要求 `--kubernetes`。其中 client config 与 tunnel config 是可跨操作复用的稳定连接事实;command 和强用户 assertion 则是每次操作的短生命周期 authority。若把四类文件都写入默认 profile、环境变量或 home 自动发现,统一 UX 会以环境劫持、错集群和长期 assertion 为代价。 + +## 决策 + +1. 在既有 `@qinglong/cluster-admin/product-cli` 内增加 operator context reader,不新增 package、依赖、数据库对象、listener、timer、watcher、cache、Secret 或 Kubernetes workload。 +2. facade 接受显式 `--context=/absolute/operator-context.json`。无该参数时继续 opaque 转发原有 `--config` 调用,保持所有已有 binary、脚本和 Job 兼容。 +3. schema v1 根只允许 `schemaVersion` 与非空 `commands`。命令键只能来自 ADR-0388 七项 catalog;普通命令 entry 只允许 `configFile`,`package-kubernetes` 精确允许 `configFile+kubernetesFile`。context 不得包含 command、assertion、private key、token、endpoint 内容或默认命令。 +4. context、client config 和 Kubernetes config 路径必须绝对、canonical、non-symlink regular file、当前 UID 所有、精确 `0600`。reader 使用 `O_NOFOLLOW|O_CLOEXEC`、descriptor 前后 identity/size 复验、64 KiB context 与 4,096-byte path 上限;读取 buffer 在退出时清零。 +5. `--context` 与显式 `--config|--kubernetes` 冲突时失败关闭;重复、空值、未知命令、未知字段、缺失 command entry、弱权限、symlink 或非 canonical 路径统一返回低敏 `QL3_CLUSTER_PRODUCT_CONTEXT_INVALID`,退出 78,不输出路径或内容。 +6. context 只注入稳定路径。`--command` 与 `--assertion` 仍必须由调用者逐次显式传入,并继续由原 client 执行 canonical/current-UID/`0600`、TLS 1.3、mTLS、Policy、quota 与结果校验。context 不扩大任何 server、migration、recovery、executor 或 key-custody authority。 +7. Local/Edge、Cluster Control 与 Worker 制品不导入该 reader。示例文件只含占位路径,不能作为 Secret 或可直接运行配置;操作者必须复制到仓库外并设置 `0600`。 + +## 不采用方案 + +- **自动读取 `$HOME`、XDG 或环境变量**:ambient selection 容易连接错误集群,也让容器和服务账号继承未审计 authority。 +- **把 assertion 或 command 放进 context**:会把逐操作、短生命周期授权退化为长期 profile。 +- **在 context 内保存 TLS 私钥或配置内容**:重复既有 no-follow reader,扩大解析器的 Secret 生命周期与备份暴露面。 +- **修改七个 client CLI**:它们已共享同一安全 transport,facade 注入路径即可改善产品 UX,无需复制变更到每个边界。 +- **新建 workspace package**:context 只服务一个现有可部署 facade,不形成独立部署、权限或供应链边界。 + +## 验收门 + +- schema、权限、owner、canonical/no-follow、大小、未知/敏感字段、缺失命令、tunnel exact shape、重复/冲突参数与低敏退出码; +- 原调用方式保持 opaque argv,context 调用只注入 config/tunnel 路径且保持 command/assertion 显式; +- 真实 Admin image 在 non-root、read-only root、network none、drop ALL、no-new-privileges、128 MiB/32 PIDs 下完成合法注入与敏感字段拒绝; +- Cluster Admin 完整 package、backend、18-package clean build/test、package/dependency/deployment/image release/Local image、14 Local Profile artifact 与 PostgreSQL HA 不回归。 + +## 当前证据 + +- context/facade 与静态边界专项 70/70;真实 arm64 Admin image 为 330,453,309 bytes,较 D-300 增加 9,237 bytes,并在受限 envelope 下报告 `operatorContext=true`。 +- workspace 保持 18 package;Cluster Admin 97 source 中 96 nested、1 root,无 single-source/shallow package,未增加依赖或常驻资源。 +- Cluster Admin 完整 package 为 293 pass/2 条件 skip;18-package clean build/test 退出 0;backend 为 1,186 pass/2 skip。deployment 专项 50/50,package/dependency/deployment/image release/Local image 五项边界审计均 compatible、零 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;与 D-300 对应制品字节数完全一致,路由设备制品没有因 operator context 增重。 +- PostgreSQL 18.4 arm64 HA 123 项 gate 全绿,timeline `1→2`;证据 SHA-256 为 `55707a4b59483a2281e0a338e06336eef0ce2ba5126efbdfe4dad6a88a466157`,离线审计 compatible、零 finding,门禁容器、网络与卷均零残留。 diff --git a/docs/adr/README.md b/docs/adr/README.md index c7984aa3..79695949 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -392,6 +392,7 @@ | [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 | +| [ADR-0389](./ADR-0389-owner-private-cluster-operator-context.md) | Owner-private Cluster Operator Context | Accepted | ## 规则 diff --git a/packages/ql3-cluster-admin/src/product-cli/cli.ts b/packages/ql3-cluster-admin/src/product-cli/cli.ts index e8b825fa..0c715b22 100644 --- a/packages/ql3-cluster-admin/src/product-cli/cli.ts +++ b/packages/ql3-cluster-admin/src/product-cli/cli.ts @@ -4,6 +4,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { constants } from 'node:os'; import { resolveQingLong3ClusterProductCommand } from './productCommand'; +import { QingLong3ClusterProductContextError } from './productContext'; const FORWARDED_SIGNALS = Object.freeze([ 'SIGINT', @@ -124,7 +125,25 @@ function main(argv: readonly string[]): void { return; } invoke(resolution.targetFilePath, resolution.argv); - } catch { + } catch (error) { + if ( + error instanceof QingLong3ClusterProductContextError || + (error !== null && + typeof error === 'object' && + 'code' in error && + error.code === 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID') + ) { + process.stderr.write( + `${JSON.stringify( + lowSensitivityFailure( + 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID', + 'QingLong 3.0 Cluster operator context is invalid', + ), + )}\n`, + ); + process.exitCode = 78; + return; + } process.stderr.write( `${JSON.stringify( lowSensitivityFailure( diff --git a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts index 3a5bba72..d0534c19 100644 --- a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts +++ b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts @@ -1,6 +1,8 @@ import { lstatSync, readFileSync, realpathSync } from 'node:fs'; import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { resolveQingLong3ClusterProductContextArguments } from './productContext'; + export interface QingLong3ClusterProductCommandDefinition { readonly name: string; readonly binary: string; @@ -171,6 +173,8 @@ export function qingLong3ClusterProductHelp(): string { commands, '', 'Use `ql3-cluster-admin --help` for command-specific usage.', + 'Use `--context=/absolute/operator-context.json` to inject only stable client paths.', + 'Command and short-lived assertion files always remain explicit per invocation.', 'Server, migration, recovery, executor and key-custody authorities remain isolated.', ].join('\n'); } @@ -208,11 +212,36 @@ export function resolveQingLong3ClusterProductCommand( message: 'unknown QingLong 3.0 Cluster product command', }); } + const commandArguments = argv.slice(1); + const contextArguments = commandArguments.filter( + (argument) => argument === '--context' || argument.startsWith('--context='), + ); + if ( + contextArguments.length > 1 || + contextArguments[0] === '--context' || + contextArguments[0] === '--context=' + ) { + return Object.freeze({ + kind: 'invalid', + code: 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID', + message: 'QingLong 3.0 Cluster product context option is invalid', + }); + } + const forwardedArguments = + contextArguments.length === 0 + ? Object.freeze(commandArguments) + : resolveQingLong3ClusterProductContextArguments( + contextArguments[0]!.slice('--context='.length), + command.name, + commandArguments.filter( + (argument) => argument !== contextArguments[0], + ), + ); const { distRoot } = installationPaths(moduleDirectory); return Object.freeze({ kind: 'invoke', command, targetFilePath: resolveInstalledTarget(distRoot, command), - argv: Object.freeze(argv.slice(1)), + argv: forwardedArguments, }); } diff --git a/packages/ql3-cluster-admin/src/product-cli/productContext.ts b/packages/ql3-cluster-admin/src/product-cli/productContext.ts new file mode 100644 index 00000000..e0e07f5e --- /dev/null +++ b/packages/ql3-cluster-admin/src/product-cli/productContext.ts @@ -0,0 +1,245 @@ +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, +} from 'node:fs'; +import { isAbsolute } from 'node:path'; +import { TextDecoder } from 'node:util'; + +const MAXIMUM_CONTEXT_BYTES = 64 * 1024; +const MAXIMUM_PATH_BYTES = 4_096; +const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/u; +const CONTEXT_COMMANDS = Object.freeze([ + 'package', + 'package-kubernetes', + 'worker-credential', + 'approval', + 'run', + 'automation', + 'model-credential', +] as const); + +type ContextCommandName = (typeof CONTEXT_COMMANDS)[number]; +type JsonObject = Record; + +export interface QingLong3ClusterProductContextCommand { + readonly configFile: string; + readonly kubernetesFile?: string; +} + +export interface QingLong3ClusterProductContext { + readonly schemaVersion: 1; + readonly commands: Readonly< + Partial> + >; +} + +export class QingLong3ClusterProductContextError extends TypeError { + readonly code = 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID'; + + constructor() { + super('QingLong 3.0 Cluster operator context is invalid'); + this.name = 'QingLong3ClusterProductContextError'; + } +} + +function invalid(): never { + throw new QingLong3ClusterProductContextError(); +} + +function exactObject(value: unknown, keys: readonly string[]): JsonObject { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + invalid(); + } + return value as JsonObject; +} + +function object(value: unknown): JsonObject { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); + return value as JsonObject; +} + +function currentUid(): number { + if (typeof process.getuid !== 'function') invalid(); + const uid = process.getuid(); + if (!Number.isSafeInteger(uid) || uid < 0) invalid(); + return uid; +} + +function validatePrivatePath(filePath: unknown, uid: number): string { + if ( + typeof filePath !== 'string' || + !isAbsolute(filePath) || + Buffer.byteLength(filePath, 'utf8') > MAXIMUM_PATH_BYTES || + CONTROL_PATTERN.test(filePath) + ) { + invalid(); + } + try { + const status = lstatSync(filePath); + if ( + !status.isFile() || + status.isSymbolicLink() || + status.uid !== uid || + (status.mode & 0o777) !== 0o600 || + realpathSync(filePath) !== filePath + ) { + invalid(); + } + } catch (error) { + if (error instanceof QingLong3ClusterProductContextError) throw error; + invalid(); + } + return filePath; +} + +function readPrivateContext(filePath: string): Buffer { + const uid = currentUid(); + validatePrivatePath(filePath, uid); + let descriptor = -1; + let bytes: Buffer | undefined; + try { + const before = lstatSync(filePath); + if (before.size < 2 || before.size > MAXIMUM_CONTEXT_BYTES) invalid(); + descriptor = openSync( + filePath, + constants.O_RDONLY | + ((constants as unknown as Readonly>).O_CLOEXEC ?? + 0) | + (constants.O_NOFOLLOW ?? 0), + ); + const opened = fstatSync(descriptor); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.uid !== before.uid || + opened.mode !== before.mode || + opened.size !== before.size + ) { + invalid(); + } + bytes = Buffer.alloc(opened.size); + let offset = 0; + while (offset < bytes.length) { + const count = readSync( + descriptor, + bytes, + offset, + bytes.length - offset, + offset, + ); + if (count < 1) invalid(); + offset += count; + } + const after = fstatSync(descriptor); + if ( + after.dev !== opened.dev || + after.ino !== opened.ino || + after.uid !== opened.uid || + after.mode !== opened.mode || + after.size !== opened.size + ) { + invalid(); + } + return bytes; + } catch (error) { + bytes?.fill(0); + if (error instanceof QingLong3ClusterProductContextError) throw error; + throw new QingLong3ClusterProductContextError(); + } finally { + if (descriptor >= 0) closeSync(descriptor); + } +} + +export function loadQingLong3ClusterProductContext( + contextFile: string, +): Readonly { + let bytes: Buffer | undefined; + try { + bytes = readPrivateContext(contextFile); + let parsed: unknown; + try { + parsed = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes), + ); + } catch { + invalid(); + } + const root = exactObject(parsed, ['schemaVersion', 'commands']); + const commands = object(root.commands); + const names = Object.keys(commands); + if ( + root.schemaVersion !== 1 || + names.length < 1 || + names.length > CONTEXT_COMMANDS.length || + names.some( + (name) => !CONTEXT_COMMANDS.includes(name as ContextCommandName), + ) + ) { + invalid(); + } + const uid = currentUid(); + const normalized: Partial< + Record + > = {}; + for (const name of names as ContextCommandName[]) { + const tunnel = name === 'package-kubernetes'; + const entry = exactObject( + commands[name], + tunnel ? ['configFile', 'kubernetesFile'] : ['configFile'], + ); + normalized[name] = Object.freeze({ + configFile: validatePrivatePath(entry.configFile, uid), + ...(tunnel + ? { kubernetesFile: validatePrivatePath(entry.kubernetesFile, uid) } + : {}), + }); + } + return Object.freeze({ + schemaVersion: 1, + commands: Object.freeze(normalized), + }); + } finally { + bytes?.fill(0); + } +} + +export function resolveQingLong3ClusterProductContextArguments( + contextFile: string, + commandName: string, + argv: readonly string[], +): readonly string[] { + if ( + argv.some( + (argument) => + argument === '--context' || + argument.startsWith('--context=') || + argument === '--config' || + argument.startsWith('--config=') || + argument === '--kubernetes' || + argument.startsWith('--kubernetes='), + ) + ) { + invalid(); + } + const context = loadQingLong3ClusterProductContext(contextFile); + const command = context.commands[commandName as ContextCommandName]; + if (command === undefined) invalid(); + return Object.freeze([ + `--config=${command.configFile}`, + ...(command.kubernetesFile === undefined + ? [] + : [`--kubernetes=${command.kubernetesFile}`]), + ...argv, + ]); +} diff --git a/packages/ql3-cluster-admin/test/productCli.test.cjs b/packages/ql3-cluster-admin/test/productCli.test.cjs index 49b0570b..626677d0 100644 --- a/packages/ql3-cluster-admin/test/productCli.test.cjs +++ b/packages/ql3-cluster-admin/test/productCli.test.cjs @@ -22,6 +22,10 @@ const { clusterProductSignalExitCode, forwardClusterProductSignals, } = require('../dist/product-cli/cli.js'); +const { + loadQingLong3ClusterProductContext, + resolveQingLong3ClusterProductContextArguments, +} = require('../dist/product-cli/productContext.js'); function runCli(args) { return spawnSync(process.execPath, [cliPath, ...args], { @@ -30,6 +34,37 @@ function runCli(args) { }); } +function privateFile(directory, name, contents) { + const filePath = path.join(directory, name); + fs.writeFileSync(filePath, contents, { mode: 0o600 }); + return filePath; +} + +function contextFixture(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-context-')), + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const runConfig = privateFile(directory, 'run-client.json', '{}'); + const packageConfig = privateFile(directory, 'package-client.json', '{}'); + const kubernetes = privateFile(directory, 'kubernetes.json', '{}'); + const contextFile = privateFile( + directory, + 'operator-context.json', + JSON.stringify({ + schemaVersion: 1, + commands: { + run: { configFile: runConfig }, + 'package-kubernetes': { + configFile: packageConfig, + kubernetesFile: kubernetes, + }, + }, + }), + ); + return { directory, runConfig, packageConfig, kubernetes, contextFile }; +} + 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); @@ -128,6 +163,162 @@ test('resolves only static remote-client targets and preserves opaque arguments' } }); +test('injects only stable paths from an explicit owner-private operator context', (t) => { + const fixture = contextFixture(t); + const context = loadQingLong3ClusterProductContext(fixture.contextFile); + assert.deepEqual(context, { + schemaVersion: 1, + commands: { + run: { configFile: fixture.runConfig }, + 'package-kubernetes': { + configFile: fixture.packageConfig, + kubernetesFile: fixture.kubernetes, + }, + }, + }); + assert.equal(Object.isFrozen(context), true); + assert.equal(Object.isFrozen(context.commands), true); + assert.equal(Object.isFrozen(context.commands.run), true); + + const run = resolveQingLong3ClusterProductCommand( + [ + 'run', + `--context=${fixture.contextFile}`, + '--command=/private/command.json', + '--assertion=/private/assertion.jwt', + ], + moduleDirectory, + ); + assert.equal(run.kind, 'invoke'); + assert.deepEqual(run.argv, [ + `--config=${fixture.runConfig}`, + '--command=/private/command.json', + '--assertion=/private/assertion.jwt', + ]); + + const tunnel = resolveQingLong3ClusterProductContextArguments( + fixture.contextFile, + 'package-kubernetes', + ['--command=/private/command.json', '--assertion=/private/assertion.jwt'], + ); + assert.deepEqual(tunnel, [ + `--config=${fixture.packageConfig}`, + `--kubernetes=${fixture.kubernetes}`, + '--command=/private/command.json', + '--assertion=/private/assertion.jwt', + ]); +}); + +test('operator context rejects weak files, unknown or secret fields and argument conflicts', (t) => { + const fixture = contextFixture(t); + const cases = [ + { schemaVersion: 1, commands: {} }, + { schemaVersion: 2, commands: { run: { configFile: fixture.runConfig } } }, + { + schemaVersion: 1, + commands: { unknown: { configFile: fixture.runConfig } }, + }, + { + schemaVersion: 1, + commands: { + run: { + configFile: fixture.runConfig, + assertionFile: '/private/assertion.jwt', + }, + }, + }, + { + schemaVersion: 1, + commands: { + run: { configFile: fixture.runConfig, privateKeyFile: '/private/key' }, + }, + }, + { + schemaVersion: 1, + commands: { + 'package-kubernetes': { configFile: fixture.packageConfig }, + }, + }, + ]; + for (const [index, value] of cases.entries()) { + const filePath = privateFile( + fixture.directory, + `invalid-${index}.json`, + JSON.stringify(value), + ); + assert.throws( + () => loadQingLong3ClusterProductContext(filePath), + /operator context is invalid/, + ); + } + + const publicContext = privateFile( + fixture.directory, + 'public.json', + JSON.stringify({ + schemaVersion: 1, + commands: { run: { configFile: fixture.runConfig } }, + }), + ); + fs.chmodSync(publicContext, 0o644); + assert.throws(() => loadQingLong3ClusterProductContext(publicContext)); + + const symlink = path.join(fixture.directory, 'context-link.json'); + fs.symlinkSync(fixture.contextFile, symlink); + assert.throws(() => loadQingLong3ClusterProductContext(symlink)); + + const binaryRejected = runCli([ + 'run', + `--context=${publicContext}`, + '--command=/private/command.json', + '--assertion=/private/assertion.jwt', + ]); + assert.equal(binaryRejected.status, 78); + assert.equal(binaryRejected.stdout, ''); + assert.deepEqual(JSON.parse(binaryRejected.stderr), { + schemaVersion: 1, + component: 'qinglong3-cluster-product-cli', + code: 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID', + message: 'QingLong 3.0 Cluster operator context is invalid', + }); + assert.equal(binaryRejected.stderr.includes(fixture.directory), false); + + assert.throws( + () => + resolveQingLong3ClusterProductCommand( + [ + 'approval', + `--context=${fixture.contextFile}`, + '--command=/private/command.json', + '--assertion=/private/assertion.jwt', + ], + moduleDirectory, + ), + /operator context is invalid/, + ); + + for (const args of [ + ['run', '--context'], + ['run', '--context='], + [ + 'run', + `--context=${fixture.contextFile}`, + `--context=${fixture.contextFile}`, + ], + ]) { + const result = resolveQingLong3ClusterProductCommand(args, moduleDirectory); + assert.equal(result.kind, 'invalid'); + } + assert.throws( + () => + resolveQingLong3ClusterProductCommand( + ['run', `--context=${fixture.contextFile}`, '--config'], + moduleDirectory, + ), + /operator context is invalid/, + ); +}); + test('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 })); @@ -169,6 +360,7 @@ 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.match(help.stdout, /--context=\/absolute\/operator-context\.json/); assert.equal(help.stderr, ''); const version = runCli(['--version']); diff --git a/scripts/ql3-cluster-admin-product-live-contract.cjs b/scripts/ql3-cluster-admin-product-live-contract.cjs index 7c8d5d51..e28eca4c 100644 --- a/scripts/ql3-cluster-admin-product-live-contract.cjs +++ b/scripts/ql3-cluster-admin-product-live-contract.cjs @@ -70,12 +70,79 @@ function runImage(image, args) { '--cpus', '0.25', '--tmpfs', - '/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700', + '/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001', image, ...args, ]); } +function runOperatorContextContract(image) { + const source = String.raw` +const { spawnSync } = require('node:child_process'); +const { writeFileSync } = require('node:fs'); +const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js'; +const config = '/tmp/run-client.json'; +const command = '/tmp/command.json'; +const assertion = '/tmp/assertion.jwt'; +const context = '/tmp/operator-context.json'; +for (const [file, contents] of [ + [config, '{}'], + [command, '{}'], + [assertion, 'a.b.c'], + [context, JSON.stringify({ schemaVersion: 1, commands: { run: { configFile: config } } })], +]) writeFileSync(file, contents, { mode: 0o600 }); +const injected = spawnSync(process.execPath, [facade, 'run', '--context=' + context, '--command=' + command, '--assertion=' + assertion], { encoding: 'utf8' }); +let injectedFailure; +try { injectedFailure = JSON.parse(injected.stderr); } catch { process.exit(21); } +if (injected.status !== 1 || injectedFailure.code !== 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_CONFIG_INVALID' || injected.stdout !== '') process.exit(22); +writeFileSync(context, JSON.stringify({ schemaVersion: 1, commands: { run: { configFile: config, assertionFile: assertion } } }), { mode: 0o600 }); +const rejected = spawnSync(process.execPath, [facade, 'run', '--context=' + context, '--command=' + command, '--assertion=' + assertion], { encoding: 'utf8' }); +let rejectedFailure; +try { rejectedFailure = JSON.parse(rejected.stderr); } catch { process.exit(23); } +if (rejected.status !== 78 || rejectedFailure.code !== 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID' || rejected.stdout !== '' || rejected.stderr.includes('/tmp/') || rejected.stderr.includes('assertion.jwt')) process.exit(24); +process.stdout.write(JSON.stringify({ schemaVersion: 1, injected: true, secretFieldsRejected: true })); +`; + const output = docker([ + 'run', + '--rm', + '--read-only', + '--network', + 'none', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + '10001:10001', + '--pids-limit', + '32', + '--memory', + '128m', + '--cpus', + '0.25', + '--tmpfs', + '/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001', + '--entrypoint', + 'node', + image, + '-e', + source, + ]); + let result; + try { + result = JSON.parse(output); + } catch { + fail('operator context result is invalid'); + } + if ( + result?.schemaVersion !== 1 || + result?.injected !== true || + result?.secretFieldsRejected !== true + ) { + fail('operator context contract drifted'); + } +} + function main() { if (process.env.QL3_CLUSTER_ADMIN_PRODUCT_LIVE !== '1') { fail('QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required'); @@ -114,6 +181,7 @@ function main() { } const version = runImage(image, ['--version']).trim(); if (version !== '3.0.0-alpha.0') fail('product version contract drifted'); + runOperatorContextContract(image); process.stdout.write( `${JSON.stringify({ @@ -123,6 +191,7 @@ function main() { user: fact.Config.User, imageBytes: fact.Size, commandCount: COMMANDS.length, + operatorContext: true, isolation: Object.freeze({ readOnlyRoot: true, network: 'none', diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index a6f8c15c..52091361 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -39,7 +39,15 @@ function requireExactOccurrences(source, pattern, expected, finding) { } } -function auditClusterImageCiWorkflow(source) { +function auditClusterImageCiWorkflow( + source, + adminProductLiveContract = readBoundedText( + path.join( + DEFAULT_ROOT, + 'scripts/ql3-cluster-admin-product-live-contract.cjs', + ), + ), +) { const workflow = yaml.load(source); const clusterImageJob = workflow?.jobs?.['cluster-image']; const localImageJob = workflow?.jobs?.['local-image']; @@ -279,6 +287,11 @@ function auditClusterImageCiWorkflow(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( + adminProductLiveContract, + /runOperatorContextContract\(image\);[\s\S]*operatorContext: true/, + 'native admin image contract must verify owner-private operator context injection', + ); requirePattern( source, /^ image-oci:\s*$/m, @@ -320,6 +333,7 @@ function auditClusterImageCiWorkflow(source) { nativeArchitectures: ['amd64', 'arm64'], runtimeInventory: true, clusterAdminProductFacade: true, + clusterAdminOperatorContext: true, ociAttestations: true, osVulnerabilityScan: { scanner: 'trivy@0.70.0', @@ -926,6 +940,12 @@ function auditClusterImageRelease(root = DEFAULT_ROOT) { const resolvedRoot = path.resolve(root); const ci = auditClusterImageCiWorkflow( readBoundedText(path.join(resolvedRoot, CI_WORKFLOW_PATH)), + readBoundedText( + path.join( + resolvedRoot, + 'scripts/ql3-cluster-admin-product-live-contract.cjs', + ), + ), ); const release = auditReleaseWorkflow( readBoundedText(path.join(resolvedRoot, RELEASE_WORKFLOW_PATH)), diff --git a/test/back/ql3ClusterDeploymentAudit.test.cjs b/test/back/ql3ClusterDeploymentAudit.test.cjs index 819dfdc3..0cf0ad8c 100644 --- a/test/back/ql3ClusterDeploymentAudit.test.cjs +++ b/test/back/ql3ClusterDeploymentAudit.test.cjs @@ -101,6 +101,44 @@ test('requires the bounded Cluster product facade and image entrypoint', () => { ); }); +test('ships a path-only Cluster operator context example without durable authority', () => { + const example = JSON.parse( + fs.readFileSync( + path.join( + ROOT, + 'deploy/kubernetes/ql3-cluster/operator-context.example.json', + ), + 'utf8', + ), + ); + assert.deepEqual(Object.keys(example).sort(), ['commands', 'schemaVersion']); + assert.equal(example.schemaVersion, 1); + assert.deepEqual(Object.keys(example.commands).sort(), [ + 'approval', + 'automation', + 'model-credential', + 'package', + 'package-kubernetes', + 'run', + 'worker-credential', + ]); + for (const [name, command] of Object.entries(example.commands)) { + assert.deepEqual( + Object.keys(command).sort(), + name === 'package-kubernetes' + ? ['configFile', 'kubernetesFile'] + : ['configFile'], + ); + for (const value of Object.values(command)) { + assert.match(value, /^\/secure\/qinglong3\/[a-z0-9-]+\.json$/); + } + } + assert.doesNotMatch( + JSON.stringify(example), + /assertion|commandFile|privateKey|token|password|secret/i, + ); +}); + 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 61e83a7a..b9dcccc5 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -27,6 +27,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { nativeArchitectures: ['amd64', 'arm64'], runtimeInventory: true, clusterAdminProductFacade: true, + clusterAdminOperatorContext: true, ociAttestations: true, osVulnerabilityScan: { scanner: 'trivy@0.70.0', @@ -110,6 +111,21 @@ test('rejects removal of the native Cluster Admin product facade gate', () => { ); }); +test('rejects a Cluster Admin live gate that omits operator context injection', () => { + const contract = fs.readFileSync( + path.join(root, 'scripts/ql3-cluster-admin-product-live-contract.cjs'), + 'utf8', + ); + assert.throws( + () => + auditClusterImageCiWorkflow( + ciSource, + contract.replace('operatorContext: true', 'operatorContext: false'), + ), + /owner-private operator context injection/, + ); +}); + 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 3a658955..7a15544c 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: 96, + sourceFiles: 97, rootSourceFiles: 1, rootSourceLines: 61, - nestedSourceFiles: 95, + nestedSourceFiles: 96, rootSourceFileRoles: { 'modelInvocationMigrationCli.ts': 'binary_entry', },