feat(ql3): harden copilot mcp host deployment

This commit is contained in:
whyour
2026-08-16 03:41:38 +08:00
parent 58025ede55
commit da4e7cf688
25 changed files with 734 additions and 48 deletions
@@ -80,7 +80,7 @@ FROM node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd3
ARG SOURCE_REVISION=uncommitted
LABEL org.opencontainers.image.title="QingLong 3.0 Cluster Admin" \
org.opencontainers.image.description="QingLong 3.0 short-lived cluster administration jobs" \
org.opencontainers.image.description="QingLong 3.0 cluster operations and bounded stdio MCP" \
org.opencontainers.image.source="https://github.com/whyour/qinglong" \
org.opencontainers.image.revision="${SOURCE_REVISION}" \
org.opencontainers.image.licenses="Apache-2.0"
@@ -2,7 +2,7 @@
"name": "@qinglong/cluster-admin-image-dependencies",
"version": "3.0.0-alpha.0",
"private": true,
"description": "Locked external dependencies for the QingLong 3.0 short-lived cluster admin image",
"description": "Locked external dependencies for QingLong 3.0 cluster operations and bounded stdio MCP",
"license": "Apache-2.0",
"engines": {
"node": ">=24.18.0 <25"
@@ -2,7 +2,7 @@
"name": "@qinglong/cluster-admin-image-dependencies",
"version": "3.0.0-alpha.0",
"private": true,
"description": "Production-only external dependency root for the QingLong 3.0 short-lived cluster admin image",
"description": "Production-only external dependency root for QingLong 3.0 cluster operations and bounded stdio MCP",
"license": "Apache-2.0",
"engines": {
"node": ">=24.18.0 <25"
+19 -9
View File
@@ -34,16 +34,18 @@ docker build \
```
The admin image has its own builder and production locks. Its five production
roots resolve to 84 external packages and it adds only `runtime-core`,
`cluster-postgres` and `cluster-admin`. Kubernetes client code and the admin
database role therefore exist only in an ephemeral Job image, never in
`cluster-control`.
roots resolve to 87 external packages and it adds only `runtime-core`, `ai`,
`cluster-postgres` and `cluster-admin`. Kubernetes client code and admin
database roles remain outside `cluster-control`; explicit operation commands
stay short-lived, while the Copilot MCP subpath loads only its remote API
client and stdio transport.
Its default entrypoint is the bounded `ql3-cluster-admin` product facade. The
facade exposes only seven operator-facing remote clients: `package`,
facade exposes eight operator-facing remote clients: `copilot`, `package`,
`package-kubernetes`, `worker-credential`, `approval`, `run`, `automation` and
`model-credential`. It delegates with the current Node executable, an exact
same-image target and `shell=false`; arguments remain opaque. Server
`model-credential`, plus the bounded `copilot-mcp` stdio process. It delegates
with the current Node executable, an exact same-image target and `shell=false`;
arguments and MCP stdio remain opaque. Server
`*-manage` processes, migration, recovery, executors, Prompt output key custody
and garbage collection remain separate explicit binaries and Kubernetes
operations. Existing Jobs continue to name their exact binary and do not
@@ -69,6 +71,14 @@ contents are deliberately forbidden. There is no home-directory, environment
or ambient Kubernetes context discovery. Existing explicit `--config` calls
remain supported when `--context` is absent.
Cluster Copilot MCP is deployed by its external MCP host, not as a Kubernetes
Deployment or Service. The reviewed digest-pinned Docker launcher, compact /
standard / dense resource ceilings, private projection examples and one-shot
readiness check live in
[`deploy/mcp/ql3-cluster-copilot`](../../mcp/ql3-cluster-copilot). This host
adapter is never composed into Edge/Standalone, Cluster Control, Cluster AI or
the shared operations Kustomization.
Validate the complete local operator context before a maintenance window:
```sh
@@ -78,7 +88,7 @@ ql3-cluster-admin context validate \
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
Kubernetes config. It also verifies that all nine 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,
@@ -99,7 +109,7 @@ pnpm sbom:cluster-image:ql3 --image=admin
pnpm audit:image-release:ql3
```
The admin image resolves 84 external plus 3 internal components. Its production
The admin image resolves 87 external plus 4 internal components. Its production
closure legitimately contains the upstream runtime dependency
`@types/js-yaml` from `@kubernetes/client-node`; the exact production lock, not
the package-name prefix, is authoritative. TypeScript, root devDependencies and
@@ -1,6 +1,9 @@
{
"schemaVersion": 1,
"commands": {
"copilot": {
"configFile": "/secure/qinglong3/copilot-client.json"
},
"package": {
"configFile": "/secure/qinglong3/plugin-package-client.json"
},
+67
View File
@@ -0,0 +1,67 @@
# Cluster Copilot MCP stdio container
This is an explicit host-side deployment for the bounded Cluster Copilot MCP.
It is not a Kubernetes Deployment or Service: stdio must be owned by the MCP
host that launches the process. A resident Pod without that parent session
would be unreachable while still retaining a credential and image attack
surface.
The launcher uses the existing independently released Cluster Admin image and
its default `ql3-cluster-admin` entrypoint. It selects the reviewed
`copilot-mcp` subcommand, never overrides the entrypoint and never mounts a
Kubernetes token, database credential, Docker socket or writable directory.
## Prepare the private projection
Create an absolute canonical directory owned by UID/GID `10001:10001`, mode
`0700`. Copy `mcp-config.example.json` to `mcp.json` and
`client-config.example.json` to `client.json`; install the reviewed API CA as
`ca.pem` and the separately issued `ql3c_` Project API credential as
`credential`. All four files must be regular, non-symlink, UID 10001-owned,
canonical files with mode `0600`. Never put the credential value in the MCP
host config, argv, environment or image.
Replace the endpoint, DNS server name and CA. The client uses TLS 1.3, does not
use a client certificate, proxy, redirect or ambient CA, and rereads
`credential` for every Tool call.
## Select resources and egress
Create a dedicated Docker network whose host firewall permits only DNS and the
reviewed Cluster API destination. The launcher rejects `bridge`, `default`,
`host`, `none` and an implicit network, but Docker network naming alone is not
an egress allowlist.
| Resource class | Memory | CPU | PIDs | Maximum configured concurrency |
| --- | ---: | ---: | ---: | ---: |
| `compact` | 192 MiB | 0.25 | 32 | 1 |
| `standard` | 512 MiB | 1 | 64 | 4 |
| `dense` | 1 GiB | 2 | 96 | 16 |
The launcher passes a second concurrency ceiling to the process. Startup and
preflight fail closed if `mcp.json` requests more concurrency than its resource
class. There is no queue, retry, poller, watcher or resident health timer.
Export only the immutable image digest, private directory path, dedicated
network name and resource class, then validate before registering the host:
```sh
deploy/mcp/ql3-cluster-copilot/docker-stdio.sh check
```
The check validates all mounted path/credential authority and makes one
unauthenticated `GET /readyz`. Its JSON contains no endpoint, path, credential
or cluster identity. A not-ready response exits 69; invalid local authority or
transport failure emits only a low-sensitive failure fact.
Copy `mcp-host.example.json` into the external MCP host's private
configuration, replace its launcher path, image digest, private root and
network, and map its `command`/`args`/`env` fields to the host's equivalent
stdio process adapter. `serve` runs Docker attached to stdin/stdout with a
read-only root filesystem, no capabilities, no-new-privileges, a fixed
non-root UID, bounded memory/CPU/PIDs and `--pull never`.
Do not compose this directory into Edge/Standalone, `cluster-control`, the
Cluster AI Pod or any shared Kubernetes operations Kustomization. Small router
profiles continue to use the separately bounded Local MCP artifact only when
explicitly selected; otherwise they carry no MCP dependency at all.
@@ -0,0 +1,7 @@
{
"schema": "qinglong/cluster-copilot-client-config@v1",
"endpoint": "https://replace-cluster-api.example.com:5800/",
"servername": "replace-cluster-api.example.com",
"caFile": "/var/run/secrets/qinglong3/copilot-mcp/ca.pem",
"requestTimeoutMs": 30000
}
+83
View File
@@ -0,0 +1,83 @@
#!/bin/sh
set -eu
usage() {
printf '%s\n' 'Usage: docker-stdio.sh check|serve' >&2
exit 64
}
fail() {
printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-copilot-mcp-launcher","event":"launch_failed"}' >&2
exit 78
}
[ "$#" -eq 1 ] || usage
mode=$1
case "$mode" in
check|serve) ;;
*) usage ;;
esac
image=${QL3_COPILOT_MCP_IMAGE-}
private_root=${QL3_COPILOT_MCP_PRIVATE_ROOT-}
network=${QL3_COPILOT_MCP_NETWORK-}
resource_class=${QL3_COPILOT_MCP_RESOURCE_CLASS-compact}
printf '%s' "$image" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/-]{0,191}@sha256:[0-9a-f]{64}$' || fail
printf '%s' "$network" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$' || fail
case "$network" in
bridge|default|host|none) fail ;;
esac
case "$private_root" in
/*) ;;
*) fail ;;
esac
case "$private_root" in
*','*|*':'*) fail ;;
esac
[ -d "$private_root" ] || fail
canonical_root=$(CDPATH= cd -- "$private_root" 2>/dev/null && pwd -P) || fail
[ "$canonical_root" = "$private_root" ] || fail
case "$resource_class" in
compact)
memory=192m
cpus=0.25
pids=32
concurrency_ceiling=1
;;
standard)
memory=512m
cpus=1
pids=64
concurrency_ceiling=4
;;
dense)
memory=1g
cpus=2
pids=96
concurrency_ceiling=16
;;
*) fail ;;
esac
set -- docker run --rm -i --pull never --init --read-only \
--network "$network" \
--cap-drop ALL \
--security-opt no-new-privileges \
--user 10001:10001 \
--pids-limit "$pids" \
--memory "$memory" \
--cpus "$cpus" \
--mount "type=bind,src=$private_root,dst=/var/run/secrets/qinglong3/copilot-mcp,readonly" \
"$image" \
copilot-mcp
if [ "$mode" = check ]; then
exec "$@" --check --config /var/run/secrets/qinglong3/copilot-mcp/mcp.json \
"--concurrency-ceiling=$concurrency_ceiling"
fi
exec "$@" --config /var/run/secrets/qinglong3/copilot-mcp/mcp.json \
"--concurrency-ceiling=$concurrency_ceiling"
@@ -0,0 +1,6 @@
{
"schema": "qinglong/cluster-copilot-mcp-server@v1",
"clientConfigFile": "/var/run/secrets/qinglong3/copilot-mcp/client.json",
"credentialFile": "/var/run/secrets/qinglong3/copilot-mcp/credential",
"maxConcurrentRequests": 1
}
@@ -0,0 +1,14 @@
{
"mcpServers": {
"qinglong-cluster-copilot": {
"command": "/absolute/path/to/docker-stdio.sh",
"args": ["serve"],
"env": {
"QL3_COPILOT_MCP_IMAGE": "ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"QL3_COPILOT_MCP_PRIVATE_ROOT": "/absolute/private/qinglong3-cluster-copilot",
"QL3_COPILOT_MCP_NETWORK": "qinglong3-copilot-egress",
"QL3_COPILOT_MCP_RESOURCE_CLASS": "compact"
}
}
}
}
+1
View File
@@ -11,6 +11,7 @@
最新增量证据(2026-08-16):
- D-326/ADR-0418(已接受):Cluster Copilot MCP 已补齐明确的外部 host 部署与资源边界。MCP 仍是 stdio 子进程,必须由支持 MCP 的外部 host 按 session 启动并持有 stdin/stdout;它不部署为 Kubernetes Deployment/Service,否则会形成没有消费者却长期持有 Project credential 的孤儿进程。新增 `deploy/mcp/ql3-cluster-copilot/` 提供 digest-pinned host 配置、owner-private client/MCP 配置示例和固定 Docker launcherlauncher 只允许显式命名网络与 `compact|standard|dense` 三档资源,分别限制为 192 MiB/0.25 CPU/32 PIDs/并发 1、512 MiB/1 CPU/64 PIDs/并发 4、1 GiB/2 CPU/96 PIDs/并发 16,并强制 `--pull never --init --read-only --cap-drop ALL --security-opt no-new-privileges --user 10001:10001`,只读挂载一个私有 authority root,禁止 Docker socket、Kubernetes token、数据库 credential、host/default/bridge/none 网络和可写工作目录。统一产品入口新增第九个静态命令 `ql3-cluster-admin copilot-mcp``ql3-copilot-mcp --check` 会先复验私有 config/credential/CA,再用无认证、固定 `GET /readyz` 做低敏预检,并在启动前拒绝配置并发超过 host resource class ceilingserve 路径仍保持无 listener、无 queue/retry/poller/cache。部署审计同时禁止任何 Kubernetes YAML 常驻该 MCP,并修正了一个真实发布缺陷:OCI layout 旧 fixture 仍声称 Admin 镜像入口是 recovery CLI,现已与真实 `product-cli/cli.js` entrypoint 对齐。workspace 仍为 18 package、无 single-source/shallow packageCluster Admin 保持 116 个源码、115 个位于嵌套职责目录,Admin SBOM 保持 91 components/87 external/4 internalControl 和全部 Local 闭包不变。专项发布审计 145/145、Cluster Admin 362 pass/3 条件 skip、18-package clean build/test 退出 0、backend 1,210 pass/2 条件 skip/0 failpackage/dependency/Edge import/Cluster deployment 审计零 finding。真实 arm64 Admin image `qinglong3-cluster-admin:d326-local` 为 344,423,357 bytes,在 `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下验证 9 个产品命令与新 entrypoint。14 档 Local artifact 全部逐档复验且与 D-325 完全一致:默认 Edge/Standalone 为 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytes,证明 Cluster MCP host 部署没有进入低配路由设备。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应冻结 Cluster UI ownership/read-only Copilot surface,或使用公开 release digest 补真实外部 host session 证据;均不得把 stdio MCP 改成常驻服务或扩大其 credential/网络 authority。
- D-325/ADR-0417(已接受):Cluster Copilot 现已提供独立、受限、可部署的 MCP stdio 产品面。实现没有扩展旧 2.x Web UI,也没有把 Cluster authority 塞入 Edge/Standalone 的 `@qinglong/local-mcp-server`;而是在既有 `@qinglong/cluster-admin` 的内聚 `copilot-mcp/` 目录新增 `ql3-copilot-mcp``./copilot-mcp` exportworkspace 仍保持 18 package。四个静态 Tool 只接收 Project、source Run、diagnosis request、trace/mutation identity,并直接调用 D-324 的共享 TypeScript client;不启动 CLI 子进程、不写 command 临时文件、不监听网络、不直连数据库/application capability,也不允许调用者提供 URL、header、credential、Model/Provider、Artifact、usage/cost 或 Policy fence。owner-private 0600 配置只保存 client config/credential 路径和显式 `1..16` 并发上限;credential 每次 Tool call 都重新执行 canonical/private/TOCTOU 与 token 校验,rotation 下一次调用立即生效。满载即时返回 `copilot_mcp_busy`,没有隐藏 queue、retry、poller、timer、watcher 或 cache。所有结果使用 exact `qinglong/cluster-copilot-mcp-result@v1`,固定 `instructionPolicy=data_only_never_execute``actionAuthority=none`;只有 output Tool 标为 `potentially_sensitive`/`untrusted_model_output`,远端错误仅投影有界 status/code/request identity/Retry-After。真实 stdio + TLS 1.3 E2E 已覆盖 initialize、discovery、四次直接请求、Bearer credential 热轮换、无 client certificate、敏感输出标注与 graceful close;并发和未知字段均失败关闭。Cluster Admin 完整测试 361 pass/3 条件 skip18-package clean build/test 退出 0backend 1,207 pass/2 条件 skip/0 failpackage/dependency/Edge import/Cluster deployment 四项审计零 findingCluster Admin 为 116 个源码且 115 个位于嵌套职责目录。Cluster Admin 镜像精确加入已固定的 `@modelcontextprotocol/server@2.0.0`SBOM 为 91 components/87 external/4 internalCluster Control 和全部 Local 闭包不变。14 档 Local artifact 全部通过,默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes、315 files、56 modules,证明 Cluster MCP 没有进入低配路由设备;本 Gate 无 schema、migration、SQL、role、Pool、连接或部署拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应冻结 Cluster UI ownership 或增加 MCP host 的明确部署清单/运维面,仍必须复用同一 API,不得回接 2.x controller/session 或扩大 credential authority。
- D-324/ADR-0416(已接受):Cluster Copilot failure diagnosis 已获得首个可直接部署的有界产品客户端。既有 `@qinglong/cluster-admin` 在内聚的 `copilot-client/` 目录提供共享 client 与 `ql3-copilot-client`,统一 `ql3-cluster-admin copilot` 静态委托同一 binary;没有为三个实现文件新增 workspace package。客户端只接受 owner-private 0600 的绝对 `--config``--command``--credential` 文件路径,Project API credential 固定为独立 `ql3c_` Bearer authority,禁止写入 argv 值、环境、stdin、command 或 operator context,也不复用管理面的 User JWT/mTLS client certificate。`diagnose|inspect|output|cancel` 四个 operation 只调用 D-321 至 D-323 的既有 APITLS 固定 1.3、显式 CA/DNS、无连接复用/压缩/redirect/proxy/ambient CAdiagnose request identity、cancel mutation identity 和只读 transport identity 必须与唯一响应 `x-request-id` exact matching。成功响应重新执行 schema、target、状态机、digest、usage/cost 与 UTF-8 byte exact validation;只有调用者显式选择 `output` 才向 stdout 返回诊断文本,远端失败只投影 status、稳定 code、request identity 与有界 Retry-After。operator context 只能保存 Copilot config 路径,并新增离线 validate 与无认证固定 `GET /readyz` probe,不能保存 credential/command 或获得调用 authority。workspace 保持 18 package、`singleSourcePackages=[]``shallowSourcePackages=[]`Cluster Admin 从 109 增至 112 个源码,其中 111 个在嵌套职责目录,未新增生产依赖、schema、migration、SQL、role、Pool、连接、进程、timer、watcher、queue、cache、Pod、Service 或 Kubernetes 权限。Copilot/产品 CLI 定向 19/19、Cluster Admin 354 pass/3 条件 skip、18-package clean build/test 退出 0、backend 1,207 pass/2 条件 skip/0 failpackage/dependency/Edge import/Cluster deployment 四项审计零 finding14 档 Local artifact 全部通过。默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes、315 files、56 modules,证明 Cluster-only client 没有进入低配路由设备闭包。本 Gate 没有数据库或部署拓扑变更,因此不重跑物理 HA,继续引用 D-323 的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 与 SHA-256 `5dbcffb74a3181aabee66a8f68ecfa7a65e0491a6f2ba24e2bc903c83da9d766` 基线。下一独立 Gate 可让 UI/MCP 复用同一公开 API/contract,不能执行 CLI 子进程、直连 application capability/数据库或扩大 credential authority。
- D-323/ADR-0415(已接受):Cluster Copilot failure diagnosis 已补齐受围栏的 request-keyed 取消 mutation。`POST /api/v3/projects/{projectId}/runs/{runId}/copilot/failure-diagnoses/{requestId}/cancellation` 复用 exact `qinglong/run-cancellation@v1` body、`run.stop` 当前 Policy、durable audit 与通用 PostgreSQL Run cancellation transaction;调用方只能提供 `mutationId`diagnosis Run、Event、reason、Provider 与终态均由服务端从 admission plan/receipt 和 durable authority 派生。pre-Model 取消立即与 Model start 通过 Run/Step/version fence 竞争并原子 terminalizeModel start 获胜时只返回 `model_in_flight` durable intent,不伪造 Provider abort、终态、usage 或 cost,真实 completion/finalization 仍可成为最终 winner。相同或不同 mutation 都不能追加第二个 intent/Event 或覆盖首次 reason/time。能力只注入默认关闭的 Cluster AI profile,复用既有 AI Pool、repository、Policy pipeline 与进程,不新增 package、schema、Pool、连接、timer、watcher、queue、cache、端口、Pod、Service 或 Kubernetes 权限,Edge/Standalone 与普通 Cluster Control 均无该 route。目录审计曾拒绝 `failure-diagnosis` 达到 12 个直属源码文件;最终删除仅做 re-export 的单文件 façade,让公开 package subpath 直接指向 `cancellation/service`,而不是再拆 package 或放宽阈值。workspace 保持 18 package`singleSourcePackages=[]``shallowSourcePackages=[]`AI 194 个源码中 193 个、Cluster Control 64 个源码中 62 个位于嵌套职责目录。AI 254 pass/3 条件 skip、Cluster Control 261 pass/2 条件 skip、18-package clean build/test 与 backend 1,207 pass/2 条件 skip/0 fail;四项架构审计和 14 档 Local artifact 全部通过。默认 Edge/Standalone 为 2,589,890/2,589,968 bytesEdge/Standalone AI application 为 4,493,043/4,493,175 bytes,证明 Cluster-only mutation 未进入低配闭包。PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2`,覆盖 intent/Event/terminal receipt 同步复制、promotion 后 exact replay 与 in-flight 不伪造终态;报告 SHA-256 为 `5dbcffb74a3181aabee66a8f68ecfa7a65e0491a6f2ba24e2bc903c83da9d766`,离线审计零 finding。下一独立 Gate 可选择 CLI/UI/MCP 客户端或带 Provider abort acknowledgement 的取消语义,不得在没有 durable acknowledgement 时宣称外部调用已停止。
@@ -0,0 +1,41 @@
# ADR-0418:显式 Cluster Copilot MCP Host 部署与资源边界
- 状态:Accepted
- 日期:2026-08-16
- 关联 RFCQL-RFC-0001 D-326、Phase 2
## 背景
D-325 提供了独立 `ql3-copilot-mcp` stdio 进程,但只存在 binary 并不能证明用户可以安全部署它。若把 stdio server 直接包装成 Kubernetes Deployment/ServicePod 没有拥有其 stdin/stdout 的 MCP host session,既不可达又长期携带 API credential;若让用户随意拼 `docker run`,则容易使用 mutable tag、默认网络、root、可写文件系统、无限资源或把 credential 写入环境。Cluster Admin 镜像的默认入口已经是统一产品 façade,但 OCI layout 审计仍错误期待旧 recovery binary,模拟证据不能约束真实镜像。
QingLong 的部署跨度还要求明确区分:Edge/Standalone 小设备不应安装 Cluster Admin/MCP 依赖;Cluster MCP host 本身也要支持资源受限的运维终端与较高并发的专用节点,而不能靠隐藏队列或无限容器配额吸收差异。
## 决策
1. `ql3-cluster-admin` 产品 façade 增加静态 `copilot-mcp` 命令,仍用当前 Node executable、same-image canonical target、`shell=false` 和 inherited stdio;不通过 shell、动态 package resolution 或 entrypoint override 启动 MCP。
2. `ql3-copilot-mcp --check --config ...` 在不启动 MCP transport 的情况下完整验证 owner-private MCP/client/credential/CA authority,并只发一个不带认证的 `GET /readyz`。成功结果仅包含 transport、ready、配置/credential 有效性、并发上限和固定请求语义;不返回 endpoint、DNS、路径、credential、Project 或 Cluster identity。not-ready 退出 69,异常仍只输出低敏失败事实。
3. CLI 接受可选 `--concurrency-ceiling=1..16`。配置的 `maxConcurrentRequests` 高于外部部署 ceiling 时在 listener、stdio server 和网络 probe 前失败关闭。该 ceiling 不改变 D-325 的即时 busy/no-queue 语义,只防止私有配置越过容器资源档位。
4. 提供唯一受审 host-side Docker stdio launcher。它只接受 `check|serve` 和四个非 secret 环境值:immutable Admin image digest、canonical private projection root、专用 Docker network 名和 `compact|standard|dense` resource class。credential value 不得进入 argv、environment、host adapter 或 image。
5. 三档固定为:compact `192 MiB/0.25 CPU/32 PID/concurrency 1`standard `512 MiB/1 CPU/64 PID/concurrency 4`dense `1 GiB/2 CPU/96 PID/concurrency 16`。launcher 固定 `--pull never --init --read-only --cap-drop ALL --security-opt no-new-privileges --user 10001:10001`,只读挂载一个私有目录,不挂载 Docker socket、Kubernetes token、数据库 credential 或可写目录。
6. launcher 拒绝 mutable/tag-only image、非 canonical/含分隔歧义的 private root、隐式或 `bridge|default|host|none` 网络和未知资源档。专用 Docker network 只提供命名隔离;生产 host firewall 仍必须把 egress 限制到 DNS 与 exact Cluster API destination。
7. stdio MCP 由外部 MCP host 父进程启动,不新增 Kubernetes Deployment、Service、Ingress、RBAC、ServiceAccount、sidecar、health timer 或 restart controller。Cluster AI component 继续只拥有 server-side Copilot composition;二者不能合并。
8. Admin image 继续独立发布;本 Gate 不再拆第 19 个 package 或复制 MCP runtime。镜像 metadata 改为同时描述 cluster operations 与 bounded stdio MCPOCI layout 审计必须期待 Dockerfile 的真实 product façade entrypoint,而非历史 recovery binary。
9. Edge/Standalone、Local MCP、Cluster Control、Cluster AI 和 shared Kubernetes operations 均不得引用 host launcher。路由设备默认制品与依赖闭包保持不变;需要本机 MCP 时仍只使用独立 `@qinglong/local-mcp-server` Profile。
## 不选择
- **把 stdio MCP 作为独立 Kubernetes Deployment/Service**:没有父 host session,网络 Service 也不能把 MCP stdio 变成 HTTP;会留下不可达的长期 credential Pod。
- **在 Cluster Control/AI Pod 增加 MCP sidecar**:混淆 server-side Tool/Model authority 与 operator API credential,扩大常驻资源和故障域。
- **允许任意 Docker flags 或 mutable tag**:无法证明镜像、身份、挂载、网络和资源边界。
- **为 launcher 新建 workspace package**:部署 adapter 没有新的领域模型或 consumer closure,会重新制造过细包并突破 18-package hard cap。
- **把 resource class 只写进文档**:私有 config 可静默扩大并发,容器内存上限无法成为可执行契约。
- **在 MCP 进程增加轮询 health timer**:引入常驻网络负载和隐藏生命周期;一次性 check 与 host-owned restart 足够。
## 验收
1. 产品 catalog/help/delegation 覆盖八个 remote client 与 `copilot-mcp`,并证明 target 位于同一安装、stdio 原样继承、signal 可收敛。
2. TLS 1.3 preflight 覆盖 config/credential validation、无 Authorization 的 exact `/readyz`、ready/not-ready/transport failure、并发 ceiling 和低敏输出。
3. launcher 测试以假 Docker 捕获完整 argv,证明三档资源、immutable digest、named network、non-root/read-only/no-capability/no-new-privileges、只读 projection 和无 secret env;负例覆盖 tag、ambient network 与路径漂移。
4. Cluster deployment audit 必须拒绝 launcher contract 漂移以及任何 Kubernetes YAML 中的 `ql3-copilot-mcp` 常驻资源;package/Edge import/Local artifact 门不得放宽。
5. OCI layout fixture 与真实 Dockerfile 必须共同绑定 product façade entrypoint 和新 metadataAdmin image live gate 至少验证 `copilot``copilot-mcp` help 与全部既有命令。
6. Cluster Admin、18-package clean build/test、backend、SBOM/OCI/部署审计和 14 档 Local artifact 全部通过后才允许 D-326 阶段提交。本 Gate 无 schema、migration、SQL、role、Pool 或 HA 拓扑变化,不重复数据库物理 HA。
+1
View File
@@ -421,6 +421,7 @@
| [ADR-0415](./ADR-0415-fenced-copilot-failure-diagnosis-cancellation.md) | 受围栏的 Copilot 故障诊断取消入口 | Accepted |
| [ADR-0416](./ADR-0416-bounded-cluster-copilot-product-client.md) | 有界 Cluster Copilot 产品客户端 | Accepted |
| [ADR-0417](./ADR-0417-bounded-cluster-copilot-mcp-stdio-surface.md) | 有界 Cluster Copilot MCP stdio 产品面 | Accepted |
| [ADR-0418](./ADR-0418-explicit-cluster-copilot-mcp-host-deployment.md) | 显式 Cluster Copilot MCP Host 部署与资源边界 | Accepted |
## 规则
+5 -3
View File
@@ -1,8 +1,10 @@
# `@qinglong/cluster-admin`
This private QingLong 3.0 package is a short-lived cluster administration
authority. It is intentionally separate from the resident `cluster-control`
artifact and requires a distinct PostgreSQL role.
This private QingLong 3.0 package owns explicit cluster operations and the
bounded Cluster Copilot MCP product surface. Database/Kubernetes administration
remains short-lived and requires distinct purpose-bound authority; the MCP
subpath has only the remote API client, opens no database or Kubernetes
authority, and is intentionally separate from resident `cluster-control`.
The admin role can append Identity/API Credential mutations and their security
audit in one serializable transaction, and can perform bounded read-only audit
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@qinglong/cluster-admin",
"version": "3.0.0-alpha.0",
"private": true,
"description": "QingLong 3.0 short-lived cluster administration authority",
"description": "QingLong 3.0 cluster operations and bounded Copilot MCP surface",
"license": "Apache-2.0",
"engines": {
"node": ">=24.18.0 <25"
@@ -2,14 +2,46 @@
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { probeClusterCopilotClientReadiness } from '../copilot-client/client';
import { readClusterCopilotMcpServerConfig } from './config';
import { createQingLongClusterCopilotMcpServer } from './server';
const USAGE = 'Usage: ql3-copilot-mcp --config /absolute/private-config.json';
const USAGE = [
'Usage: ql3-copilot-mcp --config /absolute/private-config.json [--concurrency-ceiling=1..16]',
' ql3-copilot-mcp --check --config /absolute/private-config.json [--concurrency-ceiling=1..16]',
].join('\n');
function configArgument(argv: readonly string[]): string | null {
if (argv.length !== 2 || argv[0] !== '--config' || !argv[1]) return null;
return argv[1];
interface ClusterCopilotMcpCliArguments {
readonly check: boolean;
readonly configFile: string;
readonly concurrencyCeiling: number;
}
function configArgument(
argv: readonly string[],
): Readonly<ClusterCopilotMcpCliArguments> | null {
const check = argv[0] === '--check';
const offset = check ? 1 : 0;
if (
(argv.length !== offset + 2 && argv.length !== offset + 3) ||
argv[offset] !== '--config' ||
!argv[offset + 1]
) {
return null;
}
let concurrencyCeiling = 16;
if (argv.length === offset + 3) {
const match = /^--concurrency-ceiling=([1-9]|1[0-6])$/u.exec(
argv[offset + 2] ?? '',
);
if (!match) return null;
concurrencyCeiling = Number(match[1]);
}
return Object.freeze({
check,
configFile: argv[offset + 1]!,
concurrencyCeiling,
});
}
function fact(event: 'process_failed' | 'transport_error'): string {
@@ -26,8 +58,8 @@ async function main(argv: readonly string[]): Promise<void> {
process.stdout.write(`${USAGE}\n`);
return;
}
const configFile = configArgument(argv);
if (configFile === null) {
const command = configArgument(argv);
if (command === null) {
process.stderr.write(
`${JSON.stringify({
code: 'QL3_CLUSTER_COPILOT_MCP_CLI_USAGE_INVALID',
@@ -46,7 +78,33 @@ async function main(argv: readonly string[]): Promise<void> {
};
try {
const config = readClusterCopilotMcpServerConfig(configFile);
const config = readClusterCopilotMcpServerConfig(command.configFile);
if (config.maxConcurrentRequests > command.concurrencyCeiling) {
throw new TypeError('Cluster Copilot MCP concurrency ceiling exceeded');
}
if (command.check) {
const readiness = await probeClusterCopilotClientReadiness(
config.clientConfigFile,
);
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
component: 'qinglong3-cluster-copilot-mcp',
event: 'preflight_checked',
transport: readiness.transport,
ready: readiness.ready,
configuration: 'valid',
credential: 'valid',
maxConcurrentRequests: config.maxConcurrentRequests,
concurrencyCeiling: command.concurrencyCeiling,
requestMethod: 'GET',
requestPath: '/readyz',
mutation: false,
})}\n`,
);
if (!readiness.ready) process.exitCode = 69;
return;
}
handle = serveStdio(
() => createQingLongClusterCopilotMcpServer({ config }),
{
@@ -40,6 +40,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc
target: 'copilot-client/cli.js',
description: 'diagnose, inspect, read or cancel Runs through the API',
}),
Object.freeze({
name: 'copilot-mcp',
binary: 'ql3-copilot-mcp',
target: 'copilot-mcp/cli.js',
description: 'serve the bounded Cluster Copilot MCP over stdio',
}),
Object.freeze({
name: 'package',
binary: 'ql3-plugin-package-client',
@@ -177,7 +183,7 @@ export function qingLong3ClusterProductHelp(): string {
return [
'Usage: ql3-cluster-admin <command> [arguments]',
'',
'Remote client commands:',
'Cluster product commands:',
commands,
'',
'Local operator commands:',
@@ -185,7 +191,8 @@ export function qingLong3ClusterProductHelp(): string {
' context probe --context=/absolute/operator-context.json',
'',
'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` only with remote client commands.',
'Keep the MCP config explicit; it contains stable paths to a separately rotated credential.',
'Command and short-lived assertion files always remain explicit per invocation.',
'Server, migration, recovery, executor and key-custody authorities remain isolated.',
].join('\n');
@@ -44,7 +44,7 @@ function jsonResponse(response, statusCode, requestId, body) {
response.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'content-length': String(bytes.length),
'x-request-id': requestId,
...(requestId === undefined ? {} : { 'x-request-id': requestId }),
});
response.end(bytes);
}
@@ -124,6 +124,7 @@ async function fixture(t) {
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const requests = [];
const readiness = { value: 'ready' };
const server = createServer(
{
key: fs.readFileSync(path.join(tlsFixture, 'server-key.pem')),
@@ -144,6 +145,15 @@ async function fixture(t) {
peerCertificate: request.socket.getPeerCertificate(),
body: chunks.length === 0 ? null : JSON.parse(Buffer.concat(chunks)),
});
if (request.url === '/readyz') {
jsonResponse(
response,
readiness.value === 'ready' ? 200 : 503,
undefined,
{ status: readiness.value },
);
return;
}
jsonResponse(
response,
request.method === 'POST' && !request.url.endsWith('/cancellation') ? 201 : 200,
@@ -157,12 +167,17 @@ async function fixture(t) {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(
() =>
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
);
let closed = false;
const close = () =>
new Promise((resolve, reject) => {
if (closed) {
resolve();
return;
}
closed = true;
server.close((error) => (error ? reject(error) : resolve()));
});
t.after(close);
const caFile = privateFile(
directory,
'ca.pem',
@@ -190,7 +205,7 @@ async function fixture(t) {
maxConcurrentRequests: 2,
}),
);
return { requests, credentialFile, serverConfigFile };
return { requests, readiness, close, credentialFile, serverConfigFile };
}
function startClient(t, configFile) {
@@ -271,7 +286,10 @@ function runCli(args) {
}
test('stdio CLI exposes deterministic help and low-sensitive startup failures', async () => {
const usage = 'Usage: ql3-copilot-mcp --config /absolute/private-config.json';
const usage = [
'Usage: ql3-copilot-mcp --config /absolute/private-config.json [--concurrency-ceiling=1..16]',
' ql3-copilot-mcp --check --config /absolute/private-config.json [--concurrency-ceiling=1..16]',
].join('\n');
assert.deepEqual(await runCli(['--help']), {
status: 0,
signal: null,
@@ -298,6 +316,87 @@ test('stdio CLI exposes deterministic help and low-sensitive startup failures',
assert.doesNotMatch(failed.stderr, /secret-config-name/);
});
test('preflight validates mounted authority and probes readiness without authentication', async (t) => {
const value = await fixture(t);
const checked = await runCli([
'--check',
'--config',
value.serverConfigFile,
'--concurrency-ceiling=4',
]);
assert.equal(checked.status, 0);
assert.equal(checked.signal, null);
assert.equal(checked.stderr, '');
assert.deepEqual(JSON.parse(checked.stdout), {
schemaVersion: 1,
component: 'qinglong3-cluster-copilot-mcp',
event: 'preflight_checked',
transport: 'https',
ready: true,
configuration: 'valid',
credential: 'valid',
maxConcurrentRequests: 2,
concurrencyCeiling: 4,
requestMethod: 'GET',
requestPath: '/readyz',
mutation: false,
});
assert.equal(value.requests.length, 1);
assert.deepEqual(value.requests[0], {
method: 'GET',
path: '/readyz',
authorization: undefined,
requestId: undefined,
tls: 'TLSv1.3',
peerCertificate: {},
body: null,
});
value.readiness.value = 'not_ready';
const notReady = await runCli([
'--check',
'--config',
value.serverConfigFile,
'--concurrency-ceiling=4',
]);
assert.equal(notReady.status, 69);
assert.equal(notReady.stderr, '');
assert.equal(JSON.parse(notReady.stdout).ready, false);
assert.equal(value.requests.length, 2);
await value.close();
const unavailable = await runCli([
'--check',
'--config',
value.serverConfigFile,
'--concurrency-ceiling=4',
]);
assert.equal(unavailable.status, 1);
assert.equal(unavailable.stdout, '');
assert.deepEqual(JSON.parse(unavailable.stderr), {
schemaVersion: 1,
component: 'qinglong3-cluster-copilot-mcp',
level: 'error',
event: 'process_failed',
});
const overCeiling = await runCli([
'--check',
'--config',
value.serverConfigFile,
'--concurrency-ceiling=1',
]);
assert.equal(overCeiling.status, 1);
assert.equal(overCeiling.stdout, '');
assert.deepEqual(JSON.parse(overCeiling.stderr), {
schemaVersion: 1,
component: 'qinglong3-cluster-copilot-mcp',
level: 'error',
event: 'process_failed',
});
assert.equal(value.requests.length, 2);
});
test('stdio MCP uses direct TLS client, rotates credentials and labels untrusted output', async (t) => {
const value = await fixture(t);
const connected = startClient(t, value.serverConfigFile);
@@ -331,9 +331,9 @@ function validContextFixture(t) {
};
}
test('catalog exposes only reviewed remote clients from the same package', () => {
test('catalog exposes only reviewed product entrypoints from the same package', () => {
assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 8);
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 9);
assert.equal(
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
@@ -349,7 +349,11 @@ test('catalog exposes only reviewed remote clients from the same package', () =>
fs.lstatSync(path.join(packageRoot, 'dist', command.target)).isFile(),
true,
);
assert.equal(command.binary.includes('-client'), true);
assert.equal(
command.binary.includes('-client') ||
command.binary === 'ql3-copilot-mcp',
true,
);
}
for (const forbidden of [
'ql3-cluster-migrate',
@@ -375,6 +379,7 @@ test('help and version are bounded installation-derived product facts', () => {
assert.match(help, /^Usage: ql3-cluster-admin <command> \[arguments\]/);
assert.match(help, /\n run\s+retry or stop Runs/);
assert.match(help, /\n copilot\s+diagnose, inspect, read or cancel Runs/);
assert.match(help, /\n copilot-mcp\s+serve the bounded Cluster Copilot MCP/);
assert.match(help, /Server, migration, recovery, executor and key-custody/);
assert.equal(help.includes('plugin-package-manage'), false);
assert.equal(
@@ -9,6 +9,14 @@ const ENTRYPOINT = [
'/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js',
];
const COMMANDS = Object.freeze([
Object.freeze({
name: 'copilot',
usage: 'Usage: ql3-copilot-client ',
}),
Object.freeze({
name: 'copilot-mcp',
usage: 'Usage: ql3-copilot-mcp --config ',
}),
Object.freeze({
name: 'package',
usage: 'Usage: ql3-plugin-package-client ',
@@ -125,7 +133,9 @@ const validated = spawnSync(process.execPath, [facade, 'context', 'validate', '-
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);
const readinessServer = spawn(process.execPath, ['-e', ${JSON.stringify(readinessServerSource)}], { stdio: 'ignore' });
const readinessServer = spawn(process.execPath, ['-e', ${JSON.stringify(
readinessServerSource,
)}], { stdio: 'ignore' });
const waitArray = new Int32Array(new SharedArrayBuffer(4));
for (let attempt = 0; attempt < 200 && !existsSync('/tmp/readiness-port'); attempt += 1) Atomics.wait(waitArray, 0, 0, 10);
if (!existsSync('/tmp/readiness-port')) process.exit(27);
+106
View File
@@ -131,6 +131,105 @@ function assertClusterAdminImageCommands(readFile, root, findings) {
return references;
}
function assertClusterCopilotMcpHostDeployment(readFile, root, findings) {
const directory = path.join(root, 'deploy/mcp/ql3-cluster-copilot');
const launcher = readFile(path.join(directory, 'docker-stdio.sh'), 'utf8');
const mcpConfig = readJson(
readFile,
path.join(directory, 'mcp-config.example.json'),
);
const clientConfig = readJson(
readFile,
path.join(directory, 'client-config.example.json'),
);
const hostConfig = readJson(
readFile,
path.join(directory, 'mcp-host.example.json'),
);
const host = hostConfig?.mcpServers?.['qinglong-cluster-copilot'];
const expectedHostEnvironment = {
QL3_COPILOT_MCP_IMAGE:
'ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:' + '0'.repeat(64),
QL3_COPILOT_MCP_PRIVATE_ROOT: '/absolute/private/qinglong3-cluster-copilot',
QL3_COPILOT_MCP_NETWORK: 'qinglong3-copilot-egress',
QL3_COPILOT_MCP_RESOURCE_CLASS: 'compact',
};
const requiredLauncherFragments = [
'docker run --rm -i --pull never --init --read-only',
'--network "$network"',
'--cap-drop ALL',
'--security-opt no-new-privileges',
'--user 10001:10001',
'--mount "type=bind,src=$private_root,dst=/var/run/secrets/qinglong3/copilot-mcp,readonly"',
'copilot-mcp',
'--check --config /var/run/secrets/qinglong3/copilot-mcp/mcp.json',
'--config /var/run/secrets/qinglong3/copilot-mcp/mcp.json',
'compact)',
'memory=192m',
'concurrency_ceiling=1',
'standard)',
'memory=512m',
'concurrency_ceiling=4',
'dense)',
'memory=1g',
'concurrency_ceiling=16',
];
if (
!launcher.startsWith('#!/bin/sh\n\nset -eu\n') ||
requiredLauncherFragments.some(
(fragment) => !launcher.includes(fragment),
) ||
['--privileged', '--network host', '/var/run/docker.sock'].some(
(fragment) => launcher.includes(fragment),
) ||
JSON.stringify(mcpConfig) !==
JSON.stringify({
schema: 'qinglong/cluster-copilot-mcp-server@v1',
clientConfigFile: '/var/run/secrets/qinglong3/copilot-mcp/client.json',
credentialFile: '/var/run/secrets/qinglong3/copilot-mcp/credential',
maxConcurrentRequests: 1,
}) ||
JSON.stringify(clientConfig) !==
JSON.stringify({
schema: 'qinglong/cluster-copilot-client-config@v1',
endpoint: 'https://replace-cluster-api.example.com:5800/',
servername: 'replace-cluster-api.example.com',
caFile: '/var/run/secrets/qinglong3/copilot-mcp/ca.pem',
requestTimeoutMs: 30_000,
}) ||
host?.command !== '/absolute/path/to/docker-stdio.sh' ||
JSON.stringify(host?.args) !== JSON.stringify(['serve']) ||
JSON.stringify(host?.env) !== JSON.stringify(expectedHostEnvironment) ||
JSON.stringify(Object.keys(hostConfig?.mcpServers ?? {})) !==
JSON.stringify(['qinglong-cluster-copilot']) ||
JSON.stringify(Object.keys(host ?? {}).sort()) !==
JSON.stringify(['args', 'command', 'env'])
) {
findings.push(
finding(
'QL3_CLUSTER_COPILOT_MCP_HOST_CONTRACT_DRIFT',
'Cluster Copilot MCP must remain an explicit digest-pinned, non-root, read-only external stdio host process with bounded resource classes',
),
);
}
for (const filePath of kubernetesYamlFiles(
path.join(root, 'deploy/kubernetes/ql3-cluster'),
)) {
if (readFile(filePath, 'utf8').includes('ql3-copilot-mcp')) {
findings.push(
finding(
'QL3_CLUSTER_COPILOT_MCP_KUBERNETES_RESIDENT',
`${path.relative(
root,
filePath,
)} must not deploy a parentless stdio MCP process`,
),
);
}
}
return 'external-host-stdio';
}
function namedResource(resources, kind, name) {
return resources.find(
(resource) => resource?.kind === kind && resource?.metadata?.name === name,
@@ -5465,6 +5564,7 @@ function auditClusterDeployment(options = {}) {
const readFile = options.readFile ?? fs.readFileSync;
const findings = [];
let clusterAdminImageReferences = 0;
let clusterCopilotMcpHost = 'unavailable';
try {
assertExactExternalClosure(readFile, root, findings);
assertDockerfile(readFile, root, findings);
@@ -5474,6 +5574,11 @@ function auditClusterDeployment(options = {}) {
root,
findings,
);
clusterCopilotMcpHost = assertClusterCopilotMcpHostDeployment(
readFile,
root,
findings,
);
assertClusterAiComponent(readFile, root, findings);
assertPluginPackageManagementDeployment(readFile, root, findings);
assertWorkerCredentialManagementDeployment(readFile, root, findings);
@@ -5526,6 +5631,7 @@ function auditClusterDeployment(options = {}) {
promptOutputKeyRotation: 'caller-driven-staged-material',
clusterAi: 'optional-projected-authority',
clusterAiPromptOutput: 'optional-read-only-projected-keyring',
clusterCopilotMcpHost,
imageReleasePins: 'independent-fail-closed-digests',
clusterAdminImageReferences,
findings: Object.freeze(findings),
+2 -2
View File
@@ -232,7 +232,7 @@ function expectedImageConfig(architecture, revision, image) {
? isControlAi
? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js'
: '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/cli.js'
: '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/recovery/pluginPackageRecoveryCli.js',
: '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js',
],
WorkingDir: '/opt/qinglong',
Labels: {
@@ -240,7 +240,7 @@ function expectedImageConfig(architecture, revision, image) {
? isControlAi
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
: 'QingLong 3.0 PostgreSQL-backed cluster control plane'
: 'QingLong 3.0 short-lived cluster administration jobs',
: 'QingLong 3.0 cluster operations and bounded stdio MCP',
'org.opencontainers.image.licenses': 'Apache-2.0',
'org.opencontainers.image.revision': revision,
'org.opencontainers.image.source': 'https://github.com/whyour/qinglong',
@@ -0,0 +1,129 @@
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const ROOT = path.resolve(__dirname, '../..');
const launcher = path.join(
ROOT,
'deploy/mcp/ql3-cluster-copilot/docker-stdio.sh',
);
const image = `registry.example/qinglong3-cluster-admin@sha256:${'a'.repeat(
64,
)}`;
function fixture(t) {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-copilot-mcp-launcher-')),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const privateRoot = path.join(directory, 'private');
const bin = path.join(directory, 'bin');
fs.mkdirSync(privateRoot, { mode: 0o700 });
fs.mkdirSync(bin, { mode: 0o700 });
const capture = path.join(directory, 'docker-args');
const docker = path.join(bin, 'docker');
fs.writeFileSync(
docker,
'#!/bin/sh\nprintf \'%s\\n\' "$@" > "$QL3_TEST_DOCKER_ARGS"\n',
{ mode: 0o700 },
);
return {
privateRoot: fs.realpathSync(privateRoot),
capture,
env: {
...process.env,
PATH: `${bin}:${process.env.PATH ?? ''}`,
QL3_TEST_DOCKER_ARGS: capture,
QL3_COPILOT_MCP_IMAGE: image,
QL3_COPILOT_MCP_PRIVATE_ROOT: fs.realpathSync(privateRoot),
QL3_COPILOT_MCP_NETWORK: 'qinglong3-copilot-egress',
QL3_COPILOT_MCP_RESOURCE_CLASS: 'compact',
},
};
}
function invoke(args, env) {
return spawnSync(launcher, args, {
cwd: ROOT,
env,
encoding: 'utf8',
});
}
test('launches exact compact preflight with no ambient authority widening', (t) => {
assert.equal(fs.statSync(launcher).mode & 0o777, 0o755);
const value = fixture(t);
const result = invoke(['check'], value.env);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '');
assert.equal(result.stderr, '');
assert.deepEqual(
fs.readFileSync(value.capture, 'utf8').trimEnd().split('\n'),
[
'run',
'--rm',
'-i',
'--pull',
'never',
'--init',
'--read-only',
'--network',
'qinglong3-copilot-egress',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--user',
'10001:10001',
'--pids-limit',
'32',
'--memory',
'192m',
'--cpus',
'0.25',
'--mount',
`type=bind,src=${value.privateRoot},dst=/var/run/secrets/qinglong3/copilot-mcp,readonly`,
image,
'copilot-mcp',
'--check',
'--config',
'/var/run/secrets/qinglong3/copilot-mcp/mcp.json',
'--concurrency-ceiling=1',
],
);
});
test('binds standard serve resources and rejects tags or ambient networks', (t) => {
const value = fixture(t);
const served = invoke(['serve'], {
...value.env,
QL3_COPILOT_MCP_RESOURCE_CLASS: 'standard',
});
assert.equal(served.status, 0, served.stderr);
const args = fs.readFileSync(value.capture, 'utf8').trimEnd().split('\n');
assert.equal(args.includes('--check'), false);
assert.equal(args[args.indexOf('--memory') + 1], '512m');
assert.equal(args[args.indexOf('--cpus') + 1], '1');
assert.equal(args[args.indexOf('--pids-limit') + 1], '64');
assert.equal(args.at(-1), '--concurrency-ceiling=4');
fs.rmSync(value.capture);
for (const environment of [
{ ...value.env, QL3_COPILOT_MCP_IMAGE: 'registry.example/qinglong:latest' },
{ ...value.env, QL3_COPILOT_MCP_NETWORK: 'host' },
]) {
const rejected = invoke(['serve'], environment);
assert.equal(rejected.status, 78);
assert.equal(rejected.stdout, '');
assert.deepEqual(JSON.parse(rejected.stderr), {
schemaVersion: 1,
component: 'qinglong3-cluster-copilot-mcp-launcher',
event: 'launch_failed',
});
assert.equal(fs.existsSync(value.capture), false);
assert.doesNotMatch(rejected.stderr, /registry|private|host/);
}
});
+43 -6
View File
@@ -28,6 +28,7 @@ test('accepts the exact locked non-root multi-replica cluster deployment', () =>
report.clusterAiPromptOutput,
'optional-read-only-projected-keyring',
);
assert.equal(report.clusterCopilotMcpHost, 'external-host-stdio');
assert.equal(report.promptOutputKeyRotation, 'caller-driven-staged-material');
assert.equal(report.clusterAdminImageReferences, 24);
assert.deepEqual(report.workspacePackages, [
@@ -42,6 +43,38 @@ test('accepts the exact locked non-root multi-replica cluster deployment', () =>
]);
});
test('keeps Cluster Copilot MCP external, digest-pinned and resource-bounded', () => {
const widened = auditClusterDeployment({
root: ROOT,
readFile: intercept(
'deploy/mcp/ql3-cluster-copilot/docker-stdio.sh',
(source) => source.replace('--network "$network"', '--network host'),
),
});
assert.equal(widened.compatible, false);
assert.equal(
widened.findings.some(
({ code }) => code === 'QL3_CLUSTER_COPILOT_MCP_HOST_CONTRACT_DRIFT',
),
true,
);
const resident = auditClusterDeployment({
root: ROOT,
readFile: intercept(
'deploy/kubernetes/ql3-cluster/base/deployment.yaml',
(source) => `${source}\n# ql3-copilot-mcp\n`,
),
});
assert.equal(resident.compatible, false);
assert.equal(
resident.findings.some(
({ code }) => code === 'QL3_CLUSTER_COPILOT_MCP_KUBERNETES_RESIDENT',
),
true,
);
});
test('requires every Cluster Admin Kubernetes workload to override the image command', () => {
const report = auditClusterDeployment({
root: ROOT,
@@ -69,7 +102,8 @@ test('rejects widened Plugin Package Secret action Kubernetes authority', () =>
root: ROOT,
readFile: intercept(
'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/role.yaml',
(source) => source.replace(' - get\n', ' - get\n - list\n'),
(source) =>
source.replace(' - get\n', ' - get\n - list\n'),
),
});
assert.equal(widenedRole.compatible, false);
@@ -84,7 +118,8 @@ test('rejects widened Plugin Package Secret action Kubernetes authority', () =>
root: ROOT,
readFile: intercept(
'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/validating-admission-policy.yaml',
(source) => source.replace('failurePolicy: Fail', 'failurePolicy: Ignore'),
(source) =>
source.replace('failurePolicy: Fail', 'failurePolicy: Ignore'),
),
});
assert.equal(ignoredAdmissionFailure.compatible, false);
@@ -99,10 +134,11 @@ test('rejects widened Plugin Package Secret action Kubernetes authority', () =>
root: ROOT,
readFile: intercept(
'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/secret-action-service-account.yaml',
(source) => source.replace(
'automountServiceAccountToken: false',
'automountServiceAccountToken: true',
),
(source) =>
source.replace(
'automountServiceAccountToken: false',
'automountServiceAccountToken: true',
),
),
});
assert.equal(actionToken.compatible, false);
@@ -166,6 +202,7 @@ test('ships a path-only Cluster operator context example without durable authori
assert.deepEqual(Object.keys(example.commands).sort(), [
'approval',
'automation',
'copilot',
'model-credential',
'package',
'package-kubernetes',
+2 -2
View File
@@ -114,7 +114,7 @@ function createFixture(t, options = {}) {
? isControlAi
? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js'
: '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/cli.js'
: '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/recovery/pluginPackageRecoveryCli.js',
: '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js',
],
WorkingDir: '/opt/qinglong',
Labels: {
@@ -135,7 +135,7 @@ function createFixture(t, options = {}) {
? isControlAi
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
: 'QingLong 3.0 PostgreSQL-backed cluster control plane'
: 'QingLong 3.0 short-lived cluster administration jobs',
: 'QingLong 3.0 cluster operations and bounded stdio MCP',
'org.opencontainers.image.licenses': 'Apache-2.0',
'org.opencontainers.image.revision': revision,
'org.opencontainers.image.source':