feat(ql3): add owner-private cluster operator context

This commit is contained in:
whyour
2026-08-13 00:08:42 +08:00
parent 2934123155
commit ea3bbe35c3
14 changed files with 741 additions and 8 deletions
+20
View File
@@ -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 operations. Existing Jobs continue to name their exact binary and do not
inherit facade authority. 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 The runtime image installs only the 43 external packages reachable from the
five exact production roots in its production-only lock plus `runtime-core`, five exact production roots in its production-only lock plus `runtime-core`,
`cluster-postgres` and `cluster-control`: 46 runtime components in total. The `cluster-postgres` and `cluster-control`: 46 runtime components in total. The
@@ -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"
}
}
}
+16 -2
View File
@@ -6,11 +6,25 @@
- 目标版本:QingLong 3.x - 目标版本:QingLong 3.x
- 作者:QingLong Maintainers - 作者:QingLong Maintainers
- 创建日期:2026-07-17 - 创建日期:2026-07-17
- 最后更新:2026-08-12 - 最后更新:2026-08-13
- 讨论范围:架构与演进路线,不包含最终 UI 视觉方案 - 讨论范围:架构与演进路线,不包含最终 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 或 workloadLocal/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 packageCluster Admin 97 source 中 96 nested/1 root,无 single-source/shallow package。Cluster Admin 完整 package 为
293 pass/2 条件 skip18-package clean build/test 退出 0backend 为 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(已接受) - D-300/ADR-0388(已接受)
Cluster operator 已获得统一 `ql3-cluster-admin <command>` 产品入口,能力内聚在既有 `@qinglong/cluster-admin/product-cli`,不新增 Cluster operator 已获得统一 `ql3-cluster-admin <command>` 产品入口,能力内聚在既有 `@qinglong/cluster-admin/product-cli`,不新增
package 或依赖。catalog 只允许 Package(直连/Kubernetes tunnel)、Worker Credential、Approval、Run、Automation、Model package 或依赖。catalog 只允许 Package(直连/Kubernetes tunnel)、Worker Credential、Approval、Run、Automation、Model
@@ -0,0 +1,43 @@
# ADR-0389Owner-private Cluster Operator Context
- 状态:Accepted
- 日期:2026-08-13
- 关联 RFCQL-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 argvcontext 调用只注入 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 packageCluster Admin 97 source 中 96 nested、1 root,无 single-source/shallow package,未增加依赖或常驻资源。
- Cluster Admin 完整 package 为 293 pass/2 条件 skip18-package clean build/test 退出 0backend 为 1,186 pass/2 skip。deployment 专项 50/50package/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,门禁容器、网络与卷均零残留。
+1
View File
@@ -392,6 +392,7 @@
| [ADR-0386](./ADR-0386-cluster-run-management-kubernetes-live-evidence.md) | Cluster Run Management Kubernetes 多节点实证 | 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-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-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 |
## 规则 ## 规则
@@ -4,6 +4,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
import { constants } from 'node:os'; import { constants } from 'node:os';
import { resolveQingLong3ClusterProductCommand } from './productCommand'; import { resolveQingLong3ClusterProductCommand } from './productCommand';
import { QingLong3ClusterProductContextError } from './productContext';
const FORWARDED_SIGNALS = Object.freeze([ const FORWARDED_SIGNALS = Object.freeze([
'SIGINT', 'SIGINT',
@@ -124,7 +125,25 @@ function main(argv: readonly string[]): void {
return; return;
} }
invoke(resolution.targetFilePath, resolution.argv); 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( process.stderr.write(
`${JSON.stringify( `${JSON.stringify(
lowSensitivityFailure( lowSensitivityFailure(
@@ -1,6 +1,8 @@
import { lstatSync, readFileSync, realpathSync } from 'node:fs'; import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import { isAbsolute, relative, resolve, sep } from 'node:path'; import { isAbsolute, relative, resolve, sep } from 'node:path';
import { resolveQingLong3ClusterProductContextArguments } from './productContext';
export interface QingLong3ClusterProductCommandDefinition { export interface QingLong3ClusterProductCommandDefinition {
readonly name: string; readonly name: string;
readonly binary: string; readonly binary: string;
@@ -171,6 +173,8 @@ export function qingLong3ClusterProductHelp(): string {
commands, commands,
'', '',
'Use `ql3-cluster-admin <command> --help` for command-specific usage.', 'Use `ql3-cluster-admin <command> --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.', 'Server, migration, recovery, executor and key-custody authorities remain isolated.',
].join('\n'); ].join('\n');
} }
@@ -208,11 +212,36 @@ export function resolveQingLong3ClusterProductCommand(
message: 'unknown QingLong 3.0 Cluster product command', 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); const { distRoot } = installationPaths(moduleDirectory);
return Object.freeze({ return Object.freeze({
kind: 'invoke', kind: 'invoke',
command, command,
targetFilePath: resolveInstalledTarget(distRoot, command), targetFilePath: resolveInstalledTarget(distRoot, command),
argv: Object.freeze(argv.slice(1)), argv: forwardedArguments,
}); });
} }
@@ -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<string, unknown>;
export interface QingLong3ClusterProductContextCommand {
readonly configFile: string;
readonly kubernetesFile?: string;
}
export interface QingLong3ClusterProductContext {
readonly schemaVersion: 1;
readonly commands: Readonly<
Partial<Record<ContextCommandName, QingLong3ClusterProductContextCommand>>
>;
}
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<Record<string, number>>).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<QingLong3ClusterProductContext> {
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<ContextCommandName, QingLong3ClusterProductContextCommand>
> = {};
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,
]);
}
@@ -22,6 +22,10 @@ const {
clusterProductSignalExitCode, clusterProductSignalExitCode,
forwardClusterProductSignals, forwardClusterProductSignals,
} = require('../dist/product-cli/cli.js'); } = require('../dist/product-cli/cli.js');
const {
loadQingLong3ClusterProductContext,
resolveQingLong3ClusterProductContextArguments,
} = require('../dist/product-cli/productContext.js');
function runCli(args) { function runCli(args) {
return spawnSync(process.execPath, [cliPath, ...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', () => { 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(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 7); 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) => { test('rejects symlink targets and package manifests', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-product-')); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-product-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true })); 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']); const help = runCli(['--help']);
assert.equal(help.status, 0); assert.equal(help.status, 0);
assert.match(help.stdout, /^Usage: ql3-cluster-admin <command>/); assert.match(help.stdout, /^Usage: ql3-cluster-admin <command>/);
assert.match(help.stdout, /--context=\/absolute\/operator-context\.json/);
assert.equal(help.stderr, ''); assert.equal(help.stderr, '');
const version = runCli(['--version']); const version = runCli(['--version']);
@@ -70,12 +70,79 @@ function runImage(image, args) {
'--cpus', '--cpus',
'0.25', '0.25',
'--tmpfs', '--tmpfs',
'/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700', '/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
image, image,
...args, ...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() { function main() {
if (process.env.QL3_CLUSTER_ADMIN_PRODUCT_LIVE !== '1') { if (process.env.QL3_CLUSTER_ADMIN_PRODUCT_LIVE !== '1') {
fail('QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required'); fail('QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required');
@@ -114,6 +181,7 @@ function main() {
} }
const version = runImage(image, ['--version']).trim(); const version = runImage(image, ['--version']).trim();
if (version !== '3.0.0-alpha.0') fail('product version contract drifted'); if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
runOperatorContextContract(image);
process.stdout.write( process.stdout.write(
`${JSON.stringify({ `${JSON.stringify({
@@ -123,6 +191,7 @@ function main() {
user: fact.Config.User, user: fact.Config.User,
imageBytes: fact.Size, imageBytes: fact.Size,
commandCount: COMMANDS.length, commandCount: COMMANDS.length,
operatorContext: true,
isolation: Object.freeze({ isolation: Object.freeze({
readOnlyRoot: true, readOnlyRoot: true,
network: 'none', network: 'none',
+21 -1
View File
@@ -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 workflow = yaml.load(source);
const clusterImageJob = workflow?.jobs?.['cluster-image']; const clusterImageJob = workflow?.jobs?.['cluster-image'];
const localImageJob = workflow?.jobs?.['local-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\}"/, /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', '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( requirePattern(
source, source,
/^ image-oci:\s*$/m, /^ image-oci:\s*$/m,
@@ -320,6 +333,7 @@ function auditClusterImageCiWorkflow(source) {
nativeArchitectures: ['amd64', 'arm64'], nativeArchitectures: ['amd64', 'arm64'],
runtimeInventory: true, runtimeInventory: true,
clusterAdminProductFacade: true, clusterAdminProductFacade: true,
clusterAdminOperatorContext: true,
ociAttestations: true, ociAttestations: true,
osVulnerabilityScan: { osVulnerabilityScan: {
scanner: 'trivy@0.70.0', scanner: 'trivy@0.70.0',
@@ -926,6 +940,12 @@ function auditClusterImageRelease(root = DEFAULT_ROOT) {
const resolvedRoot = path.resolve(root); const resolvedRoot = path.resolve(root);
const ci = auditClusterImageCiWorkflow( const ci = auditClusterImageCiWorkflow(
readBoundedText(path.join(resolvedRoot, CI_WORKFLOW_PATH)), readBoundedText(path.join(resolvedRoot, CI_WORKFLOW_PATH)),
readBoundedText(
path.join(
resolvedRoot,
'scripts/ql3-cluster-admin-product-live-contract.cjs',
),
),
); );
const release = auditReleaseWorkflow( const release = auditReleaseWorkflow(
readBoundedText(path.join(resolvedRoot, RELEASE_WORKFLOW_PATH)), readBoundedText(path.join(resolvedRoot, RELEASE_WORKFLOW_PATH)),
@@ -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', () => { test('keeps Cluster AI optional with projected authority and an independent digest', () => {
const defaultEnabled = auditClusterDeployment({ const defaultEnabled = auditClusterDeployment({
root: ROOT, root: ROOT,
@@ -27,6 +27,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
nativeArchitectures: ['amd64', 'arm64'], nativeArchitectures: ['amd64', 'arm64'],
runtimeInventory: true, runtimeInventory: true,
clusterAdminProductFacade: true, clusterAdminProductFacade: true,
clusterAdminOperatorContext: true,
ociAttestations: true, ociAttestations: true,
osVulnerabilityScan: { osVulnerabilityScan: {
scanner: 'trivy@0.70.0', 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', () => { test('rejects removal of the native cluster-admin image gate', () => {
const mutated = ciSource.replace( const mutated = ciSource.replace(
'image_arch: arm64\n image: admin', 'image_arch: arm64\n image: admin',
+2 -2
View File
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles, rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
}, },
{ {
sourceFiles: 96, sourceFiles: 97,
rootSourceFiles: 1, rootSourceFiles: 1,
rootSourceLines: 61, rootSourceLines: 61,
nestedSourceFiles: 95, nestedSourceFiles: 96,
rootSourceFileRoles: { rootSourceFileRoles: {
'modelInvocationMigrationCli.ts': 'binary_entry', 'modelInvocationMigrationCli.ts': 'binary_entry',
}, },