feat(ql3): add offline cluster context preflight

This commit is contained in:
whyour
2026-08-13 01:19:23 +08:00
parent ea3bbe35c3
commit a83b4c5e8d
13 changed files with 805 additions and 99 deletions
+15
View File
@@ -69,6 +69,21 @@ contents are deliberately forbidden. There is no home-directory, environment
or ambient Kubernetes context discovery. Existing explicit `--config` calls or ambient Kubernetes context discovery. Existing explicit `--config` calls
remain supported when `--context` is absent. remain supported when `--context` is absent.
Validate the complete local operator context before a maintenance window:
```sh
ql3-cluster-admin context validate \
--context=/secure/qinglong3/operator-context.json
```
This command reuses the production client parsers to validate every selected
endpoint shape, CA, matching client certificate/private key and the bounded
Kubernetes config. It also verifies that all seven facade targets exist in the
same installation. It does not read a command or assertion, open a network
connection, query Kubernetes or mutate the cluster. Its JSON summary contains
only command names and reviewed transport/authentication classes; paths,
endpoints, namespaces, context names and credentials are never emitted.
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
+12
View File
@@ -11,6 +11,18 @@
最新增量证据(2026-08-13): 最新增量证据(2026-08-13):
- D-302/ADR-0390(已接受)
Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context
reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、
client certificate/private key 配对、embedded Kubernetes credential/config 与同安装七项 client target;只有真实命令才读取 command/
assertion、创建 Pod client、PortForward、socket 或 deadline timer。成功结果只暴露固定 command、transport/authentication class 与
`networkAccess:false/mutation:false`,不输出路径、endpoint、port、namespace、context、证书主体或 credential。实现仍内聚在既有
Cluster Admin product/client 目录,不新增 package、依赖、binary、listener、timer、数据库或 workloadLocal/Edge、Control、Worker 零导入。
product/preflight 11/11、七类 client/tunnel 定向 45/45Cluster Admin 完整 296 pass/2 条件 skip18-package clean build/test 退出 0
backend 1,187 pass/2 skip,五项边界审计零 finding。真实 arm64 Admin image 为 330,463,528 bytes,较 D-301 增加 10,219 bytes,并在
`10001:10001`、network none、read-only root、drop ALL、128 MiB/32 PIDs 下报告 `contextPreflight=true`。14 个 Local Profile artifact
字节数与 D-301 完全一致。PostgreSQL 18.4 arm64 HA 123 项 gate 全绿、timeline `1→2`,证据 SHA-256 为
`339cd10e1da2428da6c099c52c2397d5f79f7cb32b64b7e1ae927d2803b8cfc0`,离线审计及 Docker 清理通过。完整证据见 ADR-0390。
- D-301/ADR-0389(已接受) - D-301/ADR-0389(已接受)
`ql3-cluster-admin` 现在可通过显式 `--context=/absolute/operator-context.json` 复用七个远程 client 的稳定路径,同时保持每次 `ql3-cluster-admin` 现在可通过显式 `--context=/absolute/operator-context.json` 复用七个远程 client 的稳定路径,同时保持每次
command 与短生命周期强 assertion 必须显式传入。schema v1 只允许 catalog command → `configFile`Kubernetes tunnel 精确多一个 command 与短生命周期强 assertion 必须显式传入。schema v1 只允许 catalog command → `configFile`Kubernetes tunnel 精确多一个
@@ -0,0 +1,42 @@
# ADR-0390Offline Cluster Operator Context Preflight
- 状态:Accepted
- 日期:2026-08-13
- 关联 RFCQL-RFC-0001 D-302
- 前置决策:ADR-0250、ADR-0388、ADR-0389
## 上下文
ADR-0389 让操作者可以显式复用稳定 client 路径,但 context reader 只证明路径、owner、权限和 schemaendpoint route、CA、client certificate/private key 配对及 Kubernetes config 仍要到某次真实命令才由生产 client 发现。发布或维护窗口中用 mutation 命令做配置探针既迟又危险,另写一套宽松校验器则会与真实请求路径发生语义漂移。
## 决策
1. `ql3-cluster-admin` 增加内建 `context validate --context=/absolute/operator-context.json`。它是 facade 的本地命令,不进入七项远程 client catalog,不产生新的 binary、package、进程或部署 authority。
2. context 仍先通过 ADR-0389 的 canonical/current-UID/`0600`/no-follow reader。随后每个 entry 必须调用与真实 client 相同的 production configuration preparation:精确 route、HTTPS hostname、timeout、CA、client certificate/private key 配对及 Kubernetes embedded credential/config 全部同语义验证。
3. production HTTPS client 把配置读取、解析和密钥配对提炼为可显式 `dispose()` 的 preparation;真实请求与 preflight 共同调用。Kubernetes tunnel 同样提炼 config preparation;只有真实命令才创建 Pod client、PortForward、socket 和 deadline timer。
4. preflight 不读取 per-operation command 或 assertion,不打开 socket、不访问 DNS/HTTP/Kubernetes、不创建 timer、不查询数据库且不执行 mutation。它还复验同一安装内七个受审 client target 均为 canonical non-symlink regular file。
5. 成功结果只输出固定 command 名、`https|kubernetes-port-forward`、是否要求 client certificate、Kubernetes credential class,以及 `networkAccess:false/mutation:false`。禁止输出路径、endpoint、port、servername、namespace、Kubernetes context、证书主体、token 或其他 credential 内容。
6. 任一 context、安装或引用配置失败均失败关闭。context/config 类错误统一返回低敏 `QL3_CLUSTER_PRODUCT_CONTEXT_INVALID` 与退出 78;CLI 语法错误返回既有 usage code 与退出 64;不暴露失败 entry 或路径。
7. 能力继续只存在于短生命周期 Cluster Admin image。Local/Edge、Cluster Control 与 Worker 的依赖、文件、模块、RSS、listener、timer、连接和镜像闭包不得变化。
## 不采用方案
- **发送 health/read-only 请求验证连接**:仍会产生外部副作用、泄露使用时机,也不能证明 mutation 路径的全部本地材料。
- **复用某个虚构 command/assertion 执行真实 client**:会混淆配置错误与业务拒绝,并可能进入审计、quota 或 mutation 前置流程。
- **复制 JSON schema 到 facade**:两套 parser 会随 route、TLS 或 Kubernetes contract 演进而漂移。
- **把 endpoint 或证书主体写入成功摘要**:这些事实对“本地材料可解析”结论没有必要,会扩大日志敏感面。
- **为 preflight 新建 package**:它只有一个 Cluster Admin 产品消费者,不形成部署、authority 或供应链边界。
## 验收门
- 七命令完整 context、部分 context、mTLS/非 mTLS、Kubernetes token/certificate、错误 route、CA、key 配对、权限、symlink、未知字段与低敏失败;
- 生产 HTTPS/Kubernetes client 全量回归,证明提炼前后请求、TLS、PortForward 和错误语义不变;
- 真实 Admin image 在 network none、non-root、read-only root、drop ALL、no-new-privileges、128 MiB/32 PIDs 下完成 preflight
- package/dependency/deployment/image release/Local image、18-package clean build/test、backend、14 Local Profile artifact 与 PostgreSQL HA 不回归。
## 当前证据
- product facade/preflight 11/11;七类 client 与 Kubernetes tunnel 定向回归 45/45。Cluster Admin 完整回归 298 tests、296 pass/2 条件 skip/0 fail18-package clean build/test 退出 0backend 1,189 tests、1,187 pass/2 skip/0 fail。
- 真实 arm64 Admin image 为 330,463,528 bytes,较 D-301 增加 10,219 bytes;在 `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下报告 `contextPreflight=true`。五项 package/dependency/deployment/image release/Local image 边界审计零 finding。
- workspace 保持 18 packageCluster Admin 97 source 中 96 nested/1 root,无 single-source/shallow package且未增加依赖。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-301 对应制品字节数一致。
- PostgreSQL 18.4 arm64 HA 123 项 gate 全绿,timeline `1→2`;报告 SHA-256 为 `339cd10e1da2428da6c099c52c2397d5f79f7cb32b64b7e1ae927d2803b8cfc0`,离线审计零 finding,门禁容器、网络与卷均零残留。
+1
View File
@@ -393,6 +393,7 @@
| [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 | | [ADR-0389](./ADR-0389-owner-private-cluster-operator-context.md) | Owner-private Cluster Operator Context | Accepted |
| [ADR-0390](./ADR-0390-offline-cluster-operator-context-preflight.md) | Offline Cluster Operator Context Preflight | Accepted |
## 规则 ## 规则
@@ -23,14 +23,48 @@ import {
} from '../plugin-package/management/pluginPackageManagementTransport'; } from '../plugin-package/management/pluginPackageManagementTransport';
const MANAGEMENT_PATH = '/api/v3/plugin-packages/management'; const MANAGEMENT_PATH = '/api/v3/plugin-packages/management';
const ALLOWED_MANAGEMENT_PATHS = new Set([ export type ClusterAuthenticatedManagementClientKind =
MANAGEMENT_PATH, | 'package'
'/api/v3/worker-credentials/management', | 'worker-credential'
'/api/v3/automations/management', | 'automation'
'/api/v3/approvals/management', | 'approval'
'/api/v3/provider-credentials/management', | 'model-credential'
'/api/v3/runs/management', | 'run';
]);
const MANAGEMENT_CLIENT_POLICIES: Readonly<
Record<
ClusterAuthenticatedManagementClientKind,
Readonly<{
managementPath: string;
clientCertificate: 'forbidden' | 'required';
}>
>
> = Object.freeze({
package: Object.freeze({
managementPath: MANAGEMENT_PATH,
clientCertificate: 'forbidden',
}),
'worker-credential': Object.freeze({
managementPath: '/api/v3/worker-credentials/management',
clientCertificate: 'required',
}),
automation: Object.freeze({
managementPath: '/api/v3/automations/management',
clientCertificate: 'required',
}),
approval: Object.freeze({
managementPath: '/api/v3/approvals/management',
clientCertificate: 'required',
}),
'model-credential': Object.freeze({
managementPath: '/api/v3/provider-credentials/management',
clientCertificate: 'required',
}),
run: Object.freeze({
managementPath: '/api/v3/runs/management',
clientCertificate: 'required',
}),
});
const MAX_CONFIG_BYTES = 16 * 1024; const MAX_CONFIG_BYTES = 16 * 1024;
const MAX_ASSERTION_BYTES = 16 * 1024; const MAX_ASSERTION_BYTES = 16 * 1024;
const MAX_COMMAND_BYTES = 256 * 1024; const MAX_COMMAND_BYTES = 256 * 1024;
@@ -778,68 +812,48 @@ function rawHeaderCount(rawHeaders: readonly string[], name: string): number {
return count; return count;
} }
export async function executeClusterAuthenticatedManagementClient< export interface ClusterAuthenticatedManagementClientConfigurationSummary {
Command, readonly schemaVersion: 1;
Result, readonly managementPath: string;
>( readonly transport: 'https';
paths: ClusterPluginPackageManagementClientPaths, readonly clientCertificate: 'forbidden' | 'required';
protocol: ClusterAuthenticatedManagementClientProtocol<Command, Result>, }
connectionOptions?: ClusterPluginPackageManagementClientConnectionOptions,
): Promise<Readonly<ClusterAuthenticatedManagementClientResult<Result>>> { interface PreparedClusterAuthenticatedManagementClientConfiguration {
exactObject(paths, ['configFile', 'commandFile', 'assertionFile']); readonly endpoint: URL;
readonly servername: string;
readonly port: number;
readonly requestTimeoutMs: number;
readonly caBytes: Buffer;
readonly clientCertificateBytes?: Buffer;
readonly clientPrivateKeyBytes?: Buffer;
dispose(): void;
}
function prepareClusterAuthenticatedManagementClientConfiguration(
configFile: string,
managementPath: string,
clientCertificate: 'forbidden' | 'required',
): PreparedClusterAuthenticatedManagementClientConfiguration {
if ( if (
!protocol || !Object.values(MANAGEMENT_CLIENT_POLICIES).some(
typeof protocol !== 'object' || (policy) =>
Array.isArray(protocol) || policy.managementPath === managementPath &&
Object.keys(protocol).length !== 4 || policy.clientCertificate === clientCertificate,
Object.keys(protocol).some( )
(key) =>
![
'managementPath',
'clientCertificate',
'normalizeCommand',
'validateResult',
].includes(key),
) ||
!ALLOWED_MANAGEMENT_PATHS.has(protocol.managementPath) ||
!['forbidden', 'required'].includes(protocol.clientCertificate) ||
typeof protocol.normalizeCommand !== 'function' ||
typeof protocol.validateResult !== 'function' ||
(connectionOptions !== undefined &&
(!connectionOptions ||
typeof connectionOptions !== 'object' ||
Array.isArray(connectionOptions) ||
Object.keys(connectionOptions).length !== 1 ||
typeof connectionOptions.connect !== 'function'))
) { ) {
throw configurationFailure(); throw configurationFailure();
} }
let configBytes: Buffer | undefined; let configBytes: Buffer | undefined;
let commandBytes: Buffer | undefined;
let assertionBytes: Buffer | undefined;
let caBytes: Buffer | undefined; let caBytes: Buffer | undefined;
let clientCertificateBytes: Buffer | undefined; let clientCertificateBytes: Buffer | undefined;
let clientPrivateKeyBytes: Buffer | undefined; let clientPrivateKeyBytes: Buffer | undefined;
try { try {
configBytes = readCanonicalFile( configBytes = readCanonicalFile(configFile, MAX_CONFIG_BYTES, 'private');
paths.configFile,
MAX_CONFIG_BYTES,
'private',
);
commandBytes = readCanonicalFile(
paths.commandFile,
MAX_COMMAND_BYTES,
'private',
);
assertionBytes = readCanonicalFile(
paths.assertionFile,
MAX_ASSERTION_BYTES,
'private',
);
const config = parseJson(configBytes); const config = parseJson(configBytes);
exactObject( exactObject(
config, config,
protocol.clientCertificate === 'required' clientCertificate === 'required'
? [ ? [
'schemaVersion', 'schemaVersion',
'endpoint', 'endpoint',
@@ -864,7 +878,7 @@ export async function executeClusterAuthenticatedManagementClient<
!DNS_NAME_PATTERN.test(config.servername) || !DNS_NAME_PATTERN.test(config.servername) ||
isIP(config.servername) !== 0 || isIP(config.servername) !== 0 ||
typeof config.caFile !== 'string' || typeof config.caFile !== 'string' ||
(protocol.clientCertificate === 'required' && (clientCertificate === 'required' &&
(typeof config.clientCertificateFile !== 'string' || (typeof config.clientCertificateFile !== 'string' ||
typeof config.clientPrivateKeyFile !== 'string')) || typeof config.clientPrivateKeyFile !== 'string')) ||
!Number.isSafeInteger(config.requestTimeoutMs) || !Number.isSafeInteger(config.requestTimeoutMs) ||
@@ -874,7 +888,6 @@ export async function executeClusterAuthenticatedManagementClient<
throw configurationFailure(); throw configurationFailure();
} }
const servername = config.servername; const servername = config.servername;
const caFile = config.caFile;
const requestTimeoutMs = config.requestTimeoutMs as number; const requestTimeoutMs = config.requestTimeoutMs as number;
let endpoint: URL; let endpoint: URL;
try { try {
@@ -888,7 +901,7 @@ export async function executeClusterAuthenticatedManagementClient<
endpoint.password !== '' || endpoint.password !== '' ||
endpoint.search !== '' || endpoint.search !== '' ||
endpoint.hash !== '' || endpoint.hash !== '' ||
endpoint.pathname !== protocol.managementPath || endpoint.pathname !== managementPath ||
endpoint.hostname !== servername || endpoint.hostname !== servername ||
isIP(endpoint.hostname) !== 0 isIP(endpoint.hostname) !== 0
) { ) {
@@ -898,13 +911,17 @@ export async function executeClusterAuthenticatedManagementClient<
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
throw configurationFailure(); throw configurationFailure();
} }
caBytes = readCanonicalFile(caFile, MAX_CA_BYTES, 'public-integrity'); caBytes = readCanonicalFile(
config.caFile as string,
MAX_CA_BYTES,
'public-integrity',
);
try { try {
new X509Certificate(caBytes); new X509Certificate(caBytes);
} catch { } catch {
throw configurationFailure(); throw configurationFailure();
} }
if (protocol.clientCertificate === 'required') { if (clientCertificate === 'required') {
clientCertificateBytes = readCanonicalFile( clientCertificateBytes = readCanonicalFile(
config.clientCertificateFile as string, config.clientCertificateFile as string,
MAX_CLIENT_CERTIFICATE_BYTES, MAX_CLIENT_CERTIFICATE_BYTES,
@@ -931,6 +948,134 @@ export async function executeClusterAuthenticatedManagementClient<
throw configurationFailure(); throw configurationFailure();
} }
} }
let disposed = false;
return Object.freeze({
endpoint,
servername,
port,
requestTimeoutMs,
caBytes,
...(clientCertificateBytes === undefined
? {}
: {
clientCertificateBytes,
clientPrivateKeyBytes: clientPrivateKeyBytes!,
}),
dispose() {
if (disposed) return;
disposed = true;
caBytes?.fill(0);
clientCertificateBytes?.fill(0);
clientPrivateKeyBytes?.fill(0);
},
});
} catch (error) {
caBytes?.fill(0);
clientCertificateBytes?.fill(0);
clientPrivateKeyBytes?.fill(0);
if (
error instanceof ClusterPluginPackageManagementClientConfigurationError
) {
throw error;
}
throw configurationFailure();
} finally {
configBytes?.fill(0);
}
}
export function validateClusterAuthenticatedManagementClientConfiguration(
configFile: string,
kind: ClusterAuthenticatedManagementClientKind,
): Readonly<ClusterAuthenticatedManagementClientConfigurationSummary> {
const policy = MANAGEMENT_CLIENT_POLICIES[kind];
if (policy === undefined) throw configurationFailure();
const prepared = prepareClusterAuthenticatedManagementClientConfiguration(
configFile,
policy.managementPath,
policy.clientCertificate,
);
try {
return Object.freeze({
schemaVersion: 1,
managementPath: policy.managementPath,
transport: 'https',
clientCertificate: policy.clientCertificate,
});
} finally {
prepared.dispose();
}
}
export async function executeClusterAuthenticatedManagementClient<
Command,
Result,
>(
paths: ClusterPluginPackageManagementClientPaths,
protocol: ClusterAuthenticatedManagementClientProtocol<Command, Result>,
connectionOptions?: ClusterPluginPackageManagementClientConnectionOptions,
): Promise<Readonly<ClusterAuthenticatedManagementClientResult<Result>>> {
exactObject(paths, ['configFile', 'commandFile', 'assertionFile']);
if (
!protocol ||
typeof protocol !== 'object' ||
Array.isArray(protocol) ||
Object.keys(protocol).length !== 4 ||
Object.keys(protocol).some(
(key) =>
![
'managementPath',
'clientCertificate',
'normalizeCommand',
'validateResult',
].includes(key),
) ||
!Object.values(MANAGEMENT_CLIENT_POLICIES).some(
(policy) =>
policy.managementPath === protocol.managementPath &&
policy.clientCertificate === protocol.clientCertificate,
) ||
typeof protocol.normalizeCommand !== 'function' ||
typeof protocol.validateResult !== 'function' ||
(connectionOptions !== undefined &&
(!connectionOptions ||
typeof connectionOptions !== 'object' ||
Array.isArray(connectionOptions) ||
Object.keys(connectionOptions).length !== 1 ||
typeof connectionOptions.connect !== 'function'))
) {
throw configurationFailure();
}
let commandBytes: Buffer | undefined;
let assertionBytes: Buffer | undefined;
let prepared:
| PreparedClusterAuthenticatedManagementClientConfiguration
| undefined;
try {
prepared = prepareClusterAuthenticatedManagementClientConfiguration(
paths.configFile,
protocol.managementPath,
protocol.clientCertificate,
);
commandBytes = readCanonicalFile(
paths.commandFile,
MAX_COMMAND_BYTES,
'private',
);
assertionBytes = readCanonicalFile(
paths.assertionFile,
MAX_ASSERTION_BYTES,
'private',
);
const {
endpoint,
servername,
port,
requestTimeoutMs,
caBytes,
clientCertificateBytes,
clientPrivateKeyBytes,
} = prepared;
const command = protocol.normalizeCommand(parseJson(commandBytes)); const command = protocol.normalizeCommand(parseJson(commandBytes));
const assertion = assertionBytes.toString('ascii'); const assertion = assertionBytes.toString('ascii');
if ( if (
@@ -1186,12 +1331,9 @@ export async function executeClusterAuthenticatedManagementClient<
} }
} }
} finally { } finally {
configBytes?.fill(0);
commandBytes?.fill(0); commandBytes?.fill(0);
assertionBytes?.fill(0); assertionBytes?.fill(0);
caBytes?.fill(0); prepared?.dispose();
clientCertificateBytes?.fill(0);
clientPrivateKeyBytes?.fill(0);
} }
} }
@@ -49,6 +49,20 @@ interface ReviewedKubernetesClientConfig {
readonly apiTimeoutMs: number; readonly apiTimeoutMs: number;
} }
export interface ClusterPluginPackageManagementKubernetesConfigurationSummary {
readonly schemaVersion: 1;
readonly transport: 'kubernetes-port-forward';
readonly authentication: 'token' | 'client-certificate';
}
interface PreparedKubernetesClientConfiguration {
readonly config: Readonly<ReviewedKubernetesClientConfig>;
readonly kubeConfig: KubernetesConfig;
readonly kubernetes: KubernetesModule;
readonly authentication: 'token' | 'client-certificate';
dispose(): void;
}
interface KubernetesPod { interface KubernetesPod {
readonly metadata?: { readonly metadata?: {
readonly name?: string; readonly name?: string;
@@ -450,6 +464,95 @@ function validateKubeConfig(
} }
} }
async function prepareKubernetesClientConfiguration(
kubernetesFile: string,
): Promise<PreparedKubernetesClientConfiguration> {
let kubernetesConfigBytes: Buffer | undefined;
let kubeconfigBytes: Buffer | undefined;
try {
kubernetesConfigBytes = readPrivateFile(
kubernetesFile,
MAX_KUBERNETES_CONFIG_BYTES,
);
const config = normalizeConfig(parseJson(kubernetesConfigBytes));
kubeconfigBytes = readPrivateFile(
config.kubeconfigFile,
MAX_KUBECONFIG_BYTES,
);
const rawKubeconfig = parseJson(kubeconfigBytes);
validateRawKubeconfig(rawKubeconfig, config);
let kubernetes: KubernetesModule;
try {
kubernetes = await import('@kubernetes/client-node');
} catch (error) {
throw new ClusterPluginPackageManagementKubernetesClientTunnelError(
error,
);
}
const kubeConfig = new kubernetes.KubeConfig();
try {
kubeConfig.loadFromString(decodeUtf8(kubeconfigBytes));
validateKubeConfig(kubeConfig, config);
} catch (error) {
if (
error instanceof
ClusterPluginPackageManagementKubernetesClientConfigurationError
) {
throw error;
}
throw configurationFailure();
}
const rawUser = (rawKubeconfig as JsonObject).users as readonly JsonObject[];
const authentication = Object.hasOwn(
rawUser[0]!.user as object,
'token',
)
? 'token'
: 'client-certificate';
let disposed = false;
return Object.freeze({
config,
kubeConfig,
kubernetes,
authentication,
dispose() {
if (disposed) return;
disposed = true;
kubernetesConfigBytes?.fill(0);
kubeconfigBytes?.fill(0);
},
});
} catch (error) {
kubernetesConfigBytes?.fill(0);
kubeconfigBytes?.fill(0);
if (
error instanceof
ClusterPluginPackageManagementKubernetesClientConfigurationError ||
error instanceof ClusterPluginPackageManagementKubernetesClientTunnelError
) {
throw error;
}
throw configurationFailure();
}
}
export async function validateClusterPluginPackageManagementKubernetesConfiguration(
kubernetesFile: string,
): Promise<
Readonly<ClusterPluginPackageManagementKubernetesConfigurationSummary>
> {
const prepared = await prepareKubernetesClientConfiguration(kubernetesFile);
try {
return Object.freeze({
schemaVersion: 1,
transport: 'kubernetes-port-forward',
authentication: prepared.authentication,
});
} finally {
prepared.dispose();
}
}
function isReviewedPod( function isReviewedPod(
value: KubernetesPod, value: KubernetesPod,
namespace: string, namespace: string,
@@ -681,34 +784,12 @@ export async function executeClusterPluginPackageManagementKubernetesClient(
throw configurationFailure(); throw configurationFailure();
} }
let kubernetesConfigBytes: Buffer | undefined; let prepared: PreparedKubernetesClientConfiguration | undefined;
let kubeconfigBytes: Buffer | undefined;
try { try {
kubernetesConfigBytes = readPrivateFile( prepared = await prepareKubernetesClientConfiguration(
paths.kubernetesFile, paths.kubernetesFile,
MAX_KUBERNETES_CONFIG_BYTES,
); );
const config = normalizeConfig(parseJson(kubernetesConfigBytes)); const { config, kubeConfig, kubernetes } = prepared;
kubeconfigBytes = readPrivateFile(
config.kubeconfigFile,
MAX_KUBECONFIG_BYTES,
);
const kubernetes = await import('@kubernetes/client-node');
const kubeConfig = new kubernetes.KubeConfig();
try {
const kubeconfigText = decodeUtf8(kubeconfigBytes);
validateRawKubeconfig(parseJson(kubeconfigBytes), config);
kubeConfig.loadFromString(kubeconfigText);
validateKubeConfig(kubeConfig, config);
} catch (error) {
if (
error instanceof
ClusterPluginPackageManagementKubernetesClientConfigurationError
) {
throw error;
}
throw configurationFailure();
}
const runtime = (options.createRuntime ?? productionRuntime)( const runtime = (options.createRuntime ?? productionRuntime)(
kubeConfig, kubeConfig,
kubernetes, kubernetes,
@@ -790,7 +871,6 @@ export async function executeClusterPluginPackageManagementKubernetesClient(
error, error,
); );
} finally { } finally {
kubernetesConfigBytes?.fill(0); prepared?.dispose();
kubeconfigBytes?.fill(0);
} }
} }
@@ -5,6 +5,7 @@ import { constants } from 'node:os';
import { resolveQingLong3ClusterProductCommand } from './productCommand'; import { resolveQingLong3ClusterProductCommand } from './productCommand';
import { QingLong3ClusterProductContextError } from './productContext'; import { QingLong3ClusterProductContextError } from './productContext';
import { validateQingLong3ClusterProductContext } from './productContext';
const FORWARDED_SIGNALS = Object.freeze([ const FORWARDED_SIGNALS = Object.freeze([
'SIGINT', 'SIGINT',
@@ -108,7 +109,7 @@ function invoke(targetFilePath: string, argv: readonly string[]): void {
}); });
} }
function main(argv: readonly string[]): void { async function main(argv: readonly string[]): Promise<void> {
try { try {
const resolution = resolveQingLong3ClusterProductCommand(argv, __dirname); const resolution = resolveQingLong3ClusterProductCommand(argv, __dirname);
if (resolution.kind === 'help' || resolution.kind === 'version') { if (resolution.kind === 'help' || resolution.kind === 'version') {
@@ -124,6 +125,13 @@ function main(argv: readonly string[]): void {
process.exitCode = 64; process.exitCode = 64;
return; return;
} }
if (resolution.kind === 'context-validation') {
const result = await validateQingLong3ClusterProductContext(
resolution.contextFile,
);
process.stdout.write(`${JSON.stringify(result)}\n`);
return;
}
invoke(resolution.targetFilePath, resolution.argv); invoke(resolution.targetFilePath, resolution.argv);
} catch (error) { } catch (error) {
if ( if (
@@ -157,5 +165,5 @@ function main(argv: readonly string[]): void {
} }
if (require.main === module) { if (require.main === module) {
main(process.argv.slice(2)); void main(process.argv.slice(2));
} }
@@ -13,6 +13,7 @@ export interface QingLong3ClusterProductCommandDefinition {
export type QingLong3ClusterProductCommandResolution = export type QingLong3ClusterProductCommandResolution =
| Readonly<{ kind: 'help'; output: string }> | Readonly<{ kind: 'help'; output: string }>
| Readonly<{ kind: 'version'; output: string }> | Readonly<{ kind: 'version'; output: string }>
| Readonly<{ kind: 'context-validation'; contextFile: string }>
| Readonly<{ | Readonly<{
kind: 'invoke'; kind: 'invoke';
command: QingLong3ClusterProductCommandDefinition; command: QingLong3ClusterProductCommandDefinition;
@@ -172,6 +173,9 @@ export function qingLong3ClusterProductHelp(): string {
'Remote client commands:', 'Remote client commands:',
commands, commands,
'', '',
'Local operator commands:',
' context validate --context=/absolute/operator-context.json',
'',
'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.', 'Use `--context=/absolute/operator-context.json` to inject only stable client paths.',
'Command and short-lived assertion files always remain explicit per invocation.', 'Command and short-lived assertion files always remain explicit per invocation.',
@@ -193,6 +197,28 @@ export function resolveQingLong3ClusterProductCommand(
output: qingLong3ClusterProductHelp(), output: qingLong3ClusterProductHelp(),
}); });
} }
if (argv[0] === 'context') {
if (
argv.length !== 3 ||
argv[1] !== 'validate' ||
!argv[2]!.startsWith('--context=') ||
argv[2] === '--context='
) {
return Object.freeze({
kind: 'invalid',
code: 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID',
message: 'QingLong 3.0 Cluster product context command is invalid',
});
}
const { distRoot } = installationPaths(moduleDirectory);
for (const definition of QINGLONG3_CLUSTER_PRODUCT_COMMANDS) {
resolveInstalledTarget(distRoot, definition);
}
return Object.freeze({
kind: 'context-validation',
contextFile: argv[2]!.slice('--context='.length),
});
}
if ( if (
argv.length === 1 && argv.length === 1 &&
(argv[0] === '--version' || argv[0] === '-V' || argv[0] === 'version') (argv[0] === '--version' || argv[0] === '-V' || argv[0] === 'version')
@@ -10,6 +10,10 @@ import {
import { isAbsolute } from 'node:path'; import { isAbsolute } from 'node:path';
import { TextDecoder } from 'node:util'; import { TextDecoder } from 'node:util';
import { validateClusterAuthenticatedManagementClientConfiguration } from '../management-support/pluginPackageManagementClient';
import type { ClusterAuthenticatedManagementClientKind } from '../management-support/pluginPackageManagementClient';
import { validateClusterPluginPackageManagementKubernetesConfiguration } from '../plugin-package/management/pluginPackageManagementKubernetesClient';
const MAXIMUM_CONTEXT_BYTES = 64 * 1024; const MAXIMUM_CONTEXT_BYTES = 64 * 1024;
const MAXIMUM_PATH_BYTES = 4_096; const MAXIMUM_PATH_BYTES = 4_096;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/u; const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/u;
@@ -38,6 +42,33 @@ export interface QingLong3ClusterProductContext {
>; >;
} }
export interface QingLong3ClusterProductContextValidation {
readonly schemaVersion: 1;
readonly component: 'qinglong3-cluster-product-cli';
readonly event: 'context_valid';
readonly commandCount: number;
readonly commands: readonly Readonly<{
name: ContextCommandName;
transport: 'https' | 'kubernetes-port-forward';
clientCertificate: 'forbidden' | 'required';
kubernetesAuthentication?: 'token' | 'client-certificate';
}>[];
readonly networkAccess: false;
readonly mutation: false;
}
const CONTEXT_COMMAND_CLIENT_KINDS: Readonly<
Record<ContextCommandName, ClusterAuthenticatedManagementClientKind>
> = Object.freeze({
package: 'package',
'package-kubernetes': 'package',
'worker-credential': 'worker-credential',
approval: 'approval',
run: 'run',
automation: 'automation',
'model-credential': 'model-credential',
});
export class QingLong3ClusterProductContextError extends TypeError { export class QingLong3ClusterProductContextError extends TypeError {
readonly code = 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID'; readonly code = 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID';
@@ -243,3 +274,57 @@ export function resolveQingLong3ClusterProductContextArguments(
...argv, ...argv,
]); ]);
} }
export async function validateQingLong3ClusterProductContext(
contextFile: string,
): Promise<Readonly<QingLong3ClusterProductContextValidation>> {
try {
const context = loadQingLong3ClusterProductContext(contextFile);
const commands: Array<
QingLong3ClusterProductContextValidation['commands'][number]
> = [];
for (const name of CONTEXT_COMMANDS) {
const command = context.commands[name];
if (command === undefined) continue;
const clientKind = CONTEXT_COMMAND_CLIENT_KINDS[name];
const https = validateClusterAuthenticatedManagementClientConfiguration(
command.configFile,
clientKind,
);
if (name === 'package-kubernetes') {
const kubernetes =
await validateClusterPluginPackageManagementKubernetesConfiguration(
command.kubernetesFile!,
);
commands.push(
Object.freeze({
name,
transport: kubernetes.transport,
clientCertificate: https.clientCertificate,
kubernetesAuthentication: kubernetes.authentication,
}),
);
} else {
commands.push(
Object.freeze({
name,
transport: https.transport,
clientCertificate: https.clientCertificate,
}),
);
}
}
return Object.freeze({
schemaVersion: 1,
component: 'qinglong3-cluster-product-cli',
event: 'context_valid',
commandCount: commands.length,
commands: Object.freeze(commands),
networkAccess: false,
mutation: false,
});
} catch (error) {
if (error instanceof QingLong3ClusterProductContextError) throw error;
throw new QingLong3ClusterProductContextError();
}
}
@@ -9,6 +9,18 @@ const { test } = require('node:test');
const packageRoot = path.resolve(__dirname, '..'); const packageRoot = path.resolve(__dirname, '..');
const moduleDirectory = path.join(packageRoot, 'dist', 'product-cli'); const moduleDirectory = path.join(packageRoot, 'dist', 'product-cli');
const cliPath = path.join(moduleDirectory, 'cli.js'); const cliPath = path.join(moduleDirectory, 'cli.js');
const certificateFixture = path.join(
packageRoot,
'test',
'fixtures',
'management-service-cert.pem',
);
const privateKeyFixture = path.join(
packageRoot,
'test',
'fixtures',
'management-service-key.pem',
);
const manifest = JSON.parse( const manifest = JSON.parse(
fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'),
); );
@@ -26,6 +38,9 @@ const {
loadQingLong3ClusterProductContext, loadQingLong3ClusterProductContext,
resolveQingLong3ClusterProductContextArguments, resolveQingLong3ClusterProductContextArguments,
} = require('../dist/product-cli/productContext.js'); } = require('../dist/product-cli/productContext.js');
const {
validateClusterAuthenticatedManagementClientConfiguration,
} = require('../dist/management-support/pluginPackageManagementClient.js');
function runCli(args) { function runCli(args) {
return spawnSync(process.execPath, [cliPath, ...args], { return spawnSync(process.execPath, [cliPath, ...args], {
@@ -65,6 +80,135 @@ function contextFixture(t) {
return { directory, runConfig, packageConfig, kubernetes, contextFile }; return { directory, runConfig, packageConfig, kubernetes, contextFile };
} }
function validContextFixture(t) {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-valid-context-')),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const caFile = privateFile(
directory,
'management-ca.pem',
fs.readFileSync(certificateFixture),
);
const clientCertificateFile = privateFile(
directory,
'operator-certificate.pem',
fs.readFileSync(certificateFixture),
);
const clientPrivateKeyFile = privateFile(
directory,
'operator-private-key.pem',
fs.readFileSync(privateKeyFixture),
);
function config(name, managementPath, clientCertificate) {
return privateFile(
directory,
`${name}.json`,
JSON.stringify({
schemaVersion: 1,
endpoint: `https://manager.example.test:8443${managementPath}`,
servername: 'manager.example.test',
caFile,
...(clientCertificate === 'required'
? { clientCertificateFile, clientPrivateKeyFile }
: {}),
requestTimeoutMs: 1_000,
}),
);
}
const packageConfig = config(
'package-client',
'/api/v3/plugin-packages/management',
'forbidden',
);
const kubeconfigFile = privateFile(
directory,
'kubeconfig.json',
JSON.stringify({
apiVersion: 'v1',
kind: 'Config',
clusters: [
{
name: 'production',
cluster: {
server: 'https://kubernetes.example.test:6443',
'certificate-authority-data': fs
.readFileSync(certificateFixture)
.toString('base64'),
},
},
],
users: [{ name: 'operator', user: { token: 'bounded-token' } }],
contexts: [
{
name: 'production',
context: {
cluster: 'production',
user: 'operator',
namespace: 'qinglong3',
},
},
],
'current-context': 'production',
}),
);
const kubernetesFile = privateFile(
directory,
'kubernetes-client.json',
JSON.stringify({
schemaVersion: 1,
kubeconfigFile,
context: 'production',
namespace: 'qinglong3',
apiTimeoutMs: 1_000,
}),
);
const commands = {
package: { configFile: packageConfig },
'package-kubernetes': { configFile: packageConfig, kubernetesFile },
'worker-credential': {
configFile: config(
'worker-client',
'/api/v3/worker-credentials/management',
'required',
),
},
approval: {
configFile: config(
'approval-client',
'/api/v3/approvals/management',
'required',
),
},
run: {
configFile: config('run-client', '/api/v3/runs/management', 'required'),
},
automation: {
configFile: config(
'automation-client',
'/api/v3/automations/management',
'required',
),
},
'model-credential': {
configFile: config(
'provider-client',
'/api/v3/provider-credentials/management',
'required',
),
},
};
return {
directory,
contextFile: privateFile(
directory,
'operator-context.json',
JSON.stringify({ schemaVersion: 1, commands }),
),
commands,
};
}
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);
@@ -319,6 +463,126 @@ test('operator context rejects weak files, unknown or secret fields and argument
); );
}); });
test('validates the complete operator context offline without operational authority', (t) => {
const fixture = validContextFixture(t);
const resolution = resolveQingLong3ClusterProductCommand(
['context', 'validate', `--context=${fixture.contextFile}`],
moduleDirectory,
);
assert.deepEqual(resolution, {
kind: 'context-validation',
contextFile: fixture.contextFile,
});
const validated = runCli([
'context',
'validate',
`--context=${fixture.contextFile}`,
]);
assert.equal(validated.status, 0);
assert.equal(validated.stderr, '');
const fact = JSON.parse(validated.stdout);
assert.deepEqual(fact, {
schemaVersion: 1,
component: 'qinglong3-cluster-product-cli',
event: 'context_valid',
commandCount: 7,
commands: [
{ name: 'package', transport: 'https', clientCertificate: 'forbidden' },
{
name: 'package-kubernetes',
transport: 'kubernetes-port-forward',
clientCertificate: 'forbidden',
kubernetesAuthentication: 'token',
},
{
name: 'worker-credential',
transport: 'https',
clientCertificate: 'required',
},
{ name: 'approval', transport: 'https', clientCertificate: 'required' },
{ name: 'run', transport: 'https', clientCertificate: 'required' },
{
name: 'automation',
transport: 'https',
clientCertificate: 'required',
},
{
name: 'model-credential',
transport: 'https',
clientCertificate: 'required',
},
],
networkAccess: false,
mutation: false,
});
assert.equal(validated.stdout.includes(fixture.directory), false);
assert.equal(validated.stdout.includes('manager.example.test'), false);
assert.equal(validated.stdout.includes('bounded-token'), false);
});
test('context validation fails closed for invalid client configuration and syntax', (t) => {
const fixture = validContextFixture(t);
fs.writeFileSync(fixture.commands.run.configFile, '{}', { mode: 0o600 });
const invalid = runCli([
'context',
'validate',
`--context=${fixture.contextFile}`,
]);
assert.equal(invalid.status, 78);
assert.equal(invalid.stdout, '');
assert.deepEqual(JSON.parse(invalid.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(invalid.stderr.includes(fixture.directory), false);
for (const argv of [
['context'],
['context', 'validate'],
['context', 'validate', '--context'],
['context', 'validate', '--context='],
['context', 'inspect', `--context=${fixture.contextFile}`],
]) {
const rejected = resolveQingLong3ClusterProductCommand(
argv,
moduleDirectory,
);
assert.equal(rejected.kind, 'invalid');
assert.equal(rejected.code, 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID');
}
});
test('configuration preflight fixes every management route to its reviewed authentication class', (t) => {
const fixture = validContextFixture(t);
assert.throws(
() =>
validateClusterAuthenticatedManagementClientConfiguration(
fixture.commands.run.configFile,
'package',
),
/configuration is invalid/,
);
assert.throws(
() =>
validateClusterAuthenticatedManagementClientConfiguration(
fixture.commands.package.configFile,
'run',
),
/configuration is invalid/,
);
assert.throws(
() =>
validateClusterAuthenticatedManagementClientConfiguration(
fixture.commands.package.configFile,
'unknown',
),
/configuration 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 }));
@@ -80,11 +80,13 @@ function runOperatorContextContract(image) {
const source = String.raw` const source = String.raw`
const { spawnSync } = require('node:child_process'); const { spawnSync } = require('node:child_process');
const { writeFileSync } = require('node:fs'); const { writeFileSync } = require('node:fs');
const { rootCertificates } = require('node:tls');
const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js'; const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js';
const config = '/tmp/run-client.json'; const config = '/tmp/run-client.json';
const command = '/tmp/command.json'; const command = '/tmp/command.json';
const assertion = '/tmp/assertion.jwt'; const assertion = '/tmp/assertion.jwt';
const context = '/tmp/operator-context.json'; const context = '/tmp/operator-context.json';
const ca = '/tmp/management-ca.pem';
for (const [file, contents] of [ for (const [file, contents] of [
[config, '{}'], [config, '{}'],
[command, '{}'], [command, '{}'],
@@ -95,12 +97,19 @@ const injected = spawnSync(process.execPath, [facade, 'run', '--context=' + cont
let injectedFailure; let injectedFailure;
try { injectedFailure = JSON.parse(injected.stderr); } catch { process.exit(21); } 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); if (injected.status !== 1 || injectedFailure.code !== 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_CONFIG_INVALID' || injected.stdout !== '') process.exit(22);
writeFileSync(ca, rootCertificates[0], { mode: 0o600 });
writeFileSync(config, JSON.stringify({ schemaVersion: 1, endpoint: 'https://manager.example.test:8443/api/v3/plugin-packages/management', servername: 'manager.example.test', caFile: ca, requestTimeoutMs: 1000 }), { mode: 0o600 });
writeFileSync(context, JSON.stringify({ schemaVersion: 1, commands: { package: { configFile: config } } }), { mode: 0o600 });
const validated = spawnSync(process.execPath, [facade, 'context', 'validate', '--context=' + context], { encoding: 'utf8' });
let validationFact;
try { validationFact = JSON.parse(validated.stdout); } catch { process.exit(25); }
if (validated.status !== 0 || validated.stderr !== '' || validationFact.event !== 'context_valid' || validationFact.commandCount !== 1 || validationFact.networkAccess !== false || validationFact.mutation !== false || JSON.stringify(validationFact.commands) !== JSON.stringify([{ name: 'package', transport: 'https', clientCertificate: 'forbidden' }]) || validated.stdout.includes('/tmp/') || validated.stdout.includes('manager.example.test')) process.exit(26);
writeFileSync(context, JSON.stringify({ schemaVersion: 1, commands: { run: { configFile: config, assertionFile: assertion } } }), { mode: 0o600 }); 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' }); const rejected = spawnSync(process.execPath, [facade, 'run', '--context=' + context, '--command=' + command, '--assertion=' + assertion], { encoding: 'utf8' });
let rejectedFailure; let rejectedFailure;
try { rejectedFailure = JSON.parse(rejected.stderr); } catch { process.exit(23); } 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); 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 })); process.stdout.write(JSON.stringify({ schemaVersion: 1, injected: true, contextPreflight: true, secretFieldsRejected: true }));
`; `;
const output = docker([ const output = docker([
'run', 'run',
@@ -137,6 +146,7 @@ process.stdout.write(JSON.stringify({ schemaVersion: 1, injected: true, secretFi
if ( if (
result?.schemaVersion !== 1 || result?.schemaVersion !== 1 ||
result?.injected !== true || result?.injected !== true ||
result?.contextPreflight !== true ||
result?.secretFieldsRejected !== true result?.secretFieldsRejected !== true
) { ) {
fail('operator context contract drifted'); fail('operator context contract drifted');
@@ -192,6 +202,7 @@ function main() {
imageBytes: fact.Size, imageBytes: fact.Size,
commandCount: COMMANDS.length, commandCount: COMMANDS.length,
operatorContext: true, operatorContext: true,
contextPreflight: true,
isolation: Object.freeze({ isolation: Object.freeze({
readOnlyRoot: true, readOnlyRoot: true,
network: 'none', network: 'none',
+3 -2
View File
@@ -289,8 +289,8 @@ function auditClusterImageCiWorkflow(
); );
requirePattern( requirePattern(
adminProductLiveContract, adminProductLiveContract,
/runOperatorContextContract\(image\);[\s\S]*operatorContext: true/, /runOperatorContextContract\(image\);[\s\S]*operatorContext: true,[\s\S]*contextPreflight: true/,
'native admin image contract must verify owner-private operator context injection', 'native admin image contract must verify owner-private operator context injection and offline preflight',
); );
requirePattern( requirePattern(
source, source,
@@ -334,6 +334,7 @@ function auditClusterImageCiWorkflow(
runtimeInventory: true, runtimeInventory: true,
clusterAdminProductFacade: true, clusterAdminProductFacade: true,
clusterAdminOperatorContext: true, clusterAdminOperatorContext: true,
clusterAdminContextPreflight: true,
ociAttestations: true, ociAttestations: true,
osVulnerabilityScan: { osVulnerabilityScan: {
scanner: 'trivy@0.70.0', scanner: 'trivy@0.70.0',
@@ -28,6 +28,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
runtimeInventory: true, runtimeInventory: true,
clusterAdminProductFacade: true, clusterAdminProductFacade: true,
clusterAdminOperatorContext: true, clusterAdminOperatorContext: true,
clusterAdminContextPreflight: true,
ociAttestations: true, ociAttestations: true,
osVulnerabilityScan: { osVulnerabilityScan: {
scanner: 'trivy@0.70.0', scanner: 'trivy@0.70.0',
@@ -126,6 +127,24 @@ test('rejects a Cluster Admin live gate that omits operator context injection',
); );
}); });
test('rejects a Cluster Admin live gate that omits offline context preflight', () => {
const contract = fs.readFileSync(
path.join(root, 'scripts/ql3-cluster-admin-product-live-contract.cjs'),
'utf8',
);
assert.throws(
() =>
auditClusterImageCiWorkflow(
ciSource,
contract.replace(
'operatorContext: true,\n contextPreflight: true',
'operatorContext: true,\n contextPreflight: false',
),
),
/offline preflight/,
);
});
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',