mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add owner-private cluster operator context
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 或 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(已接受)
|
- 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-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,门禁容器、网络与卷均零残留。
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user