mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add offline cluster context preflight
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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、数据库或 workload,Local/Edge、Control、Worker 零导入。
|
||||||
|
product/preflight 11/11、七类 client/tunnel 定向 45/45;Cluster Admin 完整 296 pass/2 条件 skip,18-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-0390:Offline Cluster Operator Context Preflight
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-13
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-302
|
||||||
|
- 前置决策:ADR-0250、ADR-0388、ADR-0389
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
ADR-0389 让操作者可以显式复用稳定 client 路径,但 context reader 只证明路径、owner、权限和 schema;endpoint 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 fail;18-package clean build/test 退出 0;backend 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 package;Cluster 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,门禁容器、网络与卷均零残留。
|
||||||
@@ -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 |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
+209
-67
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+107
-27
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user