已合并
feat(containers): 新增单一容器规范形模块(主/sidecar 统一) 上游 fsgroup/command/args/nfs_mounts 吸收进 canonical #521
feat(containers): 新增单一容器规范形模块(主/sidecar 统一) 上游 fsgroup/command/args/nfs_mounts 吸收进 canonical #521
已合并
王明琦创建于 26 天前
共 30 个文件变更+2346-1879
@@ -24,7 +24,7 @@
24```bash24```bash
25cd applications/agent_runtime25cd applications/agent_runtime
26uv sync --extra local26uv sync --extra local
27-uv run pytest # 496 个用例:状态层 Lua(含 EVICT/TOUCH/ROUTE_PLACE 残骸自卫) / 路由匹配纯函数(含表达式解析) / config 层(含三段式契约与容器表水合、config_refresh 强制刷新、写库单事务+锁看门狗) / 容器规范形(container_spec) / envFrom / 组件全链路(含 sidecars 多容器与卷挂载) / HTTP 冒烟 / corner case / 双实例多副本 / 停机韧性 / 审计实锤回归(test_audit_repro) / 强制刷新自然老化(test_force_refresh) / 健壮性故障注入(test_k8s_io_timeouts / test_infra_faults / test_main_lifecycle,见 docs/feature/2026-09-robustness-hardening.md) / 系统自评估(tests/evaluation/:规则引擎+LLM 降级+采样报告全链路,见 docs/spec/evaluation.md)27+uv run pytest # 508 个用例:状态层 Lua(含 EVICT/TOUCH/ROUTE_PLACE 残骸自卫) / 路由匹配纯函数(含表达式解析) / config 层(含三段式契约与容器表水合、config_refresh 强制刷新、写库单事务+锁看门狗) / 容器规范形(containers 统一 canonical + container_spec wire 翻译) / envFrom / 组件全链路(含 sidecars 多容器与卷挂载) / HTTP 冒烟 / corner case / 双实例多副本 / 停机韧性 / 审计实锤回归(test_audit_repro) / 强制刷新自然老化(test_force_refresh) / 健壮性故障注入(test_k8s_io_timeouts / test_infra_faults / test_main_lifecycle,见 docs/feature/2026-09-robustness-hardening.md) / 系统自评估(tests/evaluation/:规则引擎+LLM 降级+采样报告全链路,见 docs/spec/evaluation.md)
28```28```
29 29 
30- 构造 `ServiceManager` 必须传 `deploy_mode="subprocess"`(默认 k8s 会挂死测试)。30- 构造 `ServiceManager` 必须传 `deploy_mode="subprocess"`(默认 k8s 会挂死测试)。
@@ -119,10 +119,10 @@ Envelope 外层见 §0.2;`rawdata` 为三段式配置快照:
119| `envFrom` | list | 否 | envFrom 引用注入:每项 `{prefix?, secretRef:{name, optional?}}` 或 `{prefix?, configMapRef:{name, optional?}}`(恰一 ref)。**密钥以引用名下发,值不落模板/快照/pod_spec** |119| `envFrom` | list | 否 | envFrom 引用注入:每项 `{prefix?, secretRef:{name, optional?}}` 或 `{prefix?, configMapRef:{name, optional?}}`(恰一 ref)。**密钥以引用名下发,值不落模板/快照/pod_spec** |
120| `resources` | object | 否 | `{requests?: {cpu?, memory?}, limits?: {cpu?, memory?}}`,量纲字符串(如 `"500m"`/`"1Gi"`) |120| `resources` | object | 否 | `{requests?: {cpu?, memory?}, limits?: {cpu?, memory?}}`,量纲字符串(如 `"500m"`/`"1Gi"`) |
121| `volumeMounts` | list | 否 | `[{name, mountPath, subPath?, readOnly?}]`,按名引用模板 `volumes`;悬挂引用(卷未定义)→ 400;`subPath` 仅 configMap 卷;`readOnly` 缺省按源类型(configMap→true、hostPath/PVC→false) |121| `volumeMounts` | list | 否 | `[{name, mountPath, subPath?, readOnly?}]`,按名引用模板 `volumes`;悬挂引用(卷未定义)→ 400;`subPath` 仅 configMap 卷;`readOnly` 缺省按源类型(configMap→true、hostPath/PVC→false) |
122-| `securityContext` | object | 否 | **主容器只许 `runAsUser`/`runAsGroup`**(int ≥0,`null`=走镜像默认;注意不改变卷文件属主);sidecar 另有 `privileged`(bool)、`capabilities:{add:[], drop:[]}`、`seccompProfile:{type}`、`appArmorProfile:{type}`(type ∈ {`Unconfined`, `RuntimeDefault`}) |122+| `securityContext` | object | 否 | **主/sidecar 同一白名单**(决策 B,2026-09-11):`runAsUser`/`runAsGroup`(int ≥0,`null`=走镜像默认;注意不改变卷文件属主)、`privileged`(bool)、`capabilities:{add:[], drop:[]}`、`seccompProfile:{type}`、`appArmorProfile:{type}`(type ∈ {`Unconfined`, `RuntimeDefault`}) |
123| `readinessProbe` | object | 否 | **主容器恒 `httpGet{path, port}`** + `initialDelaySeconds`/`periodSeconds`(缺省 5/5);`tcpSocket`/`timeoutSeconds` → 400;probe port 若给必须等于 sse 端口。sidecar 为 `tcpSocket{port}`/`httpGet{path, port}` 二选一(可整体缺省,默认 5/10/3),`timeoutSeconds` 1..300 |123| `readinessProbe` | object | 否 | **主容器恒 `httpGet{path, port}`** + `initialDelaySeconds`/`periodSeconds`(缺省 5/5);`tcpSocket`/`timeoutSeconds` → 400;probe port 若给必须等于 sse 端口。sidecar 为 `tcpSocket{port}`/`httpGet{path, port}` 二选一(可整体缺省,默认 5/10/3),`timeoutSeconds` 1..300 |
124 124 
125-> 未知键、越角色键、内部表达不了的 K8s 字段(`command`/`args`/端口 `protocol` 等)→ **400,绝不静默丢弃**(防"看似配置了实际没生效")。125+> 未知键、内部表达不了的 K8s 字段(端口 `protocol` 等)→ **400,绝不静默丢弃**(防"看似配置了实际没生效")。
126 126 
127#### `templates[]` 字段(模板只持容器引用与 Pod 级/策略字段)127#### `templates[]` 字段(模板只持容器引用与 Pod 级/策略字段)
128 128 
@@ -156,13 +156,14 @@ flowchart TB
156| `envFrom` | list[{prefix?, secretRef?/configMapRef?}] | **envFrom 引用注入**(K8s EnvFromSource 完整形态;每项恰一 ref,`{name, optional?}`,prefix 为 env 变量名前缀;`[]`/缺省 = 无。密钥以引用名下发,**值不落模板/快照/pod_spec**) |156| `envFrom` | list[{prefix?, secretRef?/configMapRef?}] | **envFrom 引用注入**(K8s EnvFromSource 完整形态;每项恰一 ref,`{name, optional?}`,prefix 为 env 变量名前缀;`[]`/缺省 = 无。密钥以引用名下发,**值不落模板/快照/pod_spec**) |
157| `resources` | {requests?, limits?} | 嵌套 `{cpu, memory}` 量纲字符串;缺省 None |157| `resources` | {requests?, limits?} | 嵌套 `{cpu, memory}` 量纲字符串;缺省 None |
158| `volumeMounts` | list[{name, mountPath, subPath?, readOnly?}] | 按名引用模板 `volumes`(悬挂引用 → 400;`subPath` 仅 configMap 卷;`readOnly` 缺省按内部规范:configMap→true、hostPath/PVC→false) |158| `volumeMounts` | list[{name, mountPath, subPath?, readOnly?}] | 按名引用模板 `volumes`(悬挂引用 → 400;`subPath` 仅 configMap 卷;`readOnly` 缺省按内部规范:configMap→true、hostPath/PVC→false) |
159-| `securityContext` | dict | 主容器只许 `runAsUser`/`runAsGroup`(≥0,`None` = 走镜像默认;**不改变卷文件属主**——PVC 写权限根治仍是存储侧预属主,见 `e2e-test-cases.md` 真实缺陷②);sidecar 另有 `privileged`、`capabilities{add,drop}`、`seccompProfile`/`appArmorProfile`(type ∈ {Unconfined, RuntimeDefault};appArmor 渲染为 Pod annotation) |159+| `securityContext` | dict | **主/sidecar 同一白名单**(2026-09-11 决策 B:主容器特权面放开,安全策略归管理面,runtime 只做键/值校验):`runAsUser`/`runAsGroup`(≥0,`null` = 走镜像默认;**不改变卷文件属主**——PVC 写权限根治仍是存储侧预属主,见 `e2e-test-cases.md` 真实缺陷②)、`privileged`(bool)、`capabilities{add,drop}`、`seccompProfile`/`appArmorProfile`(type ∈ {Unconfined, RuntimeDefault};appArmor 渲染为 Pod annotation) |
160| `readinessProbe` | dict | 主容器恒 `httpGet{path(=health_path), port(=sse 端口)}` + `initialDelaySeconds`/`periodSeconds`(缺省 5/5;`tcpSocket`/`timeoutSeconds` → 400);sidecar `tcpSocket`/`httpGet` 二选一可缺省(缺省 5/**10**/3,period 差异不得跨角色套用),`timeoutSeconds` 1..300 |160| `readinessProbe` | dict | 主容器恒 `httpGet{path(=health_path), port(=sse 端口)}` + `initialDelaySeconds`/`periodSeconds`(缺省 5/5;`tcpSocket`/`timeoutSeconds` → 400);sidecar `tcpSocket`/`httpGet` 二选一可缺省(缺省 5/**10**/3,period 差异不得跨角色套用),`timeoutSeconds` 1..300 |
161-| —(不可表示即拒绝) | — | `command`/`args`/端口 `protocol`/nfs 卷 `readOnly:true`/`Localhost` profile 等 K8s 字段内部表达不了 → **400,绝不静默丢弃**(防"看似有特权实际没有") |161+| `command` / `args` | list[str] | 启动命令/参数覆盖(**主容器/sidecar 一致生效**,2026-09-11 起双角色);`None`/`[]` 同义 = 走镜像 ENTRYPOINT/CMD;非 str 项 → 400 |
162+| —(不可表示即拒绝) | — | 端口 `protocol`/`Localhost` profile 等 K8s 字段内部表达不了 → **400,绝不静默丢弃**(防"看似有特权实际没有") |
162 163 
163**`volumes`**(模板级,K8s `spec.volumes` 同构):`[{name(DNS-1123,模板内唯一), 恰一源}]`;源 = `hostPath{path, type?}` / `configMap{name, items?=[{key,path}]}` / `persistentVolumeClaim{claimName}` / `nfs{server, path?}`(NFS 仅主容器、至多一个挂载)。**未被任何容器挂载的卷 → 400**;同卷多容器共享天然成立(PVC 同 claim 跨容器单卷去重由 RM 渲染保证)。164**`volumes`**(模板级,K8s `spec.volumes` 同构):`[{name(DNS-1123,模板内唯一), 恰一源}]`;源 = `hostPath{path, type?}` / `configMap{name, items?=[{key,path}]}` / `persistentVolumeClaim{claimName}` / `nfs{server, path?}`(NFS 仅主容器、至多一个挂载)。**未被任何容器挂载的卷 → 400**;同卷多容器共享天然成立(PVC 同 claim 跨容器单卷去重由 RM 渲染保证)。
164 165 
165-> 内部实现注:水合后仍是扁平 `Template`(字段名 `agent_image`/`agent_env`/`sse_port`/`health_path`/`sidecars` 等,即快照与 RM `pod_spec` 契约,见 `docs/spec/session-manager.md` §models)——**同值必同 deploy_ver**(三段式与 legacy 内联逐字节等价,承重断言固化)。`max_pods` 不在 template 里——它是派生值 `⌈scope_concurrency / pod_concurrency⌉`;`autoscale_interval` 是全局默认(0.5s)。166+> 内部实现注:水合后是**统一容器规范形**(2026-09 起,`containers.py`):`Template` 持模板级字段(namespace/node_name/pod_name/sse_path/ready_*/策略)+ `main_container`(canonical dict,13 键全填满:ports/env/env_from/resources/三类挂载/nfs/security_context/readiness_probe 等)+ `sidecars`(同款 canonical 列表,name 升序)——即快照与 RM `pod_spec` 契约,见 `docs/spec/session-manager.md` §models)——**同值必同 deploy_ver**(三段式水合 vs 手构 canonical 等值,承重断言固化;RM 侧 `normalize_pod_spec` 对缓存补缺省,未来加容器字段零伪日落)。`max_pods` 不在 template 里——它是派生值 `⌈scope_concurrency / pod_concurrency⌉`;`autoscale_interval` 是全局默认(0.5s)。
166 167 
167**`routing_scope`**(config_sync 下发,持久化到 DB 表 `routing_scope`):scope 定义 = `scope_id + index + template_id + routing_rules + enabled + expires_at`,scope↔模板多对一。168**`routing_scope`**(config_sync 下发,持久化到 DB 表 `routing_scope`):scope 定义 = `scope_id + index + template_id + routing_rules + enabled + expires_at`,scope↔模板多对一。
168 169 
@@ -216,7 +217,7 @@ field := user_id | group_id | bot_id(固定小写枚举)
216```217```
217 218 
218- 语义:**以数组为准的全量替换**(upsert 全部 + 删除消失项;容器以本批为集 GC);幂等重放收敛(affected_scopes 为空)。219- 语义:**以数组为准的全量替换**(upsert 全部 + 删除消失项;容器以本批为集 GC);幂等重放收敛(affected_scopes 为空)。
219-- 校验(400 VALIDATION,锁外零副作用):缺 `containers` 键(legacy 内联载荷);`templates`/`scopes` 非 list;模板缺 `main_container_id`;**mixed**(引用键与 legacy 内联容器键并存);container_id 空/>100/同批重复/未被引用/双角色;容器逐项按角色校验(见 `container` 结构表;未知键/越角色键/不可表示字段);模板引用不在本批 containers;sidecar 引用重复/>8;volumes(重复卷名/多源/无源/悬挂挂载/未挂载卷/`subPath` 非 configMap/NFS 逾界);模板级 int 严格/策略下界/`nodeName` hostname;scope_id 字符集/`index` 拒 bool/引用不在本批模板集/`routing_rules` 表达式语法/`enabled` 须 bool/`expires_at` ISO-8601 或 null/同批重复(语法细则见上文)。220+- 校验(400 VALIDATION,锁外零副作用):缺 `containers` 键(legacy 内联载荷);`templates`/`scopes` 非 list;模板缺 `main_container_id`;**mixed**(引用键与 legacy 内联容器键并存);container_id 空/>100/同批重复/未被引用/双角色;容器逐项按角色校验(见 `container` 结构表;未知键/不可表示字段);模板引用不在本批 containers;sidecar 引用重复/>8;volumes(重复卷名/多源/无源/悬挂挂载/未挂载卷/`subPath` 非 configMap/NFS 逾界);模板级 int 严格/策略下界/`nodeName` hostname;scope_id 字符集/`index` 拒 bool/引用不在本批模板集/`routing_rules` 表达式语法/`enabled` 须 bool/`expires_at` ISO-8601 或 null/同批重复(语法细则见上文)。
220- 每次成功下发都会:重建路由快照(§5.1 `routing:snapshot`)、对每个**生效中** scope 推 RM 池参数 + pod_spec(**eager 预热**:autoscale 下一拍即预热 min_idle)、对禁用/过期 scope 与被删 scope 推 `min_idle=0`(自然排空)。221- 每次成功下发都会:重建路由快照(§5.1 `routing:snapshot`)、对每个**生效中** scope 推 RM 池参数 + pod_spec(**eager 预热**:autoscale 下一拍即预热 min_idle)、对禁用/过期 scope 与被删 scope 推 `min_idle=0`(自然排空)。
221 222 
222**curl 调用示例**(Envelope 包装:`type` 须为端点名、`metadata.request_id` 必填(兼幂等键)、三段式载荷在 `rawdata`;带 K8s pod 内探测的脚本版本见 `scripts/config_sync_seed.sh`。示例载荷要点:主容器探针恒 `httpGet` 且**无** `timeoutSeconds`/sidecar `tcpSocket` + 特权三件套/模板只持容器引用与 `volumes`/空 `routing_rules` = 通配兜底 scope):223**curl 调用示例**(Envelope 包装:`type` 须为端点名、`metadata.request_id` 必填(兼幂等键)、三段式载荷在 `rawdata`;带 K8s pod 内探测的脚本版本见 `scripts/config_sync_seed.sh`。示例载荷要点:主容器探针恒 `httpGet` 且**无** `timeoutSeconds`/sidecar `tcpSocket` + 特权三件套/模板只持容器引用与 `volumes`/空 `routing_rules` = 通配兜底 scope):
@@ -1180,15 +1181,14 @@ sequenceDiagram
1180 1181 
1181**配置项按"值是否在 deploy 时被烘焙进运行中的 Pod"分两类:**1182**配置项按"值是否在 deploy 时被烘焙进运行中的 Pod"分两类:**
1182 1183 
1183-**A 类——变更需"日落"老 Pod**(deploy 子集,除 `kubeconfig`;变更后老 Pod 运行态与新配置不一致,不再接新流量):1184+**A 类——变更需"日落"老 Pod**(deploy 子集,除 `kubeconfig`;变更后老 Pod 运行态与新配置不一致,不再接新流量)。2026-09 统一规范形起容器级以 canonical **整体**进指纹——加容器字段不动指纹字段集(spec_fields 只列模板级 + main_container/sidecars 两键):
1184 1185 
1185| 配置项 | 日落原因 |1186| 配置项 | 日落原因 |
1186|---|---|1187|---|---|
1187-| `agent_image` | 老 Pod 跑老代码 |1188+| `namespace` / `node_name` / `pod_name` | Pod 部署规格,新老不一致 |
1188-| `namespace` / `container_name` / `container_port` | Pod 部署规格,新老不一致 |1189+| `sse_path` | 影响 `pod_sse_url` 构造(`sse_port` 在 main_container 内) |
1189-| `sse_port` / `sse_path` | 影响 `pod_sse_url` 构造 |1190+| `main_container`(整体) | 镜像/`sse_port`/探针/env/envFrom/挂载/NFS/资源限额/securityContext——全部烘焙进 Pod,要重建才生效 |
1190-| `readiness_*` | 探针烘焙在 Pod spec 里,K8s 对老 Pod 持续用老探针 |1191+| `sidecars`(整体) | sidecar 镜像/端口/挂载/安全上下文,同上 |
1191-| `nfs_*` / 资源限额(CPU / 内存) | 挂载 / 限额要重建才生效 |
1192 1192 
1193**B 类——变更无需日落老 Pod**(运行时策略,控制面读时使用,老 Pod 继续服务):1193**B 类——变更无需日落老 Pod**(运行时策略,控制面读时使用,老 Pod 继续服务):
1194 1194 
@@ -42,6 +42,7 @@
42 - `config_refresh` 120s:**20 pass / 0 fail**,2 次刷新代次 0→1→2 单调(响应与 vis 双源一致),重建收敛 ≤120s,暖探测 200,冷启动 4/4 成功(p50=3.3s max=8.2s),**存量会话亲和 0 违规**,5.4 万请求 p99=10.1ms,ERROR 日志 0。42 - `config_refresh` 120s:**20 pass / 0 fail**,2 次刷新代次 0→1→2 单调(响应与 vis 双源一致),重建收敛 ≤120s,暖探测 200,冷启动 4/4 成功(p50=3.3s max=8.2s),**存量会话亲和 0 违规**,5.4 万请求 p99=10.1ms,ERROR 日志 0。
43 - `mixed` 180s(churn 12s + refresh 60s + route_touch,groups=2):**77 pass / 0 fail / 1 warn**;14 次 sync(43–129ms)+ 2 次 refresh(9–13ms)全程**零 409**(本地单发射者锁生效,同时实测了"刷新排空期内 B 类 sync 放行");代次 0→1→2 单调、重建收敛、暖探测 200、冷启动 4/4 成功(6ms–10s);11.6 万请求 p50=5.3ms p99=9.4ms;9 次 503/NO_POD_AVAILABLE(0.01%,max_pods 容量语义)按 WARN 观测;ERROR 日志 0。`affinity_violations` 全量记录 1 次换 pod(rebuild 等待期老 Pod 被 pod_ttl 回收后会话重放置,检查窗口外,属正常回收行为——JSON 全量字段提供可见性)。43 - `mixed` 180s(churn 12s + refresh 60s + route_touch,groups=2):**77 pass / 0 fail / 1 warn**;14 次 sync(43–129ms)+ 2 次 refresh(9–13ms)全程**零 409**(本地单发射者锁生效,同时实测了"刷新排空期内 B 类 sync 放行");代次 0→1→2 单调、重建收敛、暖探测 200、冷启动 4/4 成功(6ms–10s);11.6 万请求 p50=5.3ms p99=9.4ms;9 次 503/NO_POD_AVAILABLE(0.01%,max_pods 容量语义)按 WARN 观测;ERROR 日志 0。`affinity_violations` 全量记录 1 次换 pod(rebuild 等待期老 Pod 被 pod_ttl 回收后会话重放置,检查窗口外,属正常回收行为——JSON 全量字段提供可见性)。
44 - `queued` 25s 回归:**6 pass / 0 fail / 2 warn**;552rps 下 82% 请求被快失败(SCOPE_FULL 55.9% + NO_POD_AVAILABLE 26.5%——后者为快失败改造后的超限粗化码,白名单补充),p99=70.5ms;ERROR 日志 0。44 - `queued` 25s 回归:**6 pass / 0 fail / 2 warn**;552rps 下 82% 请求被快失败(SCOPE_FULL 55.9% + NO_POD_AVAILABLE 26.5%——后者为快失败改造后的超限粗化码,白名单补充),p99=70.5ms;ERROR 日志 0。
45+- **12h 真实镜像浸泡(2026-09-10 20:46 → 09-11 08:48,mixed,3 副本 LB,AgentServer 真镜像 0.0.16s 三件套契约,ready_timeout=240)**:**3310 pass / 0 fail / 1 warn**。29,179,081 请求(均值 674.5rps),延迟窗口 p50=5.4/p90=7.8/p99=10.3ms(样本裁剪保最近 1.68M 条);config 面 **719 次 sync(p50 112ms/max 260ms)+ 71 次 refresh(p50 16ms),代次 0→71 单调,全程零 409**;冷启动探测 **142/142 全 200**(真镜像 p50 12.0s/max 16.1s);亲和全量记录 574 次换 pod 均为 pod_ttl 回收引发的合法重放置(旧 Pod 存活性核验零真违规,≈每会话每 40min 一次,与 pod_ttl 120–180s + 71 轮刷新的节奏吻合);503/NO_POD_AVAILABLE 1057 次(0.00%,max_pods 容量语义 WARN 观测);**12h×3 副本 ERROR 日志 0 条**;每小时巡检 p99 全程 10.1–10.3ms 无漂移。工具侧新增:真实镜像三件套 CLI(--agent-image/--health-path/--sse-path/--agent-env/--ready-timeout)与浸泡样本裁剪(>2M 裁最旧 1/4,三数组同步保对齐)。
45- 发现的环境事实:e2e 部署镜像落后一个特性(b2b92604,无 559d62db 的 sm.capacity/history/evaluation 字段)→ 触发回退路径并被 warn 提示;自评估巡检在旧镜像上 404→skip(设计内)。46- 发现的环境事实:e2e 部署镜像落后一个特性(b2b92604,无 559d62db 的 sm.capacity/history/evaluation 字段)→ 触发回退路径并被 warn 提示;自评估巡检在旧镜像上 404→skip(设计内)。
46- **600s 浸泡(mixed,同参数放大时长)**:第一轮 257/1/1——唯一 FAIL 经 K8s 事件绝对时间戳实锤为 **pod_ttl 合法重放置**:旧 Pod(`fd8hbq1lm3`/`zkmn23kkhk`)寿命恰 180s=churn 状态 B 的 pod_ttl,refresh#6 重建暖 Pod 就绪 11s后被 reclaim,其上 6 个活跃会话 60ms 内重放置、服务零错误。据此增强亲和判定(旧 Pod 存活性核验,消亡重放置降 warn)。第二轮:**258 pass / 0 fail / 1 warn**,39 万请求(652.9rps)p50=5.4/p99=10.1ms,47 次 sync+9 次 refresh 零 409,冷启动 18/18 成功(p50 6.1s),930 次 503/NO_POD_AVAILABLE(0.24%,max_pods 容量语义 WARN 观测),ERROR 日志 0,自评估 0 critical。47- **600s 浸泡(mixed,同参数放大时长)**:第一轮 257/1/1——唯一 FAIL 经 K8s 事件绝对时间戳实锤为 **pod_ttl 合法重放置**:旧 Pod(`fd8hbq1lm3`/`zkmn23kkhk`)寿命恰 180s=churn 状态 B 的 pod_ttl,refresh#6 重建暖 Pod 就绪 11s后被 reclaim,其上 6 个活跃会话 60ms 内重放置、服务零错误。据此增强亲和判定(旧 Pod 存活性核验,消亡重放置降 warn)。第二轮:**258 pass / 0 fail / 1 warn**,39 万请求(652.9rps)p50=5.4/p99=10.1ms,47 次 sync+9 次 refresh 零 409,冷启动 18/18 成功(p50 6.1s),930 次 503/NO_POD_AVAILABLE(0.24%,max_pods 容量语义 WARN 观测),ERROR 日志 0,自评估 0 critical。
47 48 
@@ -0,0 +1,73 @@
1+# 容器规范形统一——主/sidecar 单一 canonical,加容器字段只改一处
2+ 
3+- 日期:2026-09-11
4+- 涉及模块:session_manager / resource_manager / service-core / 测试 / 文档
5+ 
6+## 背景与动机
7+ 
8+2026-08 容器表拆分后,存储层(wire 三段式 + `service_config_container` 表)已统一,但**水合出口仍投影回两套历史形状**:主容器拍平成 `Template` 的 ~23 个 `agent_*` 扁平字段,sidecar 用 `sidecars.py` 冻结的 24 键规范形(含 `env_from` 条件键)。这是当时为保 `deploy_ver` 指纹连续(暖 Pod 不被伪日落)而刻意保留的适配层。
9+ 
10+后果:**加一个容器字段要改六处**——container_spec 解析 + 两个投影 / Template 扁平字段 / spec_fields 指纹字段集 / k8s.py 两处渲染,外加测试与文档;且主/sidecar 默认值在两处各写一份,存在漂移隐患。
11+ 
12+**决策前提(用户确认)**:当前开发阶段,无生产存量 Pod——`deploy_ver` 指纹一次性重置可接受,换取"加字段只改一处"(canonical 定义 + 渲染分支)。
13+ 
14+## 方案
15+ 
16+1. **单一 canonical(13 键,role 不影响键集,只影响值域/默认值)**:新顶层共享模块 `containers.py` 吸收 `sidecars.py` 全部职责并**删除后者**(不留 re-export shim——双名指同一层违背单一规范形,旧不变式 docstring 必误导后人)。canonical = `name/image/image_pull_policy/ports/env/env_from/resources/三类挂载/nfs/security_context/readiness_probe`。
17+2. **全键填满,废除条件键**:`env_from` 值可为 None 但键恒在。条件键的唯一理由(存量指纹零扰动)随指纹重置消失;它反而是"有值才出现"的等价异形来源。NFS 从模板级三元组**收进主容器**(`nfs` 键,main 独有)——水合单输出、渲染天然适配、Template 实现净切。
18+3. **指纹不变式在新形状重建**:canonical 幂等、容器间 name 升序、`capabilities_add/drop` **排序去重**(值序无 K8s 语义,借重置窗口收紧)、`ports` http 端口号==sse 时丢弃(RM 渲染同名端口去重的既有约定,渲染同形 ⟺ canonical 同形)、probe path 前导 `/` 归一迁入 canonical。基线常量重新冻结(`test_containers.py`:0ac9bf7494973132 / 3c1ba3cbcf0b1ed7 / 4ca65eb0ce7220a8;旧常量随扁平字段集作废)。
19+4. **`normalize_pod_spec`(承重补强)**:RM `_deploy_ver` 与渲染入口统一前置——模板级透传,容器段**只补 canonical 缺省键、绝不改已有值、不丢项**。未来加容器字段(新键默认值==旧行为)时,Redis 里未刷新的旧 `pod_spec_json` 正规化后与新算指纹相等 → 零伪 A 类日落;行为性新键应当日落,日落正确。配套承重测试:"补缺省==全键指纹"+"合法不同值必不等"(防过度归一造伪相等 → 旧 Pod 误复用)。
20+5. **`spec_fields.py` 收缩**:DEPLOY_FIELDS = 模板级 pod 字段(namespace/node_name/pod_name/sse_path)+ `main_container` + `sidecars` 两键,容器字段增删**不再动本文件**——这就是"只改一处"的落点。
21+6. **Template 重构**:删 23 个扁平容器字段,`main_container`(canonical dict)+ `sidecars`(canonical 列表);保留只读兼容 property `agent_image/sse_port/health_path/container_name`(UI 摘要/诊断,不参与指纹序列化)。`container_spec.py` 缩为纯 wire 翻译 + `build_canonical` 水合出口(读/写路径共用 `config_store._hydrate_containers`)。
22+7. **RM 单一渲染器**:`_build_sidecar_container`/`_build_sidecar_security_context`/`_build_sidecar_probe` 与主容器内联段合并为 `_build_container(c, cont, role, idx, pod_id, pvc_seen)`,role 分支五处(NFS 仅 main 首序/ports 命名契约/探针形态/secctx 键域/apparmor annotation)。**渲染输出与历史逐字节一致**(黄金断言 + e2e 阶段 2c 锚定)。
23+8. **被否方案**:①RM 双读 shim(新旧两形兼容一个升级窗口)——指纹重置已强制全量日落,shim 净亏;②sidecars.py 改名保留——引用面纯机械但双名永生;③分两个中间提交切 SM/RM——deploy_subset 与 RM 渲染是同一运行契约,中间形态需保留被删投影当过渡适配器,净成本高于原子切换。
24+ 
25+## 实现
26+ 
27+- **新增** `src/agent_runtime/containers.py`(SM/RM 共享顶层,与 spec_fields/mounts 同款先例):canonical/normalize/validate/conflict/派生 helper/normalize_pod_spec/默认值单源常量(DEFAULT_*、MAIN/SIDECAR_PROBE_DEFAULT、SECCTX_DEFAULT、RESOURCES_DEFAULT)。
28+- **删除** `src/agent_runtime/sidecars.py`;引用方(models/container_spec/config_store/k8s/tests)全部切 containers。
29+- `spec_fields.py`:新 DEPLOY_FIELDS(8 键);`models.py`:Template 重构 + 兼容 property;`container_spec.py`:删两个投影、`_parse_*` 默认值 import 单源、`build_canonical`;`config_store.py`:删 legacy 内列水合(无 `main_container_id` 行 → WARNING+None,fail-closed)、`_COLUMN_OF` 缩到模板级、新增 `_LEGACY_INLINE_CONTAINER_KEYS`(mixed-400 检测)、`_hydrate_containers` 单出口;`routing.py`:`template_from_json` legacy 扁平快照检测(→ ValueError 判坏重建);`k8s.py`:单一 `_build_container` + shape 探测(缺 `main_container` → DeployFailed);RM `orchestrator.py`(`_deploy_ver` 正规化前置/sse_url/REGISTER helper 化)、`sweeper.py`(autoscale legacy 缓存 skip_legacy_spec/探测回退读 main_container)。
30+- **不动**:6 个 Lua(纯透传)、`state.py` 键 schema、`pod:info` 烘焙字段、`mounts.py`、config_sync wire(三段式契约零变化)、全部 scripts 载荷构造(e2e/load_test/config_sync_seed)、DB schema(**无 ALTER**:容器表 15 列原样,模板表 legacy 列继续死值,`agent_image=""` 死值写法保留)。
31+ 
32+## 验证
33+ 
34+- 单测:497/497 全绿(拆分提交:C1 新增 containers.py+50 用例纯增 → C2 投影内核+适配器等价证明(既有断言零改动通过) → C3 原子切换+测试改造)。承重断言:指纹基线冻结(新三常量)、`normalize_pod_spec` 补缺省==全键指纹、不同值必不等、canonical 幂等、legacy 行/快照/缓存三方 fail-closed、渲染黄金断言(kwargs 级,含主容器空 secctx 省 kwarg、sidecar `is None`)。
35+- 真环境(2026-09-11,e2e 集群 3 副本 `agent-runtime:canonical-20260911`,前置 SQL 0 行 legacy):
36+ - **真镜像发布门禁 127/127 PASS**(agentserver 0.0.16s + sandbox 0.0.18s 三件套契约 + `--with-sidecar --with-mounts`;含阶段 2c 真实 Pod spec 逐字段断言=渲染不变锚、DB 落库校验 postgresql、阶段 11b 内部不变量巡检 deploy_ver==RM cfg)。
37+ - **多副本 e2e 32/32**(S1–S8:跨副本快照共享/会话幂等/删 Pod 恢复/failover 后 LB 可服务/选主互斥)。
38+ - **load_test 60s 6 场景 6/6 checks**:28068 请求 p50=5.4ms p99=9.2ms,ERROR 日志增量 0,config_churn/config_refresh 热更新全过。
39+ 
40+## 影响面
41+ 
42+**2026-09-11 rebase 补记**:本篇落地后 develop rebase 到上游 `1d8698b7`(manager A2A 发现/凭证同步策略),其前置 `bef82fc4`("添加fsgroup,cmd,args参数")在旧两套形状上扩了容器面——本次冲突解决把三特性**吸收进 canonical**,成为统一规范形的第一次真实"加字段"验证:
43+- `fs_group`(模板级,wire `fsGroup`)→ Template 字段 + spec_fields + `_build_pod_body` 的 `V1PodSecurityContext.fsGroup`;
44+- `command`/`args`(容器级)→ canonical 两键(None/[] 同义),仅主容器渲染;
45+- NFS 第四挂载族 `nfs_mounts`(取代本篇原设计的单条 `nfs` dict 键):主/sidecar 一致开放、条数不限、readOnly 透传,RM `nfs_seen` 按 (server,path) 跨容器共享去重——上游语义更宽,采纳之。
46+canonical 由 13 键扩到 **15 键**;`deploy_ver` 随之**二次重置**(基线常量再冻结:80bd09a60f8c470c/e4c697572c185b0a/c19f1e18236e2ab1)。上游同名的 feature 文档见 `2026-09-nfs-volume-pod-level.md`(其"Template 四字段"表述以本篇 canonical 为准)。
47+ 
48+**升级前置 ALTER(实测踩到:漏 ALTER → config_sync 写库 500 → 快照缺失、阶段 11b 崩)**:
49+```sql
50+ALTER TABLE service_config_template ADD COLUMN IF NOT EXISTS fs_group integer;
51+ALTER TABLE service_config_container ADD COLUMN IF NOT EXISTS command json;
52+ALTER TABLE service_config_container ADD COLUMN IF NOT EXISTS args json;
53+```
54+rebase 后重验:真镜像门禁 127/127(镜像 `canonical-20260911b`,e2e PG 已补列)。
55+ 
56+**同日追加(用户决策)**:上游三字段在**主/sidecar 双容器一致生效**——`fs_group` 本就是 Pod 级 securityContext(天生全容器);`nfs_mounts` 吸收时已双角色;**`command`/`args` 打开 sidecar 渲染**(原上游仅主容器,canonical 已携带但渲染层丢弃 = 静默吞键,违反白名单原则)。`_build_container` 的 cmd_kwargs 去掉 role 门;HLD 容器表补 `command`/`args` 行并从"不可表示"清单摘除。
57+ 
58+- 文档同步(同一提交):HLD(内部实现注/场景 M A 类字段表三分类)、spec/session-manager.md(单轨水合/containers.py 段/水合出口)、spec/resource-manager.md(`_build_container` 五分支/`_deploy_and_register` helper)、spec/service-core.md(容器字段不碰 spec_fields 指引)、api/config-plane-api.md(pod_spec_json 示例换嵌套形)、CLAUDE.md(用例计数/模块描述)。
59+- **`deploy_ver` 一次性重置**:升级后首个 config_sync 的版本收敛把旧 idle Pod 全部软摘除,按 `pod_ttl` 回收 + autoscale 重建(dev 可 config_refresh 加速)。这是本重构的**有意决策**,非缺陷。
60+- **升级操作序列**:①前置检查(存量库):`SELECT template_id FROM service_config_template WHERE main_container_id IS NULL OR main_container_id='';`——非空则**先重放 config_sync**(否则这些模板 fail-closed 跳过,scope 落兜底);②**全量重启**换镜像(不做新旧混版:混版下两套指纹算法 → 暖池互不复用 + autoscale 误判 stale);③启动后重放一次 config_sync 或调 config_refresh(Redis `pod_spec_json`/快照收敛,RM 侧对旧形缓存 autoscale skip_legacy_spec 待重推);④dev 环境可 FLUSHDB 简化。
61+- **后续加容器字段的标准路径**:containers.py(canonical 键 + 默认值常量 + role 校验)→ container_spec.py(wire 键 + `_parse_*`)→ k8s.py `_build_container`(渲染分支)→(可选)容器表新列(框架只 create_all,存量库手工 ALTER)。spec_fields/Template/指纹/投影**零改动**。
62+ 
63+## 决策 B:主容器 securityContext 特权面放开(2026-09-11,用户确认)
64+ 
65+审阅主/sidecar 残余差异时确认分界原则:**runtime 只限制自身机制依赖的不变量,业务策略归管理面**。据此逐条判定:sse 端口/唯一性校验/探针恒 http 是 runtime 机制依赖(路由、判 Ready、场景 N 探测、pod:info 烘焙),保留;**主容器 securityContext 仅 runAs 两键是唯一一条 runtime 功能不依赖的纯策略限制**(且与"sidecar 可特权"不自洽,实为历史沉淀),放开。
66+ 
67+- 改动:canonical `_canonical_secctx` 与 wire `_parse_security_context` 去 role 值域,主/sidecar 同一白名单;渲染层主容器改走 `_build_security_context` 全量路径(与 sidecar 同款,全默认 → None 走镜像默认;渲染出的 K8s Pod 与旧路径等价,仅 kwargs 表达带显式 None 键)。
68+- 安全职责转移:**是否给主容器(AgentServer)开特权由管理面负责**,runtime 保留键白名单与值类型校验(纵深防御缩为"防配错",不再是"防越权")。
69+- 指纹零扰动:存量配置的主容器 secctx 本就全默认,canonical 输出不变。
70+ 
71+## 二次 rebase 补记(2026-09-12,up/develop 026b53ff"多容器共用一个卷")
72+ 
73+上游把跨容器共享卷从 PVC/NFS 扩展到 **hostPath(`hp_seen`,键=path+type)与 ConfigMap(`cm_seen`,键=name+items 元组——items 属卷定义,同名不同 items 不共享)**,同款登记簿模式。吸收进统一渲染器:`_render_volume_mounts` 增 hp_seen/cm_seen,`_build_container` 透传,`_build_pod_body` 四登记簿贯穿主+sidecar——**四类挂载族现在全部具备跨容器同源去重**。上游三条共享用例移植到 canonical 形。508 用例。
@@ -17,7 +17,8 @@
17 17 
18| 日期 | 文档 | 一句话 |18| 日期 | 文档 | 一句话 |
19|---|---|---|19|---|---|---|
20-| 2026-09 | [NFS 卷与 PVC 同构化(pod 级卷源)](2026-09-nfs-volume-pod-level.md) | NFS 卷源归模板级 volumes、挂载走 `agent_nfs_mounts`/sidecar `nfs_mounts` 列表(与 PVC 同构,`nfs_seen` 跨容器去重);三元组降级 legacy 只读兼容;废「仅主容器/单挂载/禁 readOnly」限制;501 用例 |20+| 2026-09 | [NFS 卷与 PVC 同构化(pod 级卷源)](2026-09-nfs-volume-pod-level.md) | NFS 卷源归模板级 volumes、挂载走第四族 `nfs_mounts`(与 PVC 同构,`nfs_seen` 跨容器去重);三元组降级 legacy 只读兼容;废「仅主容器/单挂载/禁 readOnly」限制;501 用例 |
21+| 2026-09 | [容器规范形统一(主/sidecar 单一 canonical)](2026-09-unified-container-canonical.md) | 新增 containers.py:15 键 canonical(主/sidecar 同形,role 只控值域;吸收 fs_group/command/args/nfs_mounts),删 sidecars.py 与 Template ~23 个扁平字段、spec_fields 缩到模板级+容器两键——加容器字段只改一处(canonical+渲染分支);全键填满废条件键、caps 排序去重、normalize_pod_spec 补缺省防未来伪日落;RM 单一 _build_container;deploy_ver 二次重置(rebase 合入上游,开发期无存量);505 用例,升级序列与前置 SQL 见文档 |
21| 2026-09 | [压测脚本重设计:6 场景 + 可视化/ERROR 日志判定层](2026-09-load-test-scenarios-checks.md) | 3→6 场景(+config_churn/config_refresh/mixed,config 面单发射者);判定层加可视化断言(传播/代次/亲和/重建收敛/收尾巡检)与 ERROR 日志感知(文件偏移/只读 kubectl logs,默认阈值 0 硬门);max_pods 容量语义(连续刷新多代堆积→503)白名单化 WARN 观测;实测 churn 23/0、refresh 20/0、亲和 0 违规、ERROR 0 |22| 2026-09 | [压测脚本重设计:6 场景 + 可视化/ERROR 日志判定层](2026-09-load-test-scenarios-checks.md) | 3→6 场景(+config_churn/config_refresh/mixed,config 面单发射者);判定层加可视化断言(传播/代次/亲和/重建收敛/收尾巡检)与 ERROR 日志感知(文件偏移/只读 kubectl logs,默认阈值 0 硬门);max_pods 容量语义(连续刷新多代堆积→503)白名单化 WARN 观测;实测 churn 23/0、refresh 20/0、亲和 0 违规、ERROR 0 |
22| 2026-09 | [场景 F 快失败:拆除有界等待队列](2026-09-scope-full-fastfail.md) | scope 满→立即 503 `SCOPE_FULL`(删 504/队列满两码);根因 redis-py asyncio `RedisCluster` 无 pubsub;waiter ZSET/free 通道/LUA_WAITER_GATE/`AGENT_RUNTIME_SCOPE_FULL_TIMEOUT` 全链拆除;总预算改 `ready_timeout+余量`、超限粗化 NO_POD_AVAILABLE;433 用例 |23| 2026-09 | [场景 F 快失败:拆除有界等待队列](2026-09-scope-full-fastfail.md) | scope 满→立即 503 `SCOPE_FULL`(删 504/队列满两码);根因 redis-py asyncio `RedisCluster` 无 pubsub;waiter ZSET/free 通道/LUA_WAITER_GATE/`AGENT_RUNTIME_SCOPE_FULL_TIMEOUT` 全链拆除;总预算改 `ready_timeout+余量`、超限粗化 NO_POD_AVAILABLE;433 用例 |
23| 2026-09 | [系统自评估与建议能力(观测补齐+规则引擎+LLM)](2026-09-system-self-evaluation.md) | 新 evaluation 子包+`{agent_runtime:eval}` 键域(单槽 tag/零 Lua):sys_sample(30s 采样)+sys_eval(300s 规则+可选 LLM)两选主 job、route 热路径内存缓冲 5s 批量 flush、静态 7+动态 5 规则(findings 带 A/B 代价;快失败适配:删队列压力/超时-TTL 比两规则,容量错误计数改 SCOPE_FULL)、报告只读产出人审应用;可视化补容量闸门字段+history/evaluation 端点;真镜像门禁 126/126+真 cluster 19/19;LLM env 默认禁用零外呼 |24| 2026-09 | [系统自评估与建议能力(观测补齐+规则引擎+LLM)](2026-09-system-self-evaluation.md) | 新 evaluation 子包+`{agent_runtime:eval}` 键域(单槽 tag/零 Lua):sys_sample(30s 采样)+sys_eval(300s 规则+可选 LLM)两选主 job、route 热路径内存缓冲 5s 批量 flush、静态 7+动态 5 规则(findings 带 A/B 代价;快失败适配:删队列压力/超时-TTL 比两规则,容量错误计数改 SCOPE_FULL)、报告只读产出人审应用;可视化补容量闸门字段+history/evaluation 端点;真镜像门禁 126/126+真 cluster 19/19;LLM env 默认禁用零外呼 |
@@ -44,7 +44,7 @@
44 输家 → 清占位 → _follow_leader(follower 等待室) }44 输家 → 清占位 → _follow_leader(follower 等待室) }
45```45```
46 46 
47-`_deploy_and_register`:`k8s.deploy(pod_spec)`(create+wait Ready)→ 拼 `pod_sse_url = http://{pod_ip}:{sse_port}{sse_path}` → `LUA_REGISTER`(带 sse_port/health_path,Pod 烘焙自己的探测契约)。**deploy 与 REGISTER 都在 `except BaseException` 保护内**(红线:占位清理含取消路径;REGISTER 步失败不清占位一样虚占 max_pods);孤儿兜底删除**两级推导**——异常携带 pod_id/namespace(k8s.deploy 契约)优先,**否则用已到手的 `info`**(REGISTER 步的 Redis 异常/取消不带该属性,但物理 Pod 已建 Ready,不删就成 pods:all 之外的孤儿)。47+`_deploy_and_register`:`k8s.deploy(pod_spec)`(create+wait Ready)→ 拼 `pod_sse_url = http://{pod_ip}:{sse_port}{sse_path}`(sse_port/health_path 取自 `main_container`(经 `normalize_pod_spec` 正规化 + `containers.main_sse_port/main_health_path` 单源读取)→ `LUA_REGISTER`(带 sse_port/health_path,Pod 烘焙自己的探测契约)。**deploy 与 REGISTER 都在 `except BaseException` 保护内**(红线:占位清理含取消路径;REGISTER 步失败不清占位一样虚占 max_pods);孤儿兜底删除**两级推导**——异常携带 pod_id/namespace(k8s.deploy 契约)优先,**否则用已到手的 `info`**(REGISTER 步的 Redis 异常/取消不带该属性,但物理 Pod 已建 Ready,不删就成 pods:all 之外的孤儿)。
48 48 
49`_follow_leader`(M8,deploy 锁输家的等待室):49`_follow_leader`(M8,deploy 锁输家的等待室):
50- 准入走 `LUA_DEPLOY_FOLLOWER_GATE` 原子闸门,上限 `pod_concurrency - 1`(leader 会话之外新 Pod 恰剩这些槽);overflow 严格快失败 MaxPodsReached。50- 准入走 `LUA_DEPLOY_FOLLOWER_GATE` 原子闸门,上限 `pod_concurrency - 1`(leader 会话之外新 Pod 恰剩这些槽);overflow 严格快失败 MaxPodsReached。
@@ -101,10 +101,10 @@
101- **单次调用超时(2026-09 健壮性加固)**:create/read/list/delete 一律传 `_request_timeout`(常量 `CREATE_TIMEOUT=30`/`READ_TIMEOUT=10`/`LIST_TIMEOUT=15`/`DELETE_TIMEOUT=60`)——kubernetes_asyncio 不传时 aiohttp ClientTimeout 全 None(连库默认都覆盖),API server/网络挂起会无限悬挂并逐级拖死 deploy 与上层 route。超时异常走既有 except 链归一 DeployFailed(契约不变)。101- **单次调用超时(2026-09 健壮性加固)**:create/read/list/delete 一律传 `_request_timeout`(常量 `CREATE_TIMEOUT=30`/`READ_TIMEOUT=10`/`LIST_TIMEOUT=15`/`DELETE_TIMEOUT=60`)——kubernetes_asyncio 不传时 aiohttp ClientTimeout 全 None(连库默认都覆盖),API server/网络挂起会无限悬挂并逐级拖死 deploy 与上层 route。超时异常走既有 except 链归一 DeployFailed(契约不变)。
102- **生命周期并发(同批加固)**:`close()` 锁内只做引用摘除(快照+置空+复位 `_loaded`),网络收尾 `api_client.close()` 放锁外(持锁等网络会饿死 start);在飞调用持旧引用,各调用点(`deploy/get_pod/list_pods/delete/_read`)在 start 后快照 `self._core`,None 即 `DeployFailed("k8s client closed")`(不裸抛 AttributeError);close 后的惰性调用(`_loaded=False`)经 `start()` 自愈重建。102- **生命周期并发(同批加固)**:`close()` 锁内只做引用摘除(快照+置空+复位 `_loaded`),网络收尾 `api_client.close()` 放锁外(持锁等网络会饿死 start);在飞调用持旧引用,各调用点(`deploy/get_pod/list_pods/delete/_read`)在 start 后快照 `self._core`,None 即 `DeployFailed("k8s client closed")`(不裸抛 AttributeError);close 后的惰性调用(`_loaded=False`)经 `start()` 自愈重建。
103- `deploy(pod_spec)`:pod_id = `{pod_name}-{随机10}-{随机5}`(**K8s 随机 Pod 名,严禁业务 id 当实例 id——历史死锁根因**);409 名字冲突重命名重试至多 3 次;`_wait_ready` 轮询至 Ready+有 podIP(每 30s 一条 INFO 进度行,终态/超时 WARNING 带 waited_s),终态(Failed/Succeeded)/消失/超时 → DeployFailed;**create 之后的任何失败/取消先 best-effort 删除该 Pod 再抛,DeployFailed 携带 pod_id/namespace 属性供上层兜底**(契约:失败路径不留孤儿物理 Pod——未 REGISTER 的 Pod 不在 pods:all,watch/reconcile 只做 Redis→K8s 单向对账,孤儿无人认领);`get_pod`/`list_pods`/`delete` 带 DEBUG 耗时;`probe_health` 异常原因 DEBUG 留痕(调用方 sweeper 同节奏 WARNING)。103- `deploy(pod_spec)`:pod_id = `{pod_name}-{随机10}-{随机5}`(**K8s 随机 Pod 名,严禁业务 id 当实例 id——历史死锁根因**);409 名字冲突重命名重试至多 3 次;`_wait_ready` 轮询至 Ready+有 podIP(每 30s 一条 INFO 进度行,终态/超时 WARNING 带 waited_s),终态(Failed/Succeeded)/消失/超时 → DeployFailed;**create 之后的任何失败/取消先 best-effort 删除该 Pod 再抛,DeployFailed 携带 pod_id/namespace 属性供上层兜底**(契约:失败路径不留孤儿物理 Pod——未 REGISTER 的 Pod 不在 pods:all,watch/reconcile 只做 Redis→K8s 单向对账,孤儿无人认领);`get_pod`/`list_pods`/`delete` 带 DEBUG 耗时;`probe_health` 异常原因 DEBUG 留痕(调用方 sweeper 同节奏 WARNING)。
104-- `_build_pod_body`:label `{jiuwenclaw-component: agentserver, app: pod_id}`;资源 requests/limits;sse_port 必开(名 `sse`),container_port≠sse_port 加 `http`;**模板 `agent_env` 注入容器 env**(真 AgentServer 需 `AGENT_HTTP_ENABLED/HOST/PORT` 开 HTTP 入口);**`agent_env_from`(envFrom 引用)→ 主容器 `envFrom`(`_render_env_from`:secretRef/configMapRef/prefix/optional 逐字段透传;缺省 None 不设——与历史行为逐字节一致;脏缓存坏项跳过)**,sidecar 子键 `env_from` 同款(值不落模板/快照,只传引用名——密钥不再明文);readiness probe = `GET {health_path:-/health}:sse_port`;restart_policy=Always。`probe_health`(场景 N)与 readiness 同源取 `health_path`(sweeper 从 scope:config 的 pod_spec_json 读)。104+- `_build_pod_body`(2026-09 统一规范形):入口 `normalize_pod_spec`(补缺省键)→ shape 探测(缺 `main_container` = 旧扁平缓存 → **DeployFailed**,防渲染空镜像 Pod)→ `find_container_conflict` fail-fast(撞容器名/撞端口,防 agent 经 127.0.0.1 连错进程)→ **单一 `_build_container(c, cont, role, idx, pvc_seen, nfs_seen)` 渲染主/sidecar**,role 分支仅两处:ports 有名(sse/http)vs 无名声明、探针恒 httpGet 打 sse 端口无 timeout vs 可选 tcp/http 带 timeout。securityContext/command/args/挂载四族/env/envFrom/resources 主/sidecar 一致渲染(决策 B:securityContext 全量,apparmor 走 Pod annotation;全默认 → None 走镜像默认;command/args 缺省走镜像 ENTRYPOINT/CMD)。label `{jiuwenclaw-component: agentserver, app: pod_id}`;资源 requests/limits;**容器 env 注入**(真 AgentServer 需 `AGENT_HTTP_ENABLED/HOST/PORT` 开 HTTP 入口);**envFrom 引用 → `envFrom`(`_render_env_from`:secretRef/configMapRef/prefix/optional 逐字段透传;缺省 None 不设;脏缓存坏项跳过;值不落模板/快照,只传引用名——密钥不再明文)**;restart_policy=Always。`probe_health`(场景 N)与 readiness 同源取主容器探针 path(sweeper 从 scope:config 的 pod_spec_json 读,legacy 形回退旧扁平键)。
105-- `_build_pod_body` **多容器(pod_spec.sidecars,规范形见 `sidecars.py`)**:入口 `normalize_sidecars` 兜底(pod_spec 可能来自 Redis 缓存脏数据,坏项静默丢弃);`find_sidecar_conflict`(撞主容器名/撞 agent 端口)→ **DeployFailed**(fail-fast,防 agent 经 127.0.0.1 连错进程);每项经 `_build_sidecar_container` 渲染 V1Container——端口纯声明性**无名**(sidecar 只被同 Pod 127.0.0.1 访问,不进 Service)、独立 resources、security_context(privileged/caps/seccomp/run_as)、apparmor unconfined 落 **Pod annotation**、tcp/http readiness 探针;`containers=[主容器, *sidecars]`,无 sidecars 时 annotations=None、单容器——**与历史逐字节一致**。sidecar readiness 参与 Pod Ready → `_wait_ready` 天然等 sidecar 就绪(慢启动 sidecar 需调大模板 ready_timeout)。105+- **多容器(pod_spec.sidecars,canonical 见 containers.py)**:`normalize_pod_spec` 兜底(pod_spec 可能来自 Redis 缓存脏数据,坏项静默丢弃);sidecar 经同一 `_build_container` 渲染(端口纯声明性**无名**、独立 resources、security_context(privileged/caps/seccomp/run_as)、apparmor unconfined 落 **Pod annotation**、tcp/http readiness 探针);`containers=[主容器, *sidecars]`,无 sidecars 时 annotations=None、单容器——**与历史逐字节一致**。sidecar readiness 参与 Pod Ready → `_wait_ready` 天然等 sidecar 就绪(慢启动 sidecar 需调大模板 ready_timeout)。
106-- **卷挂载渲染(`mounts.py` 规范形,主容器与 sidecar 共用 `_render_volume_mounts`)**:hostPath/ConfigMap/PVC/NFS 四种;卷名 `_scoped_volume_name(prefix, 容器名净化, 容器idx, 挂载idx)`,前缀 `hp-`/`cm-`/`pvc-`/`nfs-`,≤63 防撞(容器名唯一保证跨容器不撞);**PVC 同 claim 跨容器去重**:`_build_pod_body` 以 `pvc_seen` 登记簿贯穿主容器与 sidecars,同 claim 只建**一个**共享卷(卷名取首现容器,主容器先渲染),后继容器的 volumeMount 复用该卷名——对齐 gateway 写法,防 kubelet 挂第二个同 claim 卷死锁/超时;**NFS 同共享(server+path)跨容器去重**:`nfs_seen` 同款登记簿——agentserver 主容器与 jiuwenbox sidecar 复挂同一 NFS 数据目录时只建一个共享卷、各自挂载点复用(2026-09 起 NFS 与 PVC 同构,旧 `{pod_id}-nfs` 专用卷名/三元组渲染废除);卷级 `read_only` 取首现值(kubelet 语义:卷源 ro 压 mount 级 rw,主 ro + sidecar rw 组合下 sidecar 实际只读);ConfigMap 支持 `sub_path`(单 key 挂到文件)与 `items`(V1KeyToPath);主容器四字段 `agent_host_path_mounts`/`agent_configmap_mounts`/`agent_pvc_mounts`/`agent_nfs_mounts` 与 sidecar 子字段同款;RM 入口 `normalize_mounts` 兜底脏缓存;无挂载时零增量(与历史一致)。106+- **卷挂载渲染(`mounts.py` 规范形,主容器与 sidecar 共用 `_render_volume_mounts`)**:hostPath/ConfigMap/PVC/NFS 四种(挂载族在 canonical `*_mounts` 键,主/sidecar 一致);卷名 `_scoped_volume_name(prefix, 容器名净化, 容器idx, 挂载idx)`,前缀 `hp-`/`cm-`/`pvc-`/`nfs-`,≤63 防撞;**PVC 同 claim 跨容器去重**(`pvc_seen` 登记簿)与 **NFS 同共享(server+path)跨容器去重**(`nfs_seen` 同款)——同源只建**一个**共享卷(卷名取首现容器,主容器先渲染),后继容器的 volumeMount 复用该卷名(防 kubelet 挂第二个同源卷死锁/超时);卷级 `read_only` 取首现值(kubelet 语义:卷源 ro 压 mount 级 rw);ConfigMap 支持 `sub_path` 与 `items`(V1KeyToPath);RM 入口 `normalize_mounts` 兜底脏缓存;无挂载时零增量(与历史一致)。
107-- `_build_pod_body` **pod 落位字段**:模板 `node_name` → `V1PodSpec.node_name`(绕调度器点名绑节点,`None`/空串不设);`run_as_user`/`run_as_group` → 主容器 `securityContext.runAsUser/runAsGroup`(覆盖镜像 `USER`;给了才设,`None` 不设键——与历史 Pod 零差异;sidecar 的同名字段早有,这是主容器对齐)。107+- `_build_pod_body` **pod 落位字段**:模板 `node_name` → `V1PodSpec.node_name`(绕调度器点名绑节点,`None`/空串不设);`run_as_user`/`run_as_group` → 主容器 `securityContext.runAsUser/runAsGroup`(canonical security_context,给了才设);模板级 `fs_group`(wire `fsGroup`)→ `V1PodSecurityContext.fsGroup`(kubelet 卷属主修正——NFS 卷属主问题的官方修法;None 不设)。
108- `normalize_phase`:deletion→Terminating;容器 waiting reason(ImagePullBackOff/CrashLoopBackOff/…)优先于 phase。108- `normalize_phase`:deletion→Terminating;容器 waiting reason(ImagePullBackOff/CrashLoopBackOff/…)优先于 phase。
109 109 
110**FakeK8sPodClient**(local/单测):deploy 立即 Ready;可编程 `unready_pods`/`dead_pods`/`unhealthy_pods`/`deploy_failures`(create 前失败,无物理残留)/`fail_after_create`(create 成功但永不 Ready——Pod 留在集群、DeployFailed 携带 pod_id,考验上层兜底删除)/`delete_failures`(连续 delete 失败,非 404 形态——考验 PURGE 的 delete 门槛)模拟异常分支;`deployed_specs` 录制每次 deploy 收到的 pod_spec(断言 pod_spec 端到端透传,如 sidecars)。110**FakeK8sPodClient**(local/单测):deploy 立即 Ready;可编程 `unready_pods`/`dead_pods`/`unhealthy_pods`/`deploy_failures`(create 前失败,无物理残留)/`fail_after_create`(create 成功但永不 Ready——Pod 留在集群、DeployFailed 携带 pod_id,考验上层兜底删除)/`delete_failures`(连续 delete 失败,非 404 形态——考验 PURGE 的 delete 门槛)模拟异常分支;`deployed_specs` 录制每次 deploy 收到的 pod_spec(断言 pod_spec 端到端透传,如 sidecars)。
@@ -173,7 +173,7 @@ SM 侧 ctx,级联管理全部生命周期(框架 App 的 lifespan 只认一个 c
173- `POLICY_FIELDS`:B 类策略(`scope_concurrency/pod_concurrency/session_ttl/pod_ttl/min_idle_pods`)。173- `POLICY_FIELDS`:B 类策略(`scope_concurrency/pod_concurrency/session_ttl/pod_ttl/min_idle_pods`)。
174- **kubeconfig 例外**:在 deploy 子集但**不入指纹**(只影响新 deploy,不日落)。174- **kubeconfig 例外**:在 deploy 子集但**不入指纹**(只影响新 deploy,不日落)。
175- 约束:SM `Template.deploy_ver()` 与 RM `orchestrator._deploy_ver()` 必须用同一字段集与算法(`util.fingerprint`)——A 类版本过滤依赖两端一致。175- 约束:SM `Template.deploy_ver()` 与 RM `orchestrator._deploy_ver()` 必须用同一字段集与算法(`util.fingerprint`)——A 类版本过滤依赖两端一致。
176-- **新增 template 字段时**:先在此分类 → 再补 `config_store.py` 的 `_COLUMN_OF` 列映射与 `*_TABLE_DEF` 表结构。176+- **新增模板级字段时**:先在此分类 → 再补 `config_store.py` 的 `_COLUMN_OF` 列映射与 `*_TABLE_DEF` 表结构。**容器级字段增改不碰本文件**(2026-09 起 `main_container`/`sidecars` 以 canonical 整体进指纹,只改 containers.py + RM 渲染分支;RM 对旧缓存经 `normalize_pod_spec` 补缺省后同指纹,零伪日落)。
177 177 
178## util.py —— 纯函数178## util.py —— 纯函数
179 179 
@@ -108,11 +108,11 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。
108 108 
109**DB 表**(策略四列 2026-09 起与 wire 术语同名,identity 映射在 `_COLUMN_OF`;曾用 EE 兼容名,见文末 RENAME 义务):`service_config_template`(`min_idle_pods`/`pod_concurrency`/`pod_ttl`/`scope_concurrency` + JSON 列 `agent_env`、`sidecars`、`agent_host_path_mounts`、`agent_configmap_mounts`、`agent_pvc_mounts` + 三段式新列 `main_container_id`(string 100)/`sidecar_container_ids`(JSON)/`volumes`(JSON))、**`service_config_container`**(容器规格表,15 列:`container_id` unique ≤100、`name`/`image`/`image_pull_policy` 标量 + `ports`/`env`/`env_from`/`resources`/`volume_mounts`/`security_context`/`readiness_probe` 七个内部规范形 JSON 段落列;框架 init_table 自动建,无需手工 DDL)、`routing_scope`(`scope_id` unique / `match_index`(避 SQL 保留字 index) / `template_id` / `routing_rules` JSON / `enabled` bool 默认 true / `expires_at` datetime 可空)。表结构常量 `*_TABLE_DEF` 由 main 传给框架建表。旧 `routing_rule` 表已废弃(不再读写,老库残留无害)。**模板表三段式三列为后期新增:存量库须先手工 ALTER 再发版**(`ALTER TABLE service_config_template ADD COLUMN main_container_id VARCHAR(100) NULL; ADD COLUMN sidecar_container_ids JSON NULL; ADD COLUMN volumes JSON NULL;`,框架建表只 create_all 不补列;`sidecars`/挂载列/`agent_env`/`health_path` 同款义务)。**routing_scope 的 `enabled`/`expires_at` 同为后期新增:存量库须** `ALTER TABLE routing_scope ADD COLUMN expires_at DATETIME NULL; ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;`(方言类型按 MySQL/PG 调整)。**模板表策略四列 2026-09 改名(wire 术语统一):存量库须先手工 RENAME 再发版**(`ALTER TABLE service_config_template RENAME COLUMN min_idle_services TO min_idle_pods; RENAME COLUMN service_concurrency TO pod_concurrency; RENAME COLUMN service_ttl TO pod_ttl; RENAME COLUMN session_concurrency TO scope_concurrency;`,MySQL 8+/PG 均支持;见 `docs/feature/2026-09-template-table-runtime-terms.md`)。109**DB 表**(策略四列 2026-09 起与 wire 术语同名,identity 映射在 `_COLUMN_OF`;曾用 EE 兼容名,见文末 RENAME 义务):`service_config_template`(`min_idle_pods`/`pod_concurrency`/`pod_ttl`/`scope_concurrency` + JSON 列 `agent_env`、`sidecars`、`agent_host_path_mounts`、`agent_configmap_mounts`、`agent_pvc_mounts` + 三段式新列 `main_container_id`(string 100)/`sidecar_container_ids`(JSON)/`volumes`(JSON))、**`service_config_container`**(容器规格表,15 列:`container_id` unique ≤100、`name`/`image`/`image_pull_policy` 标量 + `ports`/`env`/`env_from`/`resources`/`volume_mounts`/`security_context`/`readiness_probe` 七个内部规范形 JSON 段落列;框架 init_table 自动建,无需手工 DDL)、`routing_scope`(`scope_id` unique / `match_index`(避 SQL 保留字 index) / `template_id` / `routing_rules` JSON / `enabled` bool 默认 true / `expires_at` datetime 可空)。表结构常量 `*_TABLE_DEF` 由 main 传给框架建表。旧 `routing_rule` 表已废弃(不再读写,老库残留无害)。**模板表三段式三列为后期新增:存量库须先手工 ALTER 再发版**(`ALTER TABLE service_config_template ADD COLUMN main_container_id VARCHAR(100) NULL; ADD COLUMN sidecar_container_ids JSON NULL; ADD COLUMN volumes JSON NULL;`,框架建表只 create_all 不补列;`sidecars`/挂载列/`agent_env`/`health_path` 同款义务)。**routing_scope 的 `enabled`/`expires_at` 同为后期新增:存量库须** `ALTER TABLE routing_scope ADD COLUMN expires_at DATETIME NULL; ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;`(方言类型按 MySQL/PG 调整)。**模板表策略四列 2026-09 改名(wire 术语统一):存量库须先手工 RENAME 再发版**(`ALTER TABLE service_config_template RENAME COLUMN min_idle_services TO min_idle_pods; RENAME COLUMN service_concurrency TO pod_concurrency; RENAME COLUMN service_ttl TO pod_ttl; RENAME COLUMN session_concurrency TO scope_concurrency;`,MySQL 8+/PG 均支持;见 `docs/feature/2026-09-template-table-runtime-terms.md`)。
110 110 
111-**行形态双轨(仅读路径)**(`template_from_row(row, containers)`):行有真值 `main_container_id` → 三段式形态(模板级列 + 容器行 + volumes join 水合;**任一引用容器行缺失 → WARNING + 整模板跳过**,绝不静默丢单个 sidecar——那会隐形改 deploy_ver;引用它的 scope 视为不命中落兜底);否则 → legacy 内联列路径(**读兼容:升级后重放前的存量旧行**;wire 已收紧,legacy 写路径已删,新写入一律三段式形态)。新形态写行(`row_from_template_split`)只写模板级列 + 引用列 + volumes + `agent_image: ""` 死值(该列 NOT NULL 无默认,create/update 都写,防转换残留误导诊断)。111+**行形态单轨(2026-09 统一规范形起)**(`template_from_row(row, containers)`):模板级行列 + 容器行引用 + volumes join → 统一 canonical 水合(**任一引用容器行缺失/容器段落校验失败 → WARNING + 整模板跳过**,绝不静默丢单个 sidecar——那会隐形改 deploy_ver;引用它的 scope 视为不命中落兜底);**无 `main_container_id` 的 legacy 内联行不再水合**(→ WARNING + None,fail-closed;wire 已三段式独占,此类行 = 拆分后未收敛残骸,重放 config_sync 收敛)。写行(`row_from_template_split`)只写模板级列 + 引用列 + volumes + `agent_image: ""` 死值(该列 NOT NULL 无默认,create/update 都写,防转换残留误导诊断)。
112 112 
113-**sidecars.py(顶层共享模块,SM 校验与 RM 渲染共用;与 spec_fields 同款先例)**:通用 sidecar 容器列表(单 JSON 列),每项一个容器规格 dict,jiuwenbox 是第一个使用者(与主 agent 容器同 Pod、共享网络命名空间,agent 经 `127.0.0.1:port` 访问)。单项 schema(规范形填满全部默认键,列表按 name 升序):`name`(必,DNS-1123 ≤63,≠ `container_name` 且 Pod 内唯一)、`image`(必,≤512)、`port`?(≠ `sse_port`/`container_port`/兄弟 sidecar;探针目标)、`env`(同 agent_env 规则 str→scalar)、`image_pull_policy`(默认 IfNotPresent)、`cpu/memory_request/limit`、`privileged`/`capabilities_add|drop`/`seccomp_unconfined`/`apparmor_unconfined`(apparmor 经 Pod annotation 表达)/`run_as_user|group`、`host_path_mounts`/`configmap_mounts`/`pvc_mounts`/`nfs_mounts`(见 mounts.py;`nfs_mounts` 为**条件键:非空才出现**——后加键,存量 sidecar 指纹零扰动)、`readiness_probe_type`("tcp"|"http",设了必须有 port)/`readiness_path`(默认 /health)/`readiness_initial_delay|period|timeout_seconds`(默认 5/10/3)/`env_from`(envFrom 引用,`canonical_env_from` 规范形——**条件键:None/[] 省略**,区别于其他显式存 None 的键,为保存量 sidecar 指纹);列表 ≤8 条;**未知键 400 拒绝**(安全敏感面,拼错键不得静默吞)。**指纹不变式(★)**:`Template.sidecars` 默认 `None`、`__post_init__` 经 `normalize_sidecars` 把空列表/坏值归一为 None——`util.fingerprint` 只滤 None,以 `[]` 为默认会使全部存量模板 deploy_ver 变化(全量伪 A 类日落);"显式给默认值"与"省略键"、下发顺序重排、DB JSON 键序重排必须同指纹(规范形 + name 排序保证)。113+**containers.py(顶层共享模块,SM 校验/水合与 RM 渲染/指纹共用;与 spec_fields 同款先例;2026-09 吸收原 sidecars.py 并删除)**:单一容器 canonical 规范形(主/sidecar 同形,**15 键**全填满,role 只影响值域/默认值):`name`(必,DNS-1123 ≤63,Pod 内唯一)/`image`(必,≤512)/`image_pull_policy`(默认 IfNotPresent)/`command`/`args`(list[str]|None,None/[] 同义=走镜像入口;主/sidecar 一致渲染)/`ports`(main:恰一 `sse` + 至多一 `http`,固定序,http 端口号==sse 时丢弃;sidecar:至多一个无名端口)/`env`(str→scalar dict)/`env_from`(全键携带,值可 None——条件键机制废除)/`resources`(嵌套四键 str|None)/**四类 `*_mounts`**(hostPath/ConfigMap/PVC/NFS,mounts.py 规范形,排序;NFS 为第四族,主/sidecar 一致开放)/`security_context`(7 键,主/sidecar 全量一致——决策 B:主容器特权面放开,安全策略归管理面)/`readiness_probe`(5 键;main 恒 http 无 timeout,sidecar 可 tcp/http 带 timeout,period 默认 5 vs 10 不拉平)。`validate_pod_containers`(fail-fast:≤8/重名/撞名撞端口/四类挂载冲突)、`normalize_container(s)`(读路径防御,main 坏值回退默认)、`find_container_conflict`(纯谓词,RM 包 DeployFailed)、`main_sse_port`/`main_health_path`(派生 helper)、`normalize_pod_spec`(指纹/渲染前置:**只补缺省不改值**——未来加容器字段后旧 pod_spec_json 缓存正规化后与新算指纹相等,零伪 A 类日落)、`default_main_container`(空镜像哨兵,同旧 agent_image="")。**指纹不变式(★)**:canonical 幂等、全键填满、capabilities 排序去重、容器间 name 升序、None/[] 统一 None——“显式给默认值”与“省略键”、下发顺序重排、DB JSON 键序重排必须同指纹(基线常量冻结在 test_containers)。
114 114 
115-**mounts.py(顶层共享模块,主容器与 sidecar 挂载共用)**:四种卷挂载的规范形/校验/归一,主 agent 容器经 Template 四字段(`agent_host_path_mounts`/`agent_configmap_mounts`/`agent_pvc_mounts`/`agent_nfs_mounts`),sidecar 经各自子字段,同一套谓词。单项 schema(规范形填满默认键 + 按 `mount_path` 升序——挂载顺序无语义):115+**mounts.py(顶层共享模块,主容器与 sidecar 挂载共用)**:四种卷挂载(hostPath/ConfigMap/PVC/NFS)的规范形/校验/归一——2026-09 统一规范形起,主容器与 sidecar 一致:挂载族就是 canonical 键(`host_path_mounts`/`configmap_mounts`/`pvc_mounts`/`nfs_mounts`),同一套谓词。单项 schema(规范形填满默认键 + 按 `mount_path` 升序——挂载顺序无语义):
116- hostPath:`{host_path(必,绝对), mount_path(必,绝对), read_only=False, host_path_type?∈7 枚举}`;116- hostPath:`{host_path(必,绝对), mount_path(必,绝对), read_only=False, host_path_type?∈7 枚举}`;
117- ConfigMap(沿老 SDK ConfigMapMount):`{config_map_name(必,k8s 资源名), mount_path(必,绝对), sub_path?(相对路径,单 key 挂到文件), items?=[{key,path}](按 key 排序), read_only=True}`;117- ConfigMap(沿老 SDK ConfigMapMount):`{config_map_name(必,k8s 资源名), mount_path(必,绝对), sub_path?(相对路径,单 key 挂到文件), items?=[{key,path}](按 key 排序), read_only=True}`;
118- PVC:`{claim_name(必,k8s 资源名), mount_path(必,绝对), read_only=False}`;118- PVC:`{claim_name(必,k8s 资源名), mount_path(必,绝对), read_only=False}`;
@@ -120,9 +120,9 @@ touch 不分桶(无容量信号,HGET 反查 scope 热路径加一跳,不做)。
120同一容器内 `mount_path` 不得重复(四类挂载列表一起查)→ 400。指纹不变式同 sidecars(默认 None + 空归一 None + 排序)。120同一容器内 `mount_path` 不得重复(四类挂载列表一起查)→ 400。指纹不变式同 sidecars(默认 None + 空归一 None + 排序)。
121 121 
122**container_spec.py(SM 私有纯函数层,三段式契约的翻译层;不放顶层——顶层是 SM/RM 共享区,RM 不感知容器表)**:K8s 原生 wire(camelCase:`imagePullPolicy`/`containerPort`/`mountPath`/`periodSeconds`…;业务键 `container_id` snake)→ 内部规范形(snake,DB JSON 段落的存储形态)。要点:122**container_spec.py(SM 私有纯函数层,三段式契约的翻译层;不放顶层——顶层是 SM/RM 共享区,RM 不感知容器表)**:K8s 原生 wire(camelCase:`imagePullPolicy`/`containerPort`/`mountPath`/`periodSeconds`…;业务键 `container_id` snake)→ 内部规范形(snake,DB JSON 段落的存储形态)。要点:
123-- `parse_container_spec(item, where, role)`:role = 引用位置(主容器/sidecar)。主容器:ports 必有 `name="sse"`(可另有一个 `http`;无名/他名 → 400)、securityContext 只许 `runAsUser/runAsGroup`(越角色键 400)、探针恒 httpGet(`tcpSocket`/`timeoutSeconds` → 400)、缺省落定与 `Template` 默认逐项相等(period 5);sidecar:ports 至多 1 个无名端口、securityContext 全六键(`seccompProfile`/`appArmorProfile` type ∈ {Unconfined→true, RuntimeDefault→false},Localhost → 400)、探针缺省 {None, /health, 5, **10**, 3}(与 `_canonical_sidecar` 默认逐项相等)。**不可表示即拒绝**(command/args/protocol/envFrom 双 ref 等 → 400,绝不静默丢弃)。123+- `parse_container_spec(item, where, role)`:role = 引用位置(主容器/sidecar)。主容器:ports 必有 `name="sse"`(可另有一个 `http`;无名/他名 → 400)、探针恒 httpGet(`tcpSocket`/`timeoutSeconds` → 400)、缺省落定与 `Template` 默认逐项相等(period 5);sidecar:ports 至多 1 个无名端口、探针缺省 {None, /health, 5, **10**, 3}。securityContext 主/sidecar 同一白名单(决策 B):`runAsUser/runAsGroup/privileged/capabilities/seccompProfile/appArmorProfile`(type ∈ {Unconfined→true, RuntimeDefault→false},Localhost → 400)。**不可表示即拒绝**(command/args/protocol/envFrom 双 ref 等 → 400,绝不静默丢弃)。
124-- **卷 join(K8s spec.volumes 同构)**:模板级 `volumes`(每卷恰一源:hostPath/configMap/persistentVolumeClaim/nfs;卷名 DNS-1123 唯一)+ 容器 `volumeMounts` 按名引用;`fuse_mounts` 重建内部 fused 挂载(mounts.py 规范形,指纹承重)。源类型规则:悬挂引用/未挂载卷 → 400;`subPath` 仅 configMap;`readOnly` 缺省按内部规范(cm→**true**、hp/pvc/nfs→false)。**NFS 与 PVC 同构**:卷源(server/path)是模板级(pod 级)卷,主容器与 sidecar 一律按名引用挂载,条数/挂载点不限,无角色限制(2026-09 起废除「NFS 仅主容器单挂载」的三元组特化——三元组降级为 legacy 旧行只读兼容载体,`__post_init__` 转 `agent_nfs_mounts`)。124+- **卷 join(K8s spec.volumes 同构)**:模板级 `volumes`(每卷恰一源:hostPath/configMap/persistentVolumeClaim/nfs;卷名 DNS-1123 唯一)+ 容器 `volumeMounts` 按名引用;`fuse_mounts` 重建内部 fused 挂载(mounts.py 规范形,指纹承重)。源类型规则:悬挂引用/未挂载卷 → 400;`subPath` 仅 configMap;`readOnly` 缺省按内部规范(cm→**true**、hp/pvc/nfs→false)。**NFS 与 PVC 同构**:卷源(server/path)是模板级(pod 级)卷,主容器与 sidecar 一律按名引用挂载,条数/挂载点不限(2026-09 起废除「NFS 仅主容器单挂载」的三元组特化)。
125-- 投影:`main_template_kwargs`(主容器 22 个 Template 容器级 kwargs,挂载过 `validate_agent_mounts`)与 `sidecar_wire_input`(交 `validate_sidecars` 幂等再规范化,跨字段冲突免费)——**同值必同 deploy_ver**(`test_split_contract_deploy_ver_identical_to_inline` 承重)。125+- 水合出口:`build_canonical`(内部规范形 + volumes join → containers.canonical 收口)×2 → `validate_pod_containers`——读路径(`_template_from_split_row`)与写路径(`template_from_split_payload`)共用 `_hydrate_containers` 单出口。**同值必同 deploy_ver**(`test_split_contract_deploy_ver_identical_to_inline`:三段式水合 vs 手构 canonical Template 逐字节相等,承重)。
126 126 
127**routing.py(纯函数)**:`routing_rules` 是**布尔表达式字符串**——条件 `field in|not in ('v1', 'v2')` 经 `and`/`or` 与括号任意组合;优先级 条件 > and > or;关键字大小写不敏感,字段名固定小写枚举(user_id/group_id/bot_id);值单引号串(`''` 加倍或 `\'`/`\\` 转义);空值列表 `()` → in 恒假、not_in 恒真;不支持一元 `not`;上限长度 8000、括号嵌套 32。**空 routing_rules(null/空串/纯空白)= 通配兜底**;遍历按 `(index ASC, scope_id ASC)` **first-fit**;引用模板缺失/禁用的 scope、以及 scope 自身 `enabled=False` / `expires_at` 已过期(墙钟判定,`null`=永不过期)的,跳过落下一个。通配告警只计生效中的空表达式 scope。解析器 = 词法(`_TOKEN_RE`)+ 递归下降(`_Parser`:or_expr → and_expr → primary),产物为表达式树(`MatchExpression` 叶 / `AndNode` / `OrNode`),存于 `RoutingScopeDef.rule`(与原始串 `expr` 成对,后者是 wire/DB/快照载体)。`SCOPE_ID_RE = ^[0-9A-Za-z._-]{1,128}$`(禁 `:`/`*`/空白——Redis 键与 `pods:registered` 切分依赖)。127**routing.py(纯函数)**:`routing_rules` 是**布尔表达式字符串**——条件 `field in|not in ('v1', 'v2')` 经 `and`/`or` 与括号任意组合;优先级 条件 > and > or;关键字大小写不敏感,字段名固定小写枚举(user_id/group_id/bot_id);值单引号串(`''` 加倍或 `\'`/`\\` 转义);空值列表 `()` → in 恒假、not_in 恒真;不支持一元 `not`;上限长度 8000、括号嵌套 32。**空 routing_rules(null/空串/纯空白)= 通配兜底**;遍历按 `(index ASC, scope_id ASC)` **first-fit**;引用模板缺失/禁用的 scope、以及 scope 自身 `enabled=False` / `expires_at` 已过期(墙钟判定,`null`=永不过期)的,跳过落下一个。通配告警只计生效中的空表达式 scope。解析器 = 词法(`_TOKEN_RE`)+ 递归下降(`_Parser`:or_expr → and_expr → primary),产物为表达式树(`MatchExpression` 叶 / `AndNode` / `OrNode`),存于 `RoutingScopeDef.rule`(与原始串 `expr` 成对,后者是 wire/DB/快照载体)。`SCOPE_ID_RE = ^[0-9A-Za-z._-]{1,128}$`(禁 `:`/`*`/空白——Redis 键与 `pods:registered` 切分依赖)。
128 128 
@@ -46,7 +46,12 @@
46 uv run --no-sync python scripts/load_test.py \46 uv run --no-sync python scripts/load_test.py \
47 --base-url http://127.0.0.1:30091/api/session --scenario mixed \47 --base-url http://127.0.0.1:30091/api/session --scenario mixed \
48 --duration 300 --groups 2 --log-source kubectl --json48 --duration 300 --groups 2 --log-source kubectl --json
49- # 浸泡:--duration 3600 --report-interval 30049+ # 真实 AgentServer 镜像(三件套契约,同 e2e 真镜像门禁):
50+ uv run --no-sync python scripts/load_test.py --scenario mixed --duration 43200 \
51+ --agent-image swr.cn-north-4.myhuaweicloud.com/openjiuwen/jiuwenclaw-agentserver-amd64:<tag> \
52+ --health-path /api/v1/health --sse-path /api/v1/events/stream --ready-timeout 240 \
53+ --agent-env '{"AGENT_HTTP_ENABLED":"true","AGENT_HTTP_HOST":"0.0.0.0","AGENT_HTTP_PORT":"8086"}'
54+ # 浸泡:--duration 3600 --report-interval 300(延迟样本自动裁剪保最近 2M 条)
50"""55"""
51 56 
52from __future__ import annotations57from __future__ import annotations
@@ -110,6 +115,20 @@ def _parse_args() -> argparse.Namespace:
110 "config 面场景不适用)")115 "config 面场景不适用)")
111 p.add_argument("--namespace", default="agent-runtime-e2e-wmq",116 p.add_argument("--namespace", default="agent-runtime-e2e-wmq",
112 help="AgentServer Pod 拉起的 namespace(须已存在)")117 help="AgentServer Pod 拉起的 namespace(须已存在)")
118+ 
119+ # ---- AgentServer 容器契约(默认 influxdb:1.8 替身;真镜像带三件套,
120+ # 同 e2e 真镜像门禁:integration_smoke.sh --image ... --health-path
121+ # ... --sse-path ... --agent-env ...)
122+ p.add_argument("--agent-image", default="influxdb:1.8",
123+ help="AgentServer 主容器镜像(真实镜像用 SWR 全名)")
124+ p.add_argument("--health-path", default="/health",
125+ help="readiness/健康探测路径(真 AgentServer=/api/v1/health)")
126+ p.add_argument("--sse-path", default="/sse",
127+ help="模板 sse_path(真 AgentServer=/api/v1/events/stream)")
128+ p.add_argument("--agent-env", default=None,
129+ help='容器 env 的 JSON 对象(真 AgentServer 需 {"AGENT_HTTP_ENABLED":"true","AGENT_HTTP_HOST":"0.0.0.0","AGENT_HTTP_PORT":"8086"})')
130+ p.add_argument("--ready-timeout", type=float, default=60,
131+ help="模板 ready_timeout 秒(真镜像冷启动慢可调大,e2e 同款 240)")
113 p.add_argument("--cleanup", choices=["none", "config"], default="config",132 p.add_argument("--cleanup", choices=["none", "config"], default="config",
114 help="结束时删除本次 run 的模板/规则(默认);none=留待 TTL")133 help="结束时删除本次 run 的模板/规则(默认);none=留待 TTL")
115 p.add_argument("--timeout", type=float, default=90.0, help="单请求超时秒")134 p.add_argument("--timeout", type=float, default=90.0, help="单请求超时秒")
@@ -171,6 +190,13 @@ def _parse_args() -> argparse.Namespace:
171 args.log_source = "none"190 args.log_source = "none"
172 if args.log_source == "file" and not args.log_file:191 if args.log_source == "file" and not args.log_file:
173 p.error("--log-source file 需要 --log-file")192 p.error("--log-source file 需要 --log-file")
193+ if args.agent_env:
194+ try:
195+ args.agent_env = json.loads(args.agent_env)
196+ if not isinstance(args.agent_env, dict):
197+ raise ValueError("须为 JSON 对象")
198+ except ValueError as exc:
199+ p.error(f"--agent-env 解析失败: {exc}")
174 if args.scenario in CONFIG_SCENARIOS and args.no_seed:200 if args.scenario in CONFIG_SCENARIOS and args.no_seed:
175 p.error(f"--scenario {args.scenario} 需要播种(--no-seed 不适用)")201 p.error(f"--scenario {args.scenario} 需要播种(--no-seed 不适用)")
176 if args.scenario in CONFIG_SCENARIOS:202 if args.scenario in CONFIG_SCENARIOS:
@@ -202,13 +228,20 @@ def _envelope(msg_type, request_id, session_id, group_id):
202 }228 }
203 229 
204 230 
205-def _main_container(container_id, agent_image):231+def _main_container(container_id, agent_image, health_path, agent_env):
206 return {232 return {
207 "container_id": container_id,233 "container_id": container_id,
208 "name": "agent",234 "name": "agent",
209 "image": agent_image,235 "image": agent_image,
210- # influxdb:1.8 的 /health 在 8086(e2e 同款替代 AgentServer)236+ # influxdb:1.8 的 /health 在 8086(e2e 同款替代 AgentServer);
237+ # 真实 AgentServer 同端口 8086,health_path/sse_path/agent_env 三件套
238+ # 见 e2e 真镜像门禁参数
211 "ports": [{"name": "sse", "containerPort": 8086}],239 "ports": [{"name": "sse", "containerPort": 8086}],
240+ "imagePullPolicy": "IfNotPresent",
241+ "readinessProbe": {"httpGet": {"path": health_path, "port": 8086},
242+ "initialDelaySeconds": 5, "periodSeconds": 5},
243+ **({"env": [{"name": k, "value": v} for k, v in agent_env.items()]}
244+ if agent_env else {}),
212 }245 }
213 246 
214 247 
@@ -244,13 +277,15 @@ def _seed_payload(run: str, args, params: dict) -> dict:
244 """三段式快照载荷。churn 重放时同 id 仅 params 变(热更新而非删建)。"""277 """三段式快照载荷。churn 重放时同 id 仅 params 变(热更新而非删建)。"""
245 cid, tpl_id = _ids(run, args.scenario)278 cid, tpl_id = _ids(run, args.scenario)
246 tpl = {"template_id": tpl_id, "main_container_id": cid,279 tpl = {"template_id": tpl_id, "main_container_id": cid,
247- "namespace": args.namespace, "ready_timeout": 60, **params}280+ "namespace": args.namespace, "sse_path": args.sse_path,
281+ "ready_timeout": args.ready_timeout, **params}
248 scopes = [282 scopes = [
249 {"scope_id": f"scope-{run}-{gi}", "index": gi, "template_id": tpl_id,283 {"scope_id": f"scope-{run}-{gi}", "index": gi, "template_id": tpl_id,
250 "routing_rules": f"group_id in ('grp-{run}-{gi}')"}284 "routing_rules": f"group_id in ('grp-{run}-{gi}')"}
251 for gi in range(args.groups)285 for gi in range(args.groups)
252 ]286 ]
253- return {"containers": [_main_container(cid, "influxdb:1.8")],287+ return {"containers": [_main_container(cid, args.agent_image, args.health_path,
288+ args.agent_env)],
254 "templates": [tpl], "scopes": scopes}289 "templates": [tpl], "scopes": scopes}
255 290 
256 291 
@@ -325,6 +360,10 @@ def _as_float(v, default=0.0):
325 360 
326 361 
327class Stats:362class Stats:
363+ # 浸泡内存上限:保留最近 ~2M 样本(≈51 分钟 @650rps),超出裁最旧 1/4;
364+ # 三数组同步裁保持 ts/endpoints 对齐。total/错误直方图始终全程累计。
365+ _CAP = 2_000_000
366+ 
328 def __init__(self) -> None:367 def __init__(self) -> None:
329 self.latencies: list[float] = [] # 毫秒368 self.latencies: list[float] = [] # 毫秒
330 self.ts: list[float] = [] # time.monotonic(),与上同下标369 self.ts: list[float] = [] # time.monotonic(),与上同下标
@@ -333,6 +372,7 @@ class Stats:
333 self.transport_errors: Counter[str] = Counter()372 self.transport_errors: Counter[str] = Counter()
334 self.total = 0373 self.total = 0
335 self.transport_total = 0374 self.transport_total = 0
375+ self.total_trimmed = 0 # 已裁样本数(soak 窗口对齐用)
336 376 
337 def record(self, latency_ms: float, status: int, error_code: str | None,377 def record(self, latency_ms: float, status: int, error_code: str | None,
338 endpoint: str = "route") -> None:378 endpoint: str = "route") -> None:
@@ -342,6 +382,10 @@ class Stats:
342 self.endpoints.append(endpoint)382 self.endpoints.append(endpoint)
343 if status != 200:383 if status != 200:
344 self.errors[f"{status}/{error_code or '-'}"] += 1384 self.errors[f"{status}/{error_code or '-'}"] += 1
385+ if len(self.latencies) > self._CAP:
386+ cut = len(self.latencies) // 4
387+ del self.latencies[:cut], self.ts[:cut], self.endpoints[:cut]
388+ self.total_trimmed += cut
345 389 
346 def record_transport_error(self, kind: str) -> None:390 def record_transport_error(self, kind: str) -> None:
347 self.transport_total += 1391 self.transport_total += 1
@@ -1131,17 +1175,19 @@ async def main() -> int:
1131 events, lock, seed_ctx, affinity,1175 events, lock, seed_ctx, affinity,
1132 cold_log, stop_at)))1176 cold_log, stop_at)))
1133 1177 
1134- # 浸泡周期报告(增量窗口)1178+ # 浸泡周期报告(增量窗口;窗口起点按裁剪量平移)
1135- last_count, last_t = 0, time.monotonic()1179+ last_count, last_t, last_trimmed = 0, time.monotonic(), 0
1136 try:1180 try:
1137 while any(not t.done() for t in tasks):1181 while any(not t.done() for t in tasks):
1138 await asyncio.sleep(min(args.report_interval, 1.0))1182 await asyncio.sleep(min(args.report_interval, 1.0))
1139 now = time.monotonic()1183 now = time.monotonic()
1140 if args.report_interval > 0 and now - last_t >= args.report_interval \1184 if args.report_interval > 0 and now - last_t >= args.report_interval \
1141 and now > warmup_until:1185 and now > warmup_until:
1142- snap = _window(stats, last_count)1186+ idx = max(0, last_count - (stats.total_trimmed - last_trimmed))
1187+ snap = _window(stats, idx)
1143 _print_report("soak", snap, now - last_t)1188 _print_report("soak", snap, now - last_t)
1144 last_count, last_t = len(stats.latencies), now1189 last_count, last_t = len(stats.latencies), now
1190+ last_trimmed = stats.total_trimmed
1145 except KeyboardInterrupt:1191 except KeyboardInterrupt:
1146 print("\n[load] Ctrl-C:等待 task 收尾后输出部分报告…")1192 print("\n[load] Ctrl-C:等待 task 收尾后输出部分报告…")
1147 for t in tasks:1193 for t in tasks:
@@ -1248,6 +1294,10 @@ async def main() -> int:
1248 1294 
1249 # ---------------- 报告与退出码1295 # ---------------- 报告与退出码
1250 snap = stats.snapshot()1296 snap = stats.snapshot()
1297+ if stats.total_trimmed:
1298+ print(f"[final] 注:延迟样本保留最近 {snap['count']} 条"
1299+ f"(累计 {stats.total} 请求,裁剪 {stats.total_trimmed});"
1300+ f"错误码直方图为全程累计")
1251 _print_report("final", snap, elapsed)1301 _print_report("final", snap, elapsed)
1252 n_sync = sum(1 for e in events if e["kind"] == "sync")1302 n_sync = sum(1 for e in events if e["kind"] == "sync")
1253 n_refresh = sum(1 for e in events if e["kind"] == "refresh")1303 n_refresh = sum(1 for e in events if e["kind"] == "refresh")
@@ -21,9 +21,15 @@ from typing import Any
21 21 
22import httpx22import httpx
23 23 
24+from ..containers import (
25+ MAIN_ROLE,
26+ SIDECAR_ROLE,
27+ find_container_conflict,
28+ main_sse_port,
29+ normalize_pod_spec,
30+)
24from ..errors import DeployFailed31from ..errors import DeployFailed
25from ..mounts import normalize_mounts32from ..mounts import normalize_mounts
26-from ..sidecars import find_sidecar_conflict, normalize_sidecars
27from .models import POD_LABEL_KEY, POD_LABEL_VALUE, PodDeployInfo, PodInfo33from .models import POD_LABEL_KEY, POD_LABEL_VALUE, PodDeployInfo, PodInfo
28 34 
29logger = logging.getLogger("agent_runtime.resource_manager")35logger = logging.getLogger("agent_runtime.resource_manager")
@@ -333,7 +339,8 @@ class RealK8sPodClient(K8sPodClient):
333 pod_id = f"{pod_spec.get('pod_name') or 'agentserver'}-{_random_suffix(10)}-{_random_suffix(5)}"339 pod_id = f"{pod_spec.get('pod_name') or 'agentserver'}-{_random_suffix(10)}-{_random_suffix(5)}"
334 body = self._build_pod_body(pod_id, pod_spec)340 body = self._build_pod_body(pod_id, pod_spec)
335 logger.info("k8s create pod: name=%s namespace=%s image=%s",341 logger.info("k8s create pod: name=%s namespace=%s image=%s",
336- pod_id, namespace, pod_spec.get("agent_image"))342+ pod_id, namespace,
343+ (pod_spec.get("main_container") or {}).get("image"))
337 try:344 try:
338 await core.create_namespaced_pod(345 await core.create_namespaced_pod(
339 namespace=namespace, body=body, _request_timeout=CREATE_TIMEOUT)346 namespace=namespace, body=body, _request_timeout=CREATE_TIMEOUT)
@@ -360,213 +367,201 @@ class RealK8sPodClient(K8sPodClient):
360 raise367 raise
361 raise DeployFailed("k8s create pod failed: name conflicts exhausted")368 raise DeployFailed("k8s create pod failed: name conflicts exhausted")
362 369 
363- # -------------------------------------------------------------- sidecar 渲染370+ # -------------------------------------------------------------- 容器渲染(主/sidecar 统一)
364 371 
365 @staticmethod372 @staticmethod
366- def _build_sidecar_security_context(c: Any, sc: dict[str, Any]) -> Any | None:373+ def _build_security_context(c: Any, secctx: dict[str, Any]) -> Any | None:
367 """sidecar 安全上下文(移植老 SDK _build_security_context 精简版):374 """sidecar 安全上下文(移植老 SDK _build_security_context 精简版):
368 privileged/caps/seccomp/run_as_;apparmor 走 Pod annotation(调用方收集)。"""375 privileged/caps/seccomp/run_as_;apparmor 走 Pod annotation(调用方收集)。"""
369 capabilities = None376 capabilities = None
370- if sc["capabilities_add"] or sc["capabilities_drop"]:377+ if secctx.get("capabilities_add") or secctx.get("capabilities_drop"):
371 capabilities = c.V1Capabilities(378 capabilities = c.V1Capabilities(
372- add=sc["capabilities_add"] or None,379+ add=secctx.get("capabilities_add") or None,
373- drop=sc["capabilities_drop"] or None,380+ drop=secctx.get("capabilities_drop") or None,
374 )381 )
375 kwargs = {382 kwargs = {
376- "privileged": True if sc["privileged"] else None,383+ "privileged": True if secctx.get("privileged") else None,
377 "capabilities": capabilities,384 "capabilities": capabilities,
378 "seccomp_profile": (c.V1SeccompProfile(type="Unconfined")385 "seccomp_profile": (c.V1SeccompProfile(type="Unconfined")
379- if sc["seccomp_unconfined"] else None),386+ if secctx.get("seccomp_unconfined") else None),
380- "run_as_user": sc["run_as_user"],387+ "run_as_user": secctx.get("run_as_user"),
381- "run_as_group": sc["run_as_group"],388+ "run_as_group": secctx.get("run_as_group"),
382 }389 }
383 if all(value is None for value in kwargs.values()):390 if all(value is None for value in kwargs.values()):
384 return None391 return None
385 return c.V1SecurityContext(**kwargs)392 return c.V1SecurityContext(**kwargs)
386 393 
387- @staticmethod394+ def _build_container(
388- def _build_sidecar_probe(c: Any, sc: dict[str, Any]) -> Any:395+ self, c: Any, cont: dict[str, Any], *, role: str, idx: int,
389- """tcp → V1TCPSocketAction;http → V1HTTPGetAction(readiness_path)。"""396+ pod_id: str = "",
390- common = {
391- "initial_delay_seconds": sc["readiness_initial_delay"],
392- "period_seconds": sc["readiness_period"],
393- "timeout_seconds": sc["readiness_timeout_seconds"],
394- }
395- if sc["readiness_probe_type"] == "tcp":
396- return c.V1Probe(tcp_socket=c.V1TCPSocketAction(port=sc["port"]), **common)
397- return c.V1Probe(
398- http_get=c.V1HTTPGetAction(path=sc["readiness_path"], port=sc["port"]),
399- **common,
400- )
401- 
402- def _build_sidecar_container(
403- self, c: Any, sc: dict[str, Any], idx: int, *,
404 hp_seen: dict[tuple[str, Any], str] | None = None,397 hp_seen: dict[tuple[str, Any], str] | None = None,
405 cm_seen: dict[tuple, str] | None = None,398 cm_seen: dict[tuple, str] | None = None,
406 pvc_seen: dict[str, str] | None = None,399 pvc_seen: dict[str, str] | None = None,
407 nfs_seen: dict[tuple[str, str], str] | None = None,400 nfs_seen: dict[tuple[str, str], str] | None = None,
408 ) -> tuple[Any, list[Any], dict[str, str]]:401 ) -> tuple[Any, list[Any], dict[str, str]]:
409- """单个 sidecar(规范形,见 sidecars.py)→ (V1Container, 挂载卷, Pod annotation)。"""402+ """单个容器(canonical,见 containers.py)→ (V1Container, 挂载卷, Pod annotation)。
403+ 
404+ 主/sidecar 统一渲染器;role 差异收敛为两处:ports 有名(sse/http)vs
405+ 无名纯声明、探针恒 httpGet 打 sse 端口且无 timeout vs 可选 tcp/http
406+ 带 timeout。securityContext/command/args/挂载四族/env/envFrom/
407+ resources 主/sidecar 一致渲染(apparmor 走 Pod annotation,role 无关;
408+ security_context 全默认时传 None = 走镜像默认)。挂载四族
409+ (hp/cm/pvc/nfs)主/sidecar 一致(_render_volume_mounts),pvc_seen/
410+ nfs_seen 跨容器共享同 claim/同 server+path 的卷(防 kubelet 挂第二
411+ 个同源卷死锁)。
412+ """
413+ is_main = role == MAIN_ROLE
410 volumes, mounts = _render_volume_mounts(414 volumes, mounts = _render_volume_mounts(
411- c, sc["name"], idx,415+ c, cont["name"], idx,
412- host_path=sc["host_path_mounts"],416+ host_path=normalize_mounts(cont.get("host_path_mounts"),
413- config_map=sc["configmap_mounts"],417+ "host_path_mounts"),
414- pvc=sc["pvc_mounts"],418+ config_map=normalize_mounts(cont.get("configmap_mounts"),
415- nfs=sc.get("nfs_mounts"),419+ "configmap_mounts"),
420+ pvc=normalize_mounts(cont.get("pvc_mounts"), "pvc_mounts"),
421+ nfs=normalize_mounts(cont.get("nfs_mounts"), "nfs_mounts"),
416 hp_seen=hp_seen,422 hp_seen=hp_seen,
417 cm_seen=cm_seen,423 cm_seen=cm_seen,
418 pvc_seen=pvc_seen,424 pvc_seen=pvc_seen,
419 nfs_seen=nfs_seen,425 nfs_seen=nfs_seen,
420 )426 )
427+ 
421 resources = None428 resources = None
422- if any(sc[f] for f in ("cpu_request", "memory_request",429+ res = cont.get("resources") or {}
423- "cpu_limit", "memory_limit")):430+ if any(res.get(f) for f in ("cpu_request", "memory_request",
431+ "cpu_limit", "memory_limit")):
424 resources = c.V1ResourceRequirements(432 resources = c.V1ResourceRequirements(
425 requests={k: v for k, v in (433 requests={k: v for k, v in (
426- ("cpu", sc["cpu_request"]), ("memory", sc["memory_request"]),434+ ("cpu", res.get("cpu_request")),
435+ ("memory", res.get("memory_request")),
427 ) if v} or None,436 ) if v} or None,
428 limits={k: v for k, v in (437 limits={k: v for k, v in (
429- ("cpu", sc["cpu_limit"]), ("memory", sc["memory_limit"]),438+ ("cpu", res.get("cpu_limit")),
439+ ("memory", res.get("memory_limit")),
430 ) if v} or None,440 ) if v} or None,
431 )441 )
432- container = c.V1Container(442+ 
433- name=sc["name"],443+ ports = None
434- image=sc["image"],444+ if is_main:
435- image_pull_policy=sc["image_pull_policy"] or "IfNotPresent",445+ sse_port = main_sse_port(cont)
446+ ports = [c.V1ContainerPort(name="sse", container_port=sse_port)]
447+ for p in cont.get("ports") or []:
448+ if (p.get("name") == "http"
449+ and p.get("container_port") != sse_port):
450+ ports.append(c.V1ContainerPort(
451+ name="http", container_port=p["container_port"]))
452+ elif cont.get("ports"):
436 # 端口纯声明性(无名,消灭端口名撞号类 bug):sidecar 只被同 Pod453 # 端口纯声明性(无名,消灭端口名撞号类 bug):sidecar 只被同 Pod
437- # 127.0.0.1 访问,不进 Service,gateway 仍直连 Pod IP 的 sse_port454+ # 127.0.0.1 访问,不进 Service,gateway 仍直连 Pod IP 的 sse 端口
438- ports=[c.V1ContainerPort(container_port=sc["port"])] if sc["port"] else None,455+ ports = [c.V1ContainerPort(
439- env=[c.V1EnvVar(name=k, value=v) for k, v in sc["env"].items()] or None,456+ container_port=cont["ports"][0]["container_port"])]
440- env_from=_render_env_from(c, sc.get("env_from")),457+ 
441- volume_mounts=mounts or None,458+ probe = None
442- resources=resources,459+ probe_spec = cont.get("readiness_probe") or {}
443- security_context=self._build_sidecar_security_context(c, sc),460+ if is_main:
444- readiness_probe=(self._build_sidecar_probe(c, sc)461+ # AgentServer 固定约定:SSE 端口提供健康端点(默认 /health,模板
445- if sc["readiness_probe_type"] else None),462+ # 可覆盖——真 AgentServer HTTP 入口为 /api/v1/health);无 timeout
446- )463+ probe = c.V1Probe(
464+ http_get=c.V1HTTPGetAction(
465+ path=probe_spec.get("path") or "/health",
466+ port=main_sse_port(cont)),
467+ initial_delay_seconds=int(probe_spec.get("initial_delay") or 5),
468+ period_seconds=int(probe_spec.get("period") or 5),
469+ )
470+ elif probe_spec.get("probe_type"):
471+ common = {
472+ "initial_delay_seconds": int(
473+ probe_spec.get("initial_delay") or 5),
474+ "period_seconds": int(probe_spec.get("period") or 10),
475+ "timeout_seconds": int(probe_spec.get("timeout") or 3),
476+ }
477+ port = (cont["ports"][0]["container_port"]
478+ if cont.get("ports") else None)
479+ if port is not None:
480+ if probe_spec["probe_type"] == "tcp":
481+ probe = c.V1Probe(
482+ tcp_socket=c.V1TCPSocketAction(port=port), **common)
483+ else:
484+ probe = c.V1Probe(
485+ http_get=c.V1HTTPGetAction(
486+ path=probe_spec.get("path") or "/health",
487+ port=port),
488+ **common,
489+ )
490+ 
491+ env = [
492+ c.V1EnvVar(name=str(k), value=str(v))
493+ for k, v in (cont.get("env") or {}).items()
494+ ] or None
495+ 
496+ # 启动命令/参数覆盖(主/sidecar 一致;缺省走镜像 ENTRYPOINT/CMD)
497+ cmd_kwargs: dict[str, Any] = {}
498+ if cont.get("command"):
499+ cmd_kwargs["command"] = [str(x) for x in cont["command"]]
500+ if cont.get("args"):
501+ cmd_kwargs["args"] = [str(x) for x in cont["args"]]
502+ 
503+ container_kwargs: dict[str, Any] = {
504+ "name": cont["name"],
505+ "image": cont.get("image") or "",
506+ "image_pull_policy": (cont.get("image_pull_policy")
507+ or "IfNotPresent"),
508+ "ports": ports,
509+ "env": env,
510+ "env_from": _render_env_from(c, cont.get("env_from")),
511+ "volume_mounts": mounts or None,
512+ "resources": resources,
513+ "readiness_probe": probe,
514+ }
515+ secctx = cont.get("security_context") or {}
516+ # securityContext 主/sidecar 全量一致渲染(决策 B:主容器特权面放开;
517+ # 全默认 → None,走镜像默认)
518+ container_kwargs["security_context"] = self._build_security_context(
519+ c, secctx)
520+ container = c.V1Container(**container_kwargs, **cmd_kwargs)
447 # apparmor unconfined 只能以 Pod annotation 表达(老 SDK 同款)521 # apparmor unconfined 只能以 Pod annotation 表达(老 SDK 同款)
448- annotations = ({f"container.apparmor.security.beta.kubernetes.io/{sc['name']}":522+ annotations = ({f"container.apparmor.security.beta.kubernetes.io/{cont['name']}":
449- "unconfined"} if sc["apparmor_unconfined"] else {})523+ "unconfined"} if secctx.get("apparmor_unconfined") else {})
450 return container, volumes, annotations524 return container, volumes, annotations
451 525 
452 def _build_pod_body(self, pod_id: str, spec: dict[str, Any]) -> Any:526 def _build_pod_body(self, pod_id: str, spec: dict[str, Any]) -> Any:
453 c = self._client527 c = self._client
454 labels = {POD_LABEL_KEY: POD_LABEL_VALUE, "app": pod_id}528 labels = {POD_LABEL_KEY: POD_LABEL_VALUE, "app": pod_id}
455- volumes, mounts = [], []
456- 
457- # 主 agent 容器卷挂载(hostPath/ConfigMap/PVC/NFS;脏缓存 normalize 兜底,
458- # 规范形见 mounts.py;无挂载时零增量——与历史一致)
459- agent_owner = spec.get("container_name") or "agent"
460- # 跨容器共享卷登记簿:同源只建一个 Pod 级卷,主+sidecar 复用卷名
461- hp_seen: dict[tuple[str, Any], str] = {} # 同 (path, type) 的 hostPath 共享卷
462- cm_seen: dict[tuple, str] = {} # 同 (name, items) 的 ConfigMap 共享卷
463- pvc_seen: dict[str, str] = {} # 同 claim 的 PVC 跨容器共享一个卷(主+sidecar)
464- nfs_seen: dict[tuple[str, str], str] = {} # 同 server+path 的 NFS 共享卷
465- agent_volumes, agent_mounts = _render_volume_mounts(
466- c, agent_owner, 0,
467- host_path=normalize_mounts(spec.get("agent_host_path_mounts"),
468- "host_path_mounts"),
469- config_map=normalize_mounts(spec.get("agent_configmap_mounts"),
470- "configmap_mounts"),
471- pvc=normalize_mounts(spec.get("agent_pvc_mounts"), "pvc_mounts"),
472- nfs=normalize_mounts(spec.get("agent_nfs_mounts"), "nfs_mounts"),
473- hp_seen=hp_seen,
474- cm_seen=cm_seen,
475- pvc_seen=pvc_seen,
476- nfs_seen=nfs_seen,
477- )
478- volumes.extend(agent_volumes)
479- mounts.extend(agent_mounts)
480- 
481- resources = None
482- if any(spec.get(f) for f in ("agent_cpu_request", "agent_memory_request",
483- "agent_cpu_limit", "agent_memory_limit")):
484- resources = c.V1ResourceRequirements(
485- requests={k: v for k, v in (
486- ("cpu", spec.get("agent_cpu_request")),
487- ("memory", spec.get("agent_memory_request")),
488- ) if v} or None,
489- limits={k: v for k, v in (
490- ("cpu", spec.get("agent_cpu_limit")),
491- ("memory", spec.get("agent_memory_limit")),
492- ) if v} or None,
493- )
494- 
495- sse_port = int(spec.get("sse_port") or 8080)
496- container_port = int(spec.get("container_port") or sse_port)
497- ports = [c.V1ContainerPort(name="sse", container_port=sse_port)]
498- if container_port != sse_port:
499- ports.append(c.V1ContainerPort(name="http", container_port=container_port))
500- 
501- # AgentServer 固定约定:SSE 端口提供健康端点(默认 /health,模板可覆盖——
502- # 真 AgentServer HTTP 入口为 /api/v1/health)
503- probe = c.V1Probe(
504- http_get=c.V1HTTPGetAction(path=spec.get("health_path") or "/health",
505- port=sse_port),
506- initial_delay_seconds=int(spec.get("readiness_initial_delay") or 5),
507- period_seconds=int(spec.get("readiness_period") or 5),
508- )
509- 
510- # Agent 容器 env 注入(模板 agent_env,如 AGENT_HTTP_ENABLED/HOST/PORT)
511- env = [
512- c.V1EnvVar(name=str(k), value=str(v))
513- for k, v in (spec.get("agent_env") or {}).items()
514- ] or None
515- # envFrom 引用注入(secretRef/configMapRef;None = 不设,历史行为不变)
516- env_from = _render_env_from(c, spec.get("agent_env_from"))
517- 
518- # 主容器 securityContext(有则设:run_as_user/run_as_group;无则不设,走镜像默认)
519- sec_kwargs: dict[str, Any] = {}
520- if spec.get("run_as_user") is not None:
521- sec_kwargs["run_as_user"] = int(spec["run_as_user"])
522- if spec.get("run_as_group") is not None:
523- sec_kwargs["run_as_group"] = int(spec["run_as_group"])
524- # 主容器启动命令/参数覆盖(模板可缺省,走镜像 ENTRYPOINT/CMD)
525- cmd_kwargs: dict[str, Any] = {}
526- if spec.get("command"):
527- cmd_kwargs["command"] = [str(x) for x in spec["command"]]
528- if spec.get("args"):
529- cmd_kwargs["args"] = [str(x) for x in spec["args"]]
530- container = c.V1Container(
531- name=spec.get("container_name") or "agent",
532- image=spec.get("agent_image") or "",
533- image_pull_policy=spec.get("image_pull_policy") or "IfNotPresent",
534- ports=ports,
535- env=env,
536- env_from=env_from,
537- volume_mounts=mounts or None,
538- resources=resources,
539- readiness_probe=probe,
540- **({"security_context": c.V1SecurityContext(**sec_kwargs)}
541- if sec_kwargs else {}),
542- **cmd_kwargs,
543- )
544- 
545- # ---- sidecar 容器(通用机制,规范形见 sidecars.py;无 sidecars 时零改动:
546- # annotations=None、containers=[container] 与历史逐字节一致)
547- annotations: dict[str, str] = {}
548- sidecar_containers: list[Any] = []
549 # pod_spec 可能来自 Redis pod_spec_json 缓存(旧版本写入/手改):529 # pod_spec 可能来自 Redis pod_spec_json 缓存(旧版本写入/手改):
550- # normalize 兜底坏项,但端口/容器名冲突 fail-fast(防 Pod 建出来530+ # normalize 补缺省键;shape 探测(缺 main_container = 旧扁平缓存)
551- # agent 经 127.0.0.1 连错进程)531+ # fail-fast,防渲染出空镜像 Pod
552- sidecars = normalize_sidecars(spec.get("sidecars"))532+ spec = normalize_pod_spec(spec)
553- if sidecars:533+ main = spec.get("main_container")
554- conflict = find_sidecar_conflict(534+ if not isinstance(main, dict):
555- sidecars,535+ raise DeployFailed(
556- spec.get("container_name") or "agent",536+ "pod spec has no main_container (legacy flat-form cache "
557- sse_port, container_port,537+ "written before the unified container canonical? re-push "
558- )538+ "config_sync/config_refresh first)")
559- if conflict:539+ sidecars = spec.get("sidecars") or []
560- raise DeployFailed(f"pod spec sidecars invalid: {conflict}")540+ # 容器名/端口冲突 fail-fast(防 Pod 建出来 agent 经 127.0.0.1 连错进程)
561- for idx, sc in enumerate(sidecars):541+ conflict = find_container_conflict(main, sidecars)
562- sc_container, sc_volumes, sc_annotations = (542+ if conflict:
563- self._build_sidecar_container(c, sc, idx, hp_seen=hp_seen,543+ raise DeployFailed(f"pod spec containers invalid: {conflict}")
564- cm_seen=cm_seen,544+ 
565- pvc_seen=pvc_seen,545+ # 跨容器共享卷登记簿:同源只建一个 Pod 级卷,主+sidecar 复用卷名
566- nfs_seen=nfs_seen))546+ hp_seen: dict[tuple[str, Any], str] = {} # 同 (path, type) 的 hostPath
567- sidecar_containers.append(sc_container)547+ cm_seen: dict[tuple, str] = {} # 同 (name, items) 的 ConfigMap
568- volumes.extend(sc_volumes)548+ pvc_seen: dict[str, str] = {} # 同 claim 的 PVC
569- annotations.update(sc_annotations)549+ nfs_seen: dict[tuple[str, str], str] = {} # 同 server+path 的 NFS
550+ container, volumes, annotations = self._build_container(
551+ c, main, role=MAIN_ROLE, idx=0, pod_id=pod_id, hp_seen=hp_seen,
552+ cm_seen=cm_seen, pvc_seen=pvc_seen, nfs_seen=nfs_seen)
553+ # ---- sidecar 容器(通用机制,canonical 见 containers.py;无 sidecars
554+ # 时零改动:annotations=None、containers=[main] 与历史逐字节一致)
555+ containers: list[Any] = [container]
556+ for idx, sc in enumerate(sidecars):
557+ sc_container, sc_volumes, sc_annotations = (
558+ self._build_container(c, sc, role=SIDECAR_ROLE, idx=idx,
559+ pod_id=pod_id, hp_seen=hp_seen,
560+ cm_seen=cm_seen, pvc_seen=pvc_seen,
561+ nfs_seen=nfs_seen))
562+ containers.append(sc_container)
563+ volumes.extend(sc_volumes)
564+ annotations.update(sc_annotations)
570 565 
571 # Pod 级 securityContext.fsGroup(模板级 wire 键 fsGroup;None = 不设,566 # Pod 级 securityContext.fsGroup(模板级 wire 键 fsGroup;None = 不设,
572 # kubelet 不做卷属主修正——NFS 卷属主问题的官方修法)567 # kubelet 不做卷属主修正——NFS 卷属主问题的官方修法)
@@ -581,13 +576,14 @@ class RealK8sPodClient(K8sPodClient):
581 metadata=c.V1ObjectMeta(name=pod_id, namespace=spec.get("namespace")576 metadata=c.V1ObjectMeta(name=pod_id, namespace=spec.get("namespace")
582 or self.default_namespace, labels=labels,577 or self.default_namespace, labels=labels,
583 annotations=annotations or None),578 annotations=annotations or None),
584- spec=c.V1PodSpec(containers=[container, *sidecar_containers],579+ spec=c.V1PodSpec(containers=containers,
585 restart_policy="Always",580 restart_policy="Always",
586 volumes=volumes or None,581 volumes=volumes or None,
587 node_name=(spec.get("node_name") or None),582 node_name=(spec.get("node_name") or None),
588 security_context=pod_security_context),583 security_context=pod_security_context),
589 )584 )
590 585 
586+ 
591 async def _wait_ready(self, pod_id: str, namespace: str,587 async def _wait_ready(self, pod_id: str, namespace: str,
592 timeout: float, poll: float) -> PodDeployInfo:588 timeout: float, poll: float) -> PodDeployInfo:
593 started = asyncio.get_running_loop().time()589 started = asyncio.get_running_loop().time()
@@ -17,6 +17,7 @@ import time
17from typing import Any17from typing import Any
18from uuid import uuid418from uuid import uuid4
19 19 
20+from ..containers import main_health_path, main_sse_port, normalize_pod_spec
20from ..errors import DeployFailed, MaxPodsReached21from ..errors import DeployFailed, MaxPodsReached
21from ..spec_fields import DEPLOY_VER_FIELDS22from ..spec_fields import DEPLOY_VER_FIELDS
22from ..util import fingerprint, now_ts23from ..util import fingerprint, now_ts
@@ -37,8 +38,13 @@ FOLLOWER_PROGRESS_LOG_SEC = 5 # follower 轮询进度 INFO 行间隔(限频
37 38 
38def _deploy_ver(pod_spec: dict[str, Any]) -> str:39def _deploy_ver(pod_spec: dict[str, Any]) -> str:
39 """pod_spec 的 deploy 子集指纹(与 SM Template.deploy_ver() 同一算法/字段,40 """pod_spec 的 deploy 子集指纹(与 SM Template.deploy_ver() 同一算法/字段,
40- 两端必须一致——A 类版本过滤依赖它)。kubeconfig 不入指纹(B 类例外)。"""41+ 两端必须一致——A 类版本过滤依赖它)。kubeconfig 不入指纹(B 类例外)。
41- return fingerprint({f: pod_spec.get(f) for f in DEPLOY_VER_FIELDS})42+ 
43+ 先过 normalize_pod_spec(只补 canonical 缺省键不改值):pod_spec 可能来自
44+ Redis pod_spec_json 缓存——未来加容器字段后旧缓存缺新键,补默认(==旧行为)
45+ 后与新算指纹相等 → 零伪 A 类日落;行为性新键应当日落,日落正确。"""
46+ norm = normalize_pod_spec(pod_spec)
47+ return fingerprint({f: norm.get(f) for f in DEPLOY_VER_FIELDS})
42 48 
43 49 
44class ResourceOrchestrator:50class ResourceOrchestrator:
@@ -288,9 +294,12 @@ class ResourceOrchestrator:
288 info: PodDeployInfo | None = None294 info: PodDeployInfo | None = None
289 try:295 try:
290 info = await self.k8s.deploy(pod_spec)296 info = await self.k8s.deploy(pod_spec)
297+ norm = normalize_pod_spec(pod_spec)
298+ main = norm.get("main_container")
299+ sse_port = main_sse_port(main)
291 sse_url = (300 sse_url = (
292- f"http://{info.pod_ip}:{pod_spec.get('sse_port', 8080)}"301+ f"http://{info.pod_ip}:{sse_port}"
293- f"{pod_spec.get('sse_path', '/sse')}"302+ f"{pod_spec.get('sse_path') or '/sse'}"
294 )303 )
295 await self.state.register_pod(304 await self.state.register_pod(
296 pod_id=info.pod_id,305 pod_id=info.pod_id,
@@ -302,8 +311,8 @@ class ResourceOrchestrator:
302 deploy_token=deploy_token,311 deploy_token=deploy_token,
303 idle_flag=idle_flag,312 idle_flag=idle_flag,
304 now=now_ts(),313 now=now_ts(),
305- sse_port=int(pod_spec.get("sse_port") or 8080),314+ sse_port=sse_port,
306- health_path=str(pod_spec.get("health_path") or "/health"),315+ health_path=main_health_path(main),
307 )316 )
308 except BaseException as exc: # noqa: BLE001 - 占位清理红线含取消路径317 except BaseException as exc: # noqa: BLE001 - 占位清理红线含取消路径
309 try:318 try:
@@ -20,6 +20,7 @@ import logging
20import time20import time
21from uuid import uuid421from uuid import uuid4
22 22 
23+from ..containers import main_health_path, main_sse_port
23from ..util import now_ts, to_int24from ..util import now_ts, to_int
24from .k8s import K8sPodClient25from .k8s import K8sPodClient
25from .models import DEAD_POD_STATUSES, POD_LABEL_SELECTOR26from .models import DEAD_POD_STATUSES, POD_LABEL_SELECTOR
@@ -133,7 +134,9 @@ class ResourceSweeper:
133 return "skip_max", (134 return "skip_max", (
134 f"warm={len(warm)} min_idle={min_idle} total={total} max_pods={max_pods}"135 f"warm={len(warm)} min_idle={min_idle} total={total} max_pods={max_pods}"
135 )136 )
136- # 热备 deploy 用缓存的 pod_spec(config_sync A 类变更后为新值)137+ # 热备 deploy 用缓存的 pod_spec(config_sync A 类变更后为新值)。
138+ # shape 探测:缺 main_container = 统一规范形前的旧扁平缓存(渲染会
139+ # 拿到空镜像)→ 跳过,下次 config_sync/config_refresh 覆写后收敛
137 try:140 try:
138 pod_spec = json.loads(cfg.get("pod_spec_json") or "{}")141 pod_spec = json.loads(cfg.get("pod_spec_json") or "{}")
139 except ValueError:142 except ValueError:
@@ -141,6 +144,11 @@ class ResourceSweeper:
141 return "skip_bad_spec", ""144 return "skip_bad_spec", ""
142 if not pod_spec:145 if not pod_spec:
143 return "skip_no_spec", ""146 return "skip_no_spec", ""
147+ if not isinstance(pod_spec.get("main_container"), dict):
148+ logger.warning(
149+ "autoscale: scope=%s pod_spec_json is legacy flat-form "
150+ "(no main_container), skip until re-pushed", scope_id)
151+ return "skip_legacy_spec", ""
144 deploy_ver = cfg.get("deploy_ver") or _deploy_ver(pod_spec)152 deploy_ver = cfg.get("deploy_ver") or _deploy_ver(pod_spec)
145 lock_key = self.state.k.lock_deploy(scope_id)153 lock_key = self.state.k.lock_deploy(scope_id)
146 lock_token = f"autoscale-{uuid4().hex}"154 lock_token = f"autoscale-{uuid4().hex}"
@@ -352,10 +360,14 @@ class ResourceSweeper:
352 pod_spec = json.loads(cfg.get("pod_spec_json") or "{}")360 pod_spec = json.loads(cfg.get("pod_spec_json") or "{}")
353 except ValueError:361 except ValueError:
354 return None362 return None
363+ # 统一规范形:主容器 ports 的 name=sse 项;legacy 扁平缓存 → None(回退)
364+ main = pod_spec.get("main_container")
365+ if isinstance(main, dict):
366+ return main_sse_port(main)
355 return to_int(pod_spec.get("sse_port")) or None367 return to_int(pod_spec.get("sse_port")) or None
356 368 
357 async def _scope_health_path(self, scope_id: str) -> str:369 async def _scope_health_path(self, scope_id: str) -> str:
358- """健康探测路径(与 readiness 同源,模板 health_path;缺省 /health)。"""370+ """健康探测路径(与 readiness 同源,主容器探针 path;缺省 /health)。"""
359 if not scope_id:371 if not scope_id:
360 return "/health"372 return "/health"
361 cfg = await self.state.load_scope_config(scope_id)373 cfg = await self.state.load_scope_config(scope_id)
@@ -363,6 +375,9 @@ class ResourceSweeper:
363 pod_spec = json.loads(cfg.get("pod_spec_json") or "{}")375 pod_spec = json.loads(cfg.get("pod_spec_json") or "{}")
364 except ValueError:376 except ValueError:
365 return "/health"377 return "/health"
378+ main = pod_spec.get("main_container")
379+ if isinstance(main, dict):
380+ return main_health_path(main)
366 return str(pod_spec.get("health_path") or "/health")381 return str(pod_spec.get("health_path") or "/health")
367 382 
368 # -------------------------------------------------------------- reconcile(L)383 # -------------------------------------------------------------- reconcile(L)
@@ -42,21 +42,20 @@ from openjiuwen_runtime.foundation.db.table_def import (
42 TableDefinition,42 TableDefinition,
43)43)
44 44 
45+from ..containers import validate_pod_containers
45from ..errors import ConfigNotFound, ConfigSyncBusy, InvalidParams46from ..errors import ConfigNotFound, ConfigSyncBusy, InvalidParams
46-from ..sidecars import validate_sidecars
47from ..util import key_unsafe, now_ts, parse_datetime, s, utc_now47from ..util import key_unsafe, now_ts, parse_datetime, s, utc_now
48from .container_spec import (48from .container_spec import (
49 MAIN_ROLE,49 MAIN_ROLE,
50 SIDECAR_ROLE,50 SIDECAR_ROLE,
51 CONTAINER_TABLE,51 CONTAINER_TABLE,
52- SERVICE_CONFIG_CONTAINER_TABLE_DEF,52+ SERVICE_CONFIG_CONTAINER_TABLE_DEF, # noqa: F401 - tests 经本模块复导出
53+ build_canonical,
53 canonical_volumes,54 canonical_volumes,
54 container_row_from_spec,55 container_row_from_spec,
55 container_spec_from_row,56 container_spec_from_row,
56- main_template_kwargs,
57 mounted_volume_names,57 mounted_volume_names,
58 parse_container_spec,58 parse_container_spec,
59- sidecar_wire_input,
60 volumes_from_column,59 volumes_from_column,
61 volumes_to_column,60 volumes_to_column,
62)61)
@@ -92,10 +91,10 @@ SERVICE_CONFIG_TEMPLATE_TABLE_DEF = TableDefinition(
92 ColumnDefinition("agent_image", "string", length=512, nullable=False),91 ColumnDefinition("agent_image", "string", length=512, nullable=False),
93 ColumnDefinition("namespace", "string", length=128, nullable=False, default="default"),92 ColumnDefinition("namespace", "string", length=128, nullable=False, default="default"),
94 ColumnDefinition("node_name", "string", length=128, nullable=True),93 ColumnDefinition("node_name", "string", length=128, nullable=True),
95- ColumnDefinition("run_as_user", "integer", nullable=True),
96- ColumnDefinition("run_as_group", "integer", nullable=True),
97 # Pod 级 securityContext.fsGroup(存量库需先手工 ALTER 补列)94 # Pod 级 securityContext.fsGroup(存量库需先手工 ALTER 补列)
98 ColumnDefinition("fs_group", "integer", nullable=True),95 ColumnDefinition("fs_group", "integer", nullable=True),
96+ ColumnDefinition("run_as_user", "integer", nullable=True),
97+ ColumnDefinition("run_as_group", "integer", nullable=True),
99 ColumnDefinition("pod_name", "string", length=128, nullable=False, default="agentserver"),98 ColumnDefinition("pod_name", "string", length=128, nullable=False, default="agentserver"),
100 ColumnDefinition("container_name", "string", length=128, nullable=False, default="agent"),99 ColumnDefinition("container_name", "string", length=128, nullable=False, default="agent"),
101 ColumnDefinition("container_port", "integer", nullable=False, default=8080),100 ColumnDefinition("container_port", "integer", nullable=False, default=8080),
@@ -164,41 +163,19 @@ ROUTING_SCOPE_TABLE_DEF = TableDefinition(
164 ],163 ],
165)164)
166 165 
167-# Template 字段 ↔ DB 列名(HLD 名 → EE 兼容列名)166+# Template 字段 ↔ DB 列名(模板级;容器级字段由容器表携带,legacy 扁平列已死值)
168_COLUMN_OF: dict[str, str] = {167_COLUMN_OF: dict[str, str] = {
169 "template_id": "template_id",168 "template_id": "template_id",
170 "template_name": "template_name",169 "template_name": "template_name",
171 "description": "description",170 "description": "description",
172- "agent_image": "agent_image",
173 "namespace": "namespace",171 "namespace": "namespace",
174 "node_name": "node_name",172 "node_name": "node_name",
175- "run_as_user": "run_as_user",
176- "run_as_group": "run_as_group",
177 "fs_group": "fs_group",173 "fs_group": "fs_group",
178 "pod_name": "pod_name",174 "pod_name": "pod_name",
179- "container_name": "container_name",
180- "container_port": "container_port",
181- "sse_port": "sse_port",
182 "sse_path": "sse_path",175 "sse_path": "sse_path",
183- "health_path": "health_path",
184- "agent_env": "agent_env",
185- "image_pull_policy": "image_pull_policy",
186 "kubeconfig": "kubeconfig",176 "kubeconfig": "kubeconfig",
187- "readiness_initial_delay": "readiness_initial_delay",
188- "readiness_period": "readiness_period",
189 "ready_timeout": "ready_timeout",177 "ready_timeout": "ready_timeout",
190 "ready_poll_interval": "ready_poll_interval",178 "ready_poll_interval": "ready_poll_interval",
191- "nfs_server": "nfs_server",
192- "nfs_path": "nfs_path",
193- "nfs_mount_path": "nfs_mount_path",
194- "agent_cpu_request": "agent_cpu_request",
195- "agent_memory_request": "agent_memory_request",
196- "agent_cpu_limit": "agent_cpu_limit",
197- "agent_memory_limit": "agent_memory_limit",
198- "sidecars": "sidecars",
199- "agent_host_path_mounts": "agent_host_path_mounts",
200- "agent_configmap_mounts": "agent_configmap_mounts",
201- "agent_pvc_mounts": "agent_pvc_mounts",
202 # 2026-09 起四列与 wire 术语同名(identity 映射;曾为 EE 兼容名179 # 2026-09 起四列与 wire 术语同名(identity 映射;曾为 EE 兼容名
203 # min_idle_services/service_concurrency/service_ttl/session_concurrency)。180 # min_idle_services/service_concurrency/service_ttl/session_concurrency)。
204 "min_idle_pods": "min_idle_pods",181 "min_idle_pods": "min_idle_pods",
@@ -212,16 +189,28 @@ _COLUMN_OF: dict[str, str] = {
212}189}
213 190 
214_INT_FIELDS = frozenset({191_INT_FIELDS = frozenset({
215- "container_port", "sse_port", "readiness_initial_delay", "readiness_period",
216 "ready_timeout", "ready_poll_interval", "min_idle_pods", "pod_concurrency",192 "ready_timeout", "ready_poll_interval", "min_idle_pods", "pod_concurrency",
217 "pod_ttl", "scope_concurrency", "session_ttl", "message_timeout",193 "pod_ttl", "scope_concurrency", "session_ttl", "message_timeout",
218- "run_as_user", "run_as_group", "fs_group",194+ "fs_group",
219})195})
220 196 
221-# 模板级字段(留在模板表;容器级 22 字段 + sidecars 由容器表水合,见197+# legacy 内联容器键(2026-08 拆表前平铺在模板上的字段;三段式 wire 独占后
222-# container_spec.main_template_kwargs / sidecar_wire_input)。三段式契约的198+# 只作为 mixed-400 检测的黑名单存在——出现在 template dict 即拒绝)
223-# template dict 只认这些键 + main_container_id/sidecar_container_ids/volumes;199+_LEGACY_INLINE_CONTAINER_KEYS = frozenset({
224-# 与 legacy 内联容器键并存 = mixed 形态 → 400。200+ "agent_image", "run_as_user", "run_as_group", "container_name",
201+ "container_port", "port_name", "sse_port", "health_path", "agent_env",
202+ "agent_env_from", "image_pull_policy", "readiness_initial_delay",
203+ "readiness_period", "nfs_server", "nfs_path", "nfs_mount_path",
204+ "agent_cpu_request", "agent_memory_request", "agent_cpu_limit",
205+ "agent_memory_limit", "sidecars", "agent_host_path_mounts",
206+ "agent_configmap_mounts", "agent_pvc_mounts", "agent_nfs_mounts",
207+ "command", "args",
208+})
209+ 
210+# 模板级字段(留在模板表;容器级由容器表水合为统一 canonical,见
211+# container_spec.build_canonical)。三段式契约的 template dict 只认这些键 +
212+# main_container_id/sidecar_container_ids/volumes;与 legacy 内联容器键
213+# 并存 = mixed 形态 → 400。
225TEMPLATE_LEVEL_FIELDS: tuple[str, ...] = (214TEMPLATE_LEVEL_FIELDS: tuple[str, ...] = (
226 "template_id", "template_name", "description", "enabled", "data",215 "template_id", "template_name", "description", "enabled", "data",
227 "namespace", "node_name", "fs_group", "pod_name", "sse_path",216 "namespace", "node_name", "fs_group", "pod_name", "sse_path",
@@ -233,7 +222,8 @@ _SPLIT_REFERENCE_KEYS = frozenset(
233 {"main_container_id", "sidecar_container_ids", "volumes"})222 {"main_container_id", "sidecar_container_ids", "volumes"})
234# 模板级 wire 键别名:K8s 派生字段用 K8s 拼写(nodeName);snake 双形态拒绝223# 模板级 wire 键别名:K8s 派生字段用 K8s 拼写(nodeName);snake 双形态拒绝
235# (防静默二义——两个拼写同时给不同值无法仲裁,fail-fast)224# (防静默二义——两个拼写同时给不同值无法仲裁,fail-fast)
236-_TEMPLATE_WIRE_ALIASES = {"node_name": "nodeName", "fs_group": "fsGroup"}225+_TEMPLATE_WIRE_ALIASES = {"node_name": "nodeName",
226+ "fs_group": "fsGroup"}
237 227 
238 228 
239def _scope_row(scope: RoutingScopeDef) -> dict[str, Any]:229def _scope_row(scope: RoutingScopeDef) -> dict[str, Any]:
@@ -251,40 +241,44 @@ def _scope_row(scope: RoutingScopeDef) -> dict[str, Any]:
251 }241 }
252 242 
253 243 
244+def _hydrate_containers(
245+ main_spec: dict[str, Any],
246+ sidecar_specs: list[dict[str, Any]],
247+ volumes: dict[str, dict[str, Any]],
248+ where: str,
249+) -> dict[str, Any]:
250+ """容器内部规范形 + 模板 volumes → ``{main_container, sidecars}``(canonical)。
251+ 
252+ 读路径(行水合)与写路径(载荷解析)共用的唯一水合出口:build_canonical
253+ ×2 → validate_pod_containers(≤8/重名/撞端口/挂载冲突)。
254+ """
255+ main = build_canonical(main_spec, volumes, where, role=MAIN_ROLE)
256+ sidecars = [build_canonical(spec, volumes, where, role=SIDECAR_ROLE)
257+ for spec in sidecar_specs]
258+ main, sidecars = validate_pod_containers(main, sidecars, where)
259+ return {"main_container": main, "sidecars": sidecars}
260+ 
261+ 
254def template_from_row(row: Any,262def template_from_row(row: Any,
255 containers: dict[str, dict[str, Any]] | None = None,263 containers: dict[str, dict[str, Any]] | None = None,
256 ) -> Template | None:264 ) -> Template | None:
257 """DB 行 → Template 业务对象(未命中 enabled=False 的模板仍返回,调用方判定)。265 """DB 行 → Template 业务对象(未命中 enabled=False 的模板仍返回,调用方判定)。
258 266 
259- 双形态:行有真值 ``main_container_id`` → 三段式新形态(模板级行列 +267+ 单轨水合(2026-09 起):模板级行列 + 容器引用 + volumes join → 统一
260- 容器行 + volumes join 水合;任一引用容器行缺失 → WARNING + None,268+ canonical。任一引用容器行缺失/水合校验失败 → WARNING + None(fail-closed,
261- 绝不静默丢单个 sidecar——那会隐形改 deploy_ver);否则 → legacy 内联269+ 绝不静默丢单个 sidecar——那会隐形改 deploy_ver)。**无 ``main_container_id``
262- 列路径(旧行,行为逐字节保留,``containers`` 被忽略)。270+ 的 legacy 内联行不再水合**(wire 已三段式独占,此类行 = 未收敛残骸,
271+ 升级前置检查见 docs/feature/2026-09-unified-container-canonical.md)。
263 """272 """
264 main_cid = getattr(row, "main_container_id", None)273 main_cid = getattr(row, "main_container_id", None)
265- if main_cid:
266- return _template_from_split_row(row, main_cid, containers or {})
267- kwargs: dict[str, Any] = {}
268- for field_name, column in _COLUMN_OF.items():
269- value = getattr(row, column, None)
270- if field_name in _INT_FIELDS and value is not None:
271- value = int(value)
272- kwargs[field_name] = value
273- # 老行/NULL 防御:agent_env 非 dict → 空表;health_path 空 → 默认;
274- # sidecars 坏值/空 → None 的兜底在 Template.__post_init__(normalize_sidecars)
275- if not isinstance(kwargs.get("agent_env"), dict):
276- kwargs["agent_env"] = {}
277- if not kwargs.get("health_path"):
278- kwargs["health_path"] = "/health"
279- return Template(**kwargs)
280- 
281- 
282-def _template_from_split_row(row: Any, main_cid: str,
283- containers: dict[str, dict[str, Any]],
284- ) -> Template | None:
285- """新形态行水合:模板级列 + 容器引用 + volumes join(损坏 fail-closed 跳过)。"""
286 tid = getattr(row, "template_id", "?")274 tid = getattr(row, "template_id", "?")
287- main_spec = containers.get(main_cid)275+ if not main_cid:
276+ logger.warning(
277+ "template %r has no main_container_id (legacy inline row, "
278+ "pre-2026-08 split), skipped -- re-send config_sync", tid,
279+ )
280+ return None
281+ main_spec = (containers or {}).get(main_cid)
288 if main_spec is None:282 if main_spec is None:
289 logger.warning(283 logger.warning(
290 "template %r references missing main container %r, skipped",284 "template %r references missing main container %r, skipped",
@@ -296,7 +290,7 @@ def _template_from_split_row(row: Any, main_cid: str,
296 sidecar_ids = []290 sidecar_ids = []
297 sidecar_specs = []291 sidecar_specs = []
298 for cid in sidecar_ids:292 for cid in sidecar_ids:
299- spec = containers.get(cid) if isinstance(cid, str) else None293+ spec = (containers or {}).get(cid) if isinstance(cid, str) else None
300 if spec is None:294 if spec is None:
301 logger.warning(295 logger.warning(
302 "template %r references missing sidecar container %r, skipped",296 "template %r references missing sidecar container %r, skipped",
@@ -315,17 +309,11 @@ def _template_from_split_row(row: Any, main_cid: str,
315 if not isinstance(kwargs.get("data"), dict):309 if not isinstance(kwargs.get("data"), dict):
316 kwargs["data"] = {}310 kwargs["data"] = {}
317 try:311 try:
318- kwargs.update(main_template_kwargs(main_spec, volumes, f"template {tid!r}"))312+ kwargs.update(_hydrate_containers(
319- kwargs["sidecars"] = validate_sidecars(313+ main_spec, sidecar_specs, volumes, f"template {tid!r}"))
320- [sidecar_wire_input(spec, volumes, f"template {tid!r}")
321- for spec in sidecar_specs],
322- container_name=str(kwargs.get("container_name") or "agent"),
323- sse_port=int(kwargs.get("sse_port") or 8080),
324- container_port=int(kwargs.get("container_port") or kwargs.get("sse_port") or 8080),
325- )
326 except InvalidParams:314 except InvalidParams:
327 logger.warning(315 logger.warning(
328- "template %r split-form hydration failed, skipped", tid,316+ "template %r container hydration failed, skipped", tid,
329 exc_info=True,317 exc_info=True,
330 )318 )
331 return None319 return None
@@ -366,8 +354,7 @@ def template_from_split_payload(
366 volumes join;mixed 形态(引用键与 legacy 内联容器键并存)→ 400。354 volumes join;mixed 形态(引用键与 legacy 内联容器键并存)→ 400。
367 返回卷映射供调用方落 volumes 列(volumes_to_column)。355 返回卷映射供调用方落 volumes 列(volumes_to_column)。
368 """356 """
369- inline = ({k for k in payload if k in _COLUMN_OF}357+ inline = {k for k in payload if k in _LEGACY_INLINE_CONTAINER_KEYS}
370- | {k for k in payload if k == "sidecars"}) - set(TEMPLATE_LEVEL_FIELDS)
371 if inline:358 if inline:
372 raise InvalidParams(359 raise InvalidParams(
373 f"template {template_id!r} mixes container references with inline "360 f"template {template_id!r} mixes container references with inline "
@@ -455,14 +442,7 @@ def template_from_split_payload(
455 )442 )
456 443 
457 where = f"template {template_id!r}"444 where = f"template {template_id!r}"
458- kwargs.update(main_template_kwargs(main_spec, volumes, where))445+ kwargs.update(_hydrate_containers(main_spec, sidecar_specs, volumes, where))
459- kwargs["sidecars"] = validate_sidecars(
460- [sidecar_wire_input(spec, volumes, where) for spec in sidecar_specs],
461- container_name=str(kwargs.get("container_name") or "agent"),
462- sse_port=int(kwargs.get("sse_port") or 8080),
463- container_port=int(kwargs.get("container_port")
464- or kwargs.get("sse_port") or 8080),
465- )
466 return Template(**kwargs), volumes446 return Template(**kwargs), volumes
467 447 
468 448 
@@ -552,9 +532,6 @@ def _validate_policy_fields(template_id: str, kwargs: dict[str, Any]) -> None:
552 value = kwargs.get(field)532 value = kwargs.get(field)
553 if isinstance(value, int) and value < minimum:533 if isinstance(value, int) and value < minimum:
554 problems.append(f"{field}={value} < {minimum}")534 problems.append(f"{field}={value} < {minimum}")
555- sse_port = kwargs.get("sse_port")
556- if isinstance(sse_port, int) and sse_port and not (1 <= sse_port <= 65535):
557- problems.append(f"sse_port={sse_port} out of range")
558 if problems:535 if problems:
559 raise InvalidParams(536 raise InvalidParams(
560 f"template {template_id!r} policy fields invalid: {'; '.join(problems)}"537 f"template {template_id!r} policy fields invalid: {'; '.join(problems)}"
@@ -567,13 +544,12 @@ _NODE_NAME_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$")
567 544 
568 545 
569def _validate_pod_placing_fields(template_id: str, kwargs: dict[str, Any]) -> None:546def _validate_pod_placing_fields(template_id: str, kwargs: dict[str, Any]) -> None:
570- """A 类容器身份/节点绑定字段(run_as_user/group、node_name)校验。547+ """节点绑定字段(node_name)校验(run_as 已随容器 canonical 校验)。
571 548 
572- 负 uid 与坏节点名都要到 K8s API 侧才失败(后者 Pod 永久 Pending 挂满549+ 坏节点名要到 K8s API 侧才失败(Pod 永久 Pending 挂满 ready_timeout,
573- ready_timeout,错误对下发方不可见)——提前到 config_sync 锁外,确定性 400。550+ 错误对下发方不可见)——提前到 config_sync 锁外,确定性 400。
574- 对齐 sidecars.py 对 sidecar run_as_user 的 minimum=0 先例。
575 """551 """
576- for field in ("run_as_user", "run_as_group", "fs_group"):552+ for field in ("fs_group",):
577 value = kwargs.get(field)553 value = kwargs.get(field)
578 if isinstance(value, int) and value < 0:554 if isinstance(value, int) and value < 0:
579 raise InvalidParams(555 raise InvalidParams(
@@ -611,13 +587,11 @@ class ConfigStore:
611 # -------------------------------------------------------------- 读路径587 # -------------------------------------------------------------- 读路径
612 588 
613 async def get_template(self, template_id: str) -> Template | None:589 async def get_template(self, template_id: str) -> Template | None:
614- """单模板水合(新形态行才取容器表;引用损坏返回 None,日志区分)。"""590+ """单模板水合(引用损坏/legacy 行返回 None,日志区分)。"""
615 row = await self._db.get(TEMPLATE_TABLE, {"template_id": template_id})591 row = await self._db.get(TEMPLATE_TABLE, {"template_id": template_id})
616 if row is None:592 if row is None:
617 return None593 return None
618- if getattr(row, "main_container_id", None):594+ return template_from_row(row, await self._all_containers())
619- return template_from_row(row, await self._all_containers())
620- return template_from_row(row)
621 595 
622 async def list_templates(self, limit: int = 200) -> list[dict[str, Any]]:596 async def list_templates(self, limit: int = 200) -> list[dict[str, Any]]:
623 """诊断只读:模板摘要(HLD 字段名;kubeconfig 等敏感列由 /visualization 层脱敏)。"""597 """诊断只读:模板摘要(HLD 字段名;kubeconfig 等敏感列由 /visualization 层脱敏)。"""
@@ -26,23 +26,32 @@ from openjiuwen_runtime.foundation.db.table_def import (
26 TableDefinition,26 TableDefinition,
27)27)
28 28 
29+from ..containers import (
30+ CONTAINER_NAME_RE,
31+ DEFAULT_IMAGE_PULL_POLICY,
32+ canonical_env_from,
33+ DEFAULT_MAIN_NAME,
34+ DEFAULT_SSE_PORT,
35+ MAIN_PROBE_DEFAULT,
36+ RESOURCES_DEFAULT,
37+ SECCTX_DEFAULT,
38+ SIDECAR_PROBE_DEFAULT,
39+ MAIN_ROLE,
40+ SIDECAR_ROLE,
41+ canonical_container,
42+)
29from ..errors import InvalidParams43from ..errors import InvalidParams
30from ..mounts import (44from ..mounts import (
31 canonical_configmap_mounts,45 canonical_configmap_mounts,
32 canonical_host_path_mounts,46 canonical_host_path_mounts,
33 canonical_pvc_mounts,47 canonical_pvc_mounts,
34- validate_agent_mounts,48+ find_mount_path_conflicts,
35-)
36-from ..sidecars import (
37- SIDECAR_NAME_RE,
38- canonical_env_from,
39)49)
40 50 
51+ 
41CONTAINER_TABLE = "service_config_container"52CONTAINER_TABLE = "service_config_container"
42 53 
43CONTAINER_ID_MAX = 10054CONTAINER_ID_MAX = 100
44-MAIN_ROLE = "main"
45-SIDECAR_ROLE = "sidecar"
46 55 
47# 容器表:标量列 + 段落 JSON 列(内容为本模块产出的内部规范形,snake 键)。56# 容器表:标量列 + 段落 JSON 列(内容为本模块产出的内部规范形,snake 键)。
48# 新表由框架 init_table 自动建(create_all),无需手工 DDL。57# 新表由框架 init_table 自动建(create_all),无需手工 DDL。
@@ -90,11 +99,10 @@ _PROBE_KEYS = frozenset(
90 "timeoutSeconds"})99 "timeoutSeconds"})
91_HTTP_GET_KEYS = frozenset({"path", "port"})100_HTTP_GET_KEYS = frozenset({"path", "port"})
92_TCP_SOCKET_KEYS = frozenset({"port"})101_TCP_SOCKET_KEYS = frozenset({"port"})
93-_MAIN_SECCTX_KEYS = frozenset({"runAsUser", "runAsGroup"})
94-_SIDECAR_SECCTX_EXTRA = {
95- "privileged", "capabilities", "seccompProfile", "appArmorProfile",
96-}
97_SECCTX_PROFILE_TYPES = {"Unconfined": True, "RuntimeDefault": False}102_SECCTX_PROFILE_TYPES = {"Unconfined": True, "RuntimeDefault": False}
103+_SECCTX_WIRE_KEYS = frozenset({
104+ "runAsUser", "runAsGroup", "privileged", "capabilities",
105+ "seccompProfile", "appArmorProfile"})
98 106 
99# 模板级 volumes:K8s 卷源键 → 内部 kind107# 模板级 volumes:K8s 卷源键 → 内部 kind
100_VOLUME_WIRE_SOURCES = ("hostPath", "configMap", "persistentVolumeClaim", "nfs")108_VOLUME_WIRE_SOURCES = ("hostPath", "configMap", "persistentVolumeClaim", "nfs")
@@ -132,7 +140,7 @@ def _parse_ports(value: Any, where: str, role: str) -> Optional[list[dict]]:
132 """140 """
133 if value is None:141 if value is None:
134 if role == MAIN_ROLE:142 if role == MAIN_ROLE:
135- return [{"name": "sse", "container_port": 8080}]143+ return [{"name": "sse", "container_port": DEFAULT_SSE_PORT}]
136 return None144 return None
137 if not isinstance(value, list):145 if not isinstance(value, list):
138 raise InvalidParams(146 raise InvalidParams(
@@ -257,10 +265,7 @@ def _parse_env_from(value: Any, where: str) -> Optional[list[dict]]:
257 265 
258def _parse_resources(value: Any, where: str) -> dict[str, Optional[str]]:266def _parse_resources(value: Any, where: str) -> dict[str, Optional[str]]:
259 """resources(K8s 嵌套)→ 内部扁平四字段(与 Template/sidecar 同名)。"""267 """resources(K8s 嵌套)→ 内部扁平四字段(与 Template/sidecar 同名)。"""
260- out: dict[str, Optional[str]] = {268+ out: dict[str, Optional[str]] = dict(RESOURCES_DEFAULT)
261- "cpu_request": None, "memory_request": None,
262- "cpu_limit": None, "memory_limit": None,
263- }
264 if value is None:269 if value is None:
265 return out270 return out
266 if not isinstance(value, dict):271 if not isinstance(value, dict):
@@ -355,29 +360,19 @@ def _parse_str_list(value: Any, where: str, key: str) -> list[str] | None:
355 return value or None360 return value or None
356 361 
357 362 
358-def _parse_security_context(value: Any, where: str,363+def _parse_security_context(value: Any, where: str) -> dict[str, Any]:
359- role: str) -> dict[str, Any]:364+ """securityContext → 内部八键规范形(主/sidecar 同一白名单,决策 B)。"""
360- """securityContext → 内部八键规范形(主容器仅 runAs 两键合法,越角色 400)。"""365+ out: dict[str, Any] = dict(SECCTX_DEFAULT)
361- out: dict[str, Any] = {
362- "run_as_user": None, "run_as_group": None,
363- "privileged": False, "capabilities_add": [], "capabilities_drop": [],
364- "seccomp_unconfined": False, "apparmor_unconfined": False,
365- }
366 if value is None:366 if value is None:
367 return out367 return out
368 if not isinstance(value, dict):368 if not isinstance(value, dict):
369 raise InvalidParams(369 raise InvalidParams(
370 f"{where}.securityContext must be an object, got {value!r}")370 f"{where}.securityContext must be an object, got {value!r}")
371- allowed = _MAIN_SECCTX_KEYS | (371+ unknown = set(value) - _SECCTX_WIRE_KEYS
372- _SIDECAR_SECCTX_EXTRA if role == SIDECAR_ROLE else set())
373- unknown = set(value) - allowed
374 if unknown:372 if unknown:
375- role_note = ("only runAsUser/runAsGroup are allowed on the main "
376- "container" if role == MAIN_ROLE else "")
377 raise InvalidParams(373 raise InvalidParams(
378 f"{where}.securityContext unknown keys {sorted(unknown)}; "374 f"{where}.securityContext unknown keys {sorted(unknown)}; "
379- f"allowed: {sorted(allowed)}"375+ f"allowed: {sorted(_SECCTX_WIRE_KEYS)}")
380- + (f" ({role_note})" if role_note else ""))
381 for wire_key, out_key in (("runAsUser", "run_as_user"),376 for wire_key, out_key in (("runAsUser", "run_as_user"),
382 ("runAsGroup", "run_as_group")):377 ("runAsGroup", "run_as_group")):
383 if value.get(wire_key) is not None:378 if value.get(wire_key) is not None:
@@ -437,11 +432,9 @@ def _parse_readiness_probe(value: Any, where: str, role: str,
437 探针 port 若给必须等于容器端口(主容器 = sse 端口)。432 探针 port 若给必须等于容器端口(主容器 = sse 端口)。
438 """433 """
439 if role == MAIN_ROLE:434 if role == MAIN_ROLE:
440- out = {"probe_type": "http", "path": "/health",435+ out = dict(MAIN_PROBE_DEFAULT)
441- "initial_delay": 5, "period": 5, "timeout": None}
442 else:436 else:
443- out = {"probe_type": None, "path": "/health",437+ out = dict(SIDECAR_PROBE_DEFAULT)
444- "initial_delay": 5, "period": 10, "timeout": 3}
445 if value is None:438 if value is None:
446 return out439 return out
447 if not isinstance(value, dict):440 if not isinstance(value, dict):
@@ -533,13 +526,14 @@ def parse_container_spec(item: Any, where: str, *, role: str) -> dict[str, Any]:
533 f"{CONTAINER_ID_MAX} chars, got {container_id!r}")526 f"{CONTAINER_ID_MAX} chars, got {container_id!r}")
534 name = item.get("name")527 name = item.get("name")
535 if role == MAIN_ROLE and name is None:528 if role == MAIN_ROLE and name is None:
536- name = "agent" # Template.container_name 默认529+ name = DEFAULT_MAIN_NAME # Template.container_name 默认
537- if not isinstance(name, str) or not SIDECAR_NAME_RE.match(name):530+ if not isinstance(name, str) or not CONTAINER_NAME_RE.match(name):
538 raise InvalidParams(531 raise InvalidParams(
539 f"{where}.name {name!r} must be a DNS-1123 label (lowercase "532 f"{where}.name {name!r} must be a DNS-1123 label (lowercase "
540 "alphanumeric or '-'), max 63 chars")533 "alphanumeric or '-'), max 63 chars")
541 image = _nonempty_str(item.get("image"), where, "image", max_len=512)534 image = _nonempty_str(item.get("image"), where, "image", max_len=512)
542- image_pull_policy = item.get("imagePullPolicy") or "IfNotPresent"535+ image_pull_policy = (
536+ item.get("imagePullPolicy") or DEFAULT_IMAGE_PULL_POLICY)
543 if not isinstance(image_pull_policy, str) or not image_pull_policy.strip():537 if not isinstance(image_pull_policy, str) or not image_pull_policy.strip():
544 raise InvalidParams(538 raise InvalidParams(
545 f"{where}.imagePullPolicy must be a non-empty string, "539 f"{where}.imagePullPolicy must be a non-empty string, "
@@ -550,7 +544,7 @@ def parse_container_spec(item: Any, where: str, *, role: str) -> dict[str, Any]:
550 resources = _parse_resources(item.get("resources"), where)544 resources = _parse_resources(item.get("resources"), where)
551 volume_mounts = _parse_volume_mounts(item.get("volumeMounts"), where)545 volume_mounts = _parse_volume_mounts(item.get("volumeMounts"), where)
552 security_context = _parse_security_context(546 security_context = _parse_security_context(
553- item.get("securityContext"), where, role)547+ item.get("securityContext"), where)
554 command = _parse_str_list(item.get("command"), where, "command")548 command = _parse_str_list(item.get("command"), where, "command")
555 args = _parse_str_list(item.get("args"), where, "args")549 args = _parse_str_list(item.get("args"), where, "args")
556 readiness_probe = _parse_readiness_probe(550 readiness_probe = _parse_readiness_probe(
@@ -598,7 +592,7 @@ def canonical_volumes(value: Any, where: str) -> dict[str, dict[str, Any]]:
598 f"{entry_where} must have exactly one volume source among "592 f"{entry_where} must have exactly one volume source among "
599 f"{list(_VOLUME_WIRE_SOURCES)}, got {sorted(entry)!r}")593 f"{list(_VOLUME_WIRE_SOURCES)}, got {sorted(entry)!r}")
600 name = entry.get("name")594 name = entry.get("name")
601- if not isinstance(name, str) or not SIDECAR_NAME_RE.match(name):595+ if not isinstance(name, str) or not CONTAINER_NAME_RE.match(name):
602 raise InvalidParams(596 raise InvalidParams(
603 f"{entry_where}.name {name!r} must be a DNS-1123 label, "597 f"{entry_where}.name {name!r} must be a DNS-1123 label, "
604 "max 63 chars")598 "max 63 chars")
@@ -718,106 +712,36 @@ def fuse_mounts(spec: dict[str, Any], volumes: dict[str, dict[str, Any]],
718 "pvc_mounts": pvc, "nfs_mounts": nfs}712 "pvc_mounts": pvc, "nfs_mounts": nfs}
719 713 
720 714 
721-# -------------------------------------------------------------- 投影:内部规范形 → Template/sidecar715+# -------------------------------------------------------------- 投影:内部规范形 → canonical/旧形
722 716 
723-def _sse_port(spec: dict[str, Any]) -> int:717+def build_canonical(spec: dict[str, Any],
724- return spec["ports"][0]["container_port"]718+ volumes: dict[str, dict[str, Any]],
719+ where: str, *, role: str) -> dict[str, Any]:
720+ """容器内部规范形(14 键 unfused)+ 模板 volumes → canonical(15 键)。
725 721 
726- 722+ fuse_mounts(join 卷,四挂载族)组装 canonical 输入,交
727-def _http_port(spec: dict[str, Any]) -> int:723+ containers.canonical_container 幂等收口(默认填满/挂载排序/校验)。
728- """主容器 http 端口;无则 = sse 端口(RM 渲染同名端口的既有约定)。"""724+ 产物 = Template.main_container / sidecars 元素 / pod_spec 容器段
729- if len(spec["ports"]) > 1:725+ (同一直径,指纹/传输/渲染三处同形)。
730- return spec["ports"][1]["container_port"]
731- return spec["ports"][0]["container_port"]
732- 
733- 
734-def main_template_kwargs(spec: dict[str, Any],
735- volumes: dict[str, dict[str, Any]],
736- where: str) -> dict[str, Any]:
737- """主容器内部规范形(+模板 volumes join)→ Template 容器级 kwargs。
738- 
739- 与 Template 默认逐项对齐(缺省落定不漂指纹);挂载经
740- validate_agent_mounts 规范化 + 冲突检查(四类挂载 mount_path 互斥)。
741 """726 """
742- fused = fuse_mounts(spec, volumes, where, MAIN_ROLE)727+ fused = fuse_mounts(spec, volumes, where, role)
743- (host, cm, pvc, nfs) = validate_agent_mounts(728+ return canonical_container({
744- fused["host_path_mounts"] or None,
745- fused["configmap_mounts"] or None,
746- fused["pvc_mounts"] or None,
747- fused["nfs_mounts"] or None,
748- )
749- secctx = spec["security_context"]
750- probe = spec["readiness_probe"]
751- resources = spec["resources"]
752- return {
753- "container_name": spec["name"],
754- "agent_image": spec["image"],
755- "image_pull_policy": spec["image_pull_policy"],
756- "sse_port": _sse_port(spec),
757- "container_port": _http_port(spec),
758- "agent_env": spec["env"],
759- "agent_env_from": spec["env_from"],
760- "agent_cpu_request": resources["cpu_request"],
761- "agent_memory_request": resources["memory_request"],
762- "agent_cpu_limit": resources["cpu_limit"],
763- "agent_memory_limit": resources["memory_limit"],
764- "run_as_user": secctx["run_as_user"],
765- "run_as_group": secctx["run_as_group"],
766- "command": spec["command"],
767- "args": spec["args"],
768- "health_path": probe["path"],
769- "readiness_initial_delay": probe["initial_delay"],
770- "readiness_period": probe["period"],
771- "agent_host_path_mounts": host,
772- "agent_configmap_mounts": cm,
773- "agent_pvc_mounts": pvc,
774- "agent_nfs_mounts": nfs,
775- }
776- 
777- 
778-def sidecar_wire_input(spec: dict[str, Any],
779- volumes: dict[str, dict[str, Any]],
780- where: str) -> dict[str, Any]:
781- """sidecar 内部规范形(+模板 volumes join)→ sidecars.py 校验输入形态。
782- 
783- 产物交 validate_sidecars(幂等再规范化 + ≤8/重名/撞端口/挂载冲突);
784- 挂载列表给 raw 条目(canonical_* 在其中兜全量校验与排序)。
785- NFS 与 PVC 同构:sidecar 经 ``nfs_mounts`` 列表按名挂载模板级 NFS 卷,
786- 与主容器无耦合(_canonical_sidecar 内条件键:空列表省略,存量指纹零扰动)。
787- """
788- fused = fuse_mounts(spec, volumes, where, SIDECAR_ROLE)
789- secctx = spec["security_context"]
790- probe = spec["readiness_probe"]
791- resources = spec["resources"]
792- return {
793 "name": spec["name"],729 "name": spec["name"],
794 "image": spec["image"],730 "image": spec["image"],
795- "port": (spec["ports"][0]["container_port"]731+ "image_pull_policy": spec["image_pull_policy"],
796- if spec["ports"] else None),732+ "command": spec["command"],
733+ "args": spec["args"],
734+ "ports": spec["ports"],
797 "env": spec["env"],735 "env": spec["env"],
798 "env_from": spec["env_from"],736 "env_from": spec["env_from"],
799- "image_pull_policy": spec["image_pull_policy"],737+ "resources": spec["resources"],
800- "cpu_request": resources["cpu_request"],
801- "memory_request": resources["memory_request"],
802- "cpu_limit": resources["cpu_limit"],
803- "memory_limit": resources["memory_limit"],
804- "privileged": secctx["privileged"],
805- "capabilities_add": secctx["capabilities_add"],
806- "capabilities_drop": secctx["capabilities_drop"],
807- "seccomp_unconfined": secctx["seccomp_unconfined"],
808- "apparmor_unconfined": secctx["apparmor_unconfined"],
809- "run_as_user": secctx["run_as_user"],
810- "run_as_group": secctx["run_as_group"],
811 "host_path_mounts": fused["host_path_mounts"],738 "host_path_mounts": fused["host_path_mounts"],
812 "configmap_mounts": fused["configmap_mounts"],739 "configmap_mounts": fused["configmap_mounts"],
813 "pvc_mounts": fused["pvc_mounts"],740 "pvc_mounts": fused["pvc_mounts"],
814 "nfs_mounts": fused["nfs_mounts"],741 "nfs_mounts": fused["nfs_mounts"],
815- "readiness_probe_type": probe["probe_type"],742+ "security_context": spec["security_context"],
816- "readiness_path": probe["path"],743+ "readiness_probe": spec["readiness_probe"],
817- "readiness_initial_delay": probe["initial_delay"],744+ }, where, role=role)
818- "readiness_period": probe["period"],
819- "readiness_timeout_seconds": probe["timeout"],
820- }
821 745 
822 746 
823# -------------------------------------------------------------- volumes 列存取747# -------------------------------------------------------------- volumes 列存取
@@ -5,6 +5,10 @@ template 字段定义见 HLD §3.1「数据结构定义」。DB 列名与 wire
5(2026-09 起统一;曾用 EE 兼容名 session_concurrency/service_concurrency/5(2026-09 起统一;曾用 EE 兼容名 session_concurrency/service_concurrency/
6service_ttl/min_idle_services,存量库须 RENAME COLUMN)。6service_ttl/min_idle_services,存量库须 RENAME COLUMN)。
7 7 
8+2026-09 统一规范形:容器级配置(主/sidecar)以 containers.py canonical
9+(dict,13 键全填满)携带——``main_container`` 单容器 + ``sidecars`` 列表,
10+不再平铺 ~23 个 agent_* 字段(历史包袱,加字段要改六处的根因)。
11+ 
8scope 定义(scope_id/index/引用模板/路由规则集)由 config_sync 全量下发,12scope 定义(scope_id/index/引用模板/路由规则集)由 config_sync 全量下发,
9见 ``routing.py``(RoutingScopeDef)与 ``routing_scope`` 表——不再由13见 ``routing.py``(RoutingScopeDef)与 ``routing_scope`` 表——不再由
10(group_id, bot_id) 二元组派生。14(group_id, bot_id) 二元组派生。
@@ -16,8 +20,14 @@ import math
16from dataclasses import dataclass, field20from dataclasses import dataclass, field
17from typing import Any21from typing import Any
18 22 
19-from ..mounts import normalize_mounts23+from ..containers import (
20-from ..sidecars import normalize_sidecars24+ MAIN_ROLE,
25+ default_main_container,
26+ main_health_path,
27+ main_sse_port,
28+ normalize_container,
29+ normalize_containers,
30+)
21from ..spec_fields import DEPLOY_VER_FIELDS, POLICY_FIELDS # noqa: F401 - 字段分类定义31from ..spec_fields import DEPLOY_VER_FIELDS, POLICY_FIELDS # noqa: F401 - 字段分类定义
22from ..util import fingerprint32from ..util import fingerprint
23 33 
@@ -33,56 +43,22 @@ class Template:
33 session_ttl: int = 6043 session_ttl: int = 60
34 pod_ttl: int = 30044 pod_ttl: int = 300
35 min_idle_pods: int = 045 min_idle_pods: int = 0
36- # deploy 子集(A 类)46+ # deploy 子集(A 类)——Pod 级字段
37- agent_image: str = ""
38 namespace: str = "default"47 namespace: str = "default"
39 node_name: str | None = None48 node_name: str | None = None
40- run_as_user: int | None = None49+ # Pod 级 securityContext.fsGroup(wire 键 fsGroup,与 nodeName 同款模板级;
41- run_as_group: int | None = None50+ # kubelet 卷属主修正——NFS 卷属主问题的官方修法;None = 不设)
42- # Pod 级 securityContext.fsGroup(wire 键 fsGroup,与 nodeName 同款拍平;
43- # 运行时落到 Pod securityContext;None = 不设)
44 fs_group: int | None = None51 fs_group: int | None = None
45- # 主容器启动命令/参数覆盖(缺省走镜像 ENTRYPOINT/CMD;None = 不设)
46- command: list[str] | None = None
47- args: list[str] | None = None
48 pod_name: str = "agentserver" # Pod 名前缀(pod_id = 前缀-随机后缀)52 pod_name: str = "agentserver" # Pod 名前缀(pod_id = 前缀-随机后缀)
49- container_name: str = "agent"
50- container_port: int = 8080
51- sse_port: int = 8080 # gateway 直连 Pod 的 SSE 端口
52 sse_path: str = "/sse"53 sse_path: str = "/sse"
53- health_path: str = "/health" # readiness 探针路径(真 AgentServer HTTP 入口为 /api/v1/health)
54- agent_env: dict[str, str] = field(default_factory=dict) # Agent 容器 env 注入(AGENT_HTTP_* 等)
55- # envFrom 引用(K8s EnvFromSource 内部规范形:[{prefix?, secret_ref|config_map_ref:
56- # {name, optional}}])。缺省 None 被 fingerprint 滤除 → 存量模板指纹零扰动;
57- # 值变化 = 正确的 A 类日落(env 烘焙进 Pod)。仅新契约(config_sync containers
58- # 形态)可下发;legacy 内联 payload 不接此字段。
59- agent_env_from: list[dict[str, Any]] | None = None
60- image_pull_policy: str = "IfNotPresent"
61- readiness_initial_delay: int = 5
62- readiness_period: int = 5
63 ready_timeout: int = 300 # deploy 等 Ready 的超时(秒)54 ready_timeout: int = 300 # deploy 等 Ready 的超时(秒)
64 ready_poll_interval: int = 255 ready_poll_interval: int = 2
65- # NFS 三元组:legacy 内联行的只读兼容载体(新契约不下发——NFS 卷与 PVC56+ # 主容器(canonical,见 containers.py;default = 空镜像哨兵,与旧
66- # 同构,卷源在模板级 volumes、挂载在 agent_nfs_mounts/sidecar nfs_mounts;57+ # agent_image="" 同语义——未配置模板渲染空镜像,由上层拒绝/覆盖)
67- # __post_init__ 把旧行三元组转成 agent_nfs_mounts,见下)58+ main_container: dict[str, Any] = field(default_factory=default_main_container)
68- nfs_server: str | None = None59+ # 同 Pod sidecar 容器列表(canonical;None 与 [] 统一归一为 None——
69- nfs_path: str | None = None60+ # fingerprint 只滤 None,[] 会扰动指纹)
70- nfs_mount_path: str | None = None
71- agent_cpu_request: str | None = None
72- agent_memory_request: str | None = None
73- agent_cpu_limit: str | None = None
74- agent_memory_limit: str | None = None
75- # 同 Pod sidecar 容器列表(A 类字段,通用机制,jiuwenbox 是第一个使用者;
76- # 规范形与校验见 sidecars.py)。None 与 [] 统一归一为 None——fingerprint
77- # 只滤 None,若以 [] 为默认会使全部存量模板 deploy_ver 变化 → 全量 A 类
78- # 日落,不可接受(见 __post_init__ 的 normalize_sidecars)。
79 sidecars: list[dict[str, Any]] | None = None61 sidecars: list[dict[str, Any]] | None = None
80- # 主 agent 容器卷挂载(A 类;规范形与校验见 mounts.py,与 sidecar 挂载同款;
81- # 空列表/坏值同样归一为 None 保指纹稳定)
82- agent_host_path_mounts: list[dict[str, Any]] | None = None
83- agent_configmap_mounts: list[dict[str, Any]] | None = None
84- agent_pvc_mounts: list[dict[str, Any]] | None = None
85- agent_nfs_mounts: list[dict[str, Any]] | None = None
86 # deploy 凭证(B 类例外:只影响新 deploy,不日落)62 # deploy 凭证(B 类例外:只影响新 deploy,不日落)
87 kubeconfig: str | None = None63 kubeconfig: str | None = None
88 # 元信息64 # 元信息
@@ -93,30 +69,38 @@ class Template:
93 data: dict[str, Any] = field(default_factory=dict)69 data: dict[str, Any] = field(default_factory=dict)
94 70 
95 def __post_init__(self) -> None:71 def __post_init__(self) -> None:
96- # 空列表/坏值 → None;逐项规范形 + 排序(指纹/DB/快照三处形态唯一)。72+ # 规范形收敛:payload/DB 行/快照 JSON/测试手搓全部构造路径统一于此
97- # payload/DB 行/快照 JSON/测试手搓全部构造路径收敛于此。73+ # (指纹/DB/快照三处形态唯一;canonical 幂等 → 显式默认 == 省略)。
98- object.__setattr__(self, "sidecars", normalize_sidecars(self.sidecars))74+ object.__setattr__(self, "main_container",
99- for field in ("agent_host_path_mounts", "agent_configmap_mounts",75+ normalize_container(self.main_container,
100- "agent_pvc_mounts", "agent_nfs_mounts"):76+ role=MAIN_ROLE))
101- kind = field.replace("agent_", "", 1)77+ object.__setattr__(self, "sidecars", normalize_containers(self.sidecars))
102- object.__setattr__(self, field, normalize_mounts(getattr(self, field), kind))
103- # legacy 内联三元组 → agent_nfs_mounts(旧行读兼容;RM 只认列表形态)。
104- # mount_path 缺省 "/data" 沿旧 RM 缺省(nfs_server 有值而挂载点未给时
105- # 历史上挂 /data);新契约不受影响——三元组列对三段式行恒为 NULL。
106- if self.nfs_server and not self.agent_nfs_mounts:
107- object.__setattr__(self, "agent_nfs_mounts", normalize_mounts(
108- [{"server": self.nfs_server, "path": self.nfs_path,
109- "mount_path": self.nfs_mount_path or "/data",
110- "read_only": False}], "nfs_mounts"))
111 # 路径字段归一:缺前导 '/' 的值会拼出 "http://ip:8080api/..."(端口段78 # 路径字段归一:缺前导 '/' 的值会拼出 "http://ip:8080api/..."(端口段
112 # 粘连路径,httpx 直接抛非法端口 → 健康 Pod 被探死无限重部署)79 # 粘连路径,httpx 直接抛非法端口 → 健康 Pod 被探死无限重部署)
113- for field in ("sse_path", "health_path"):80+ value = self.sse_path or ""
114- value = getattr(self, field) or ""81+ if value and not value.startswith("/"):
115- if value and not value.startswith("/"):82+ object.__setattr__(self, "sse_path", f"/{value}")
116- object.__setattr__(self, field, f"/{value}")83+ 
117- # envFrom 空列表归一 None(指纹滤 None;[] 不入库不入快照)84+ # -------------------------------------------------------------- 兼容只读派生
118- if self.agent_env_from == []:85+ 
119- object.__setattr__(self, "agent_env_from", None)86+ @property
87+ def agent_image(self) -> str:
88+ """主容器镜像(UI/诊断摘要兼容键;不参与指纹与序列化)。"""
89+ return str(self.main_container.get("image") or "")
90+ 
91+ @property
92+ def sse_port(self) -> int:
93+ """gateway 直连 Pod 的 SSE 端口(canonical main ports 的 name=sse 项)。"""
94+ return main_sse_port(self.main_container)
95+ 
96+ @property
97+ def health_path(self) -> str:
98+ """readiness/健康探测路径(与主容器探针同源)。"""
99+ return main_health_path(self.main_container)
100+ 
101+ @property
102+ def container_name(self) -> str:
103+ return str(self.main_container.get("name") or "agent")
120 104 
121 # -------------------------------------------------------------- 派生105 # -------------------------------------------------------------- 派生
122 106 
@@ -422,7 +422,17 @@ def template_to_json(template: Template) -> dict[str, Any]:
422 422 
423 423 
424def template_from_json(payload: dict[str, Any]) -> Template:424def template_from_json(payload: dict[str, Any]) -> Template:
425- """快照 dict → Template;未知键忽略,int/bool 字段按默认值类型矫正。"""425+ """快照 dict → Template;未知键忽略,int/bool 字段按默认值类型矫正。
426+ 
427+ legacy 扁平快照(统一规范形前写入:有 agent_image 无 main_container)
428+ → ValueError:判坏重建(lifespan ensure_snapshot 本就无条件重建,
429+ 这是冷读路径的双保险)。
430+ """
431+ if "main_container" not in payload and "agent_image" in payload:
432+ raise ValueError(
433+ "legacy flat-form template snapshot (pre unified-canonical); "
434+ "rebuild required"
435+ )
426 kwargs: dict[str, Any] = {}436 kwargs: dict[str, Any] = {}
427 for name in _TEMPLATE_FIELDS:437 for name in _TEMPLATE_FIELDS:
428 if name not in payload or payload[name] is None:438 if name not in payload or payload[name] is None:
@@ -1,378 +0,0 @@
1-# coding: utf-8
2-"""template.sidecars —— 同 Pod sidecar 容器规格(SM 校验/归一 + RM 渲染兜底共享)。
3- 
4-通用 sidecar 列表(单 JSON DB 列 ``sidecars``),每项一个容器规格 dict;
5-jiuwenbox 是第一个使用者(与主 agent 容器同 Pod、共享网络命名空间,
6-agent 经 127.0.0.1:port 访问)。SM 与 RM 共用本模块,不引入 SM↔RM 相互 import
7-(与 spec_fields.py 同款的顶层共享先例)。
8- 
9-指纹不变式(★):规范形 = 每项填满全部默认键 + 列表按 name 升序。
10-「显式给默认值」与「省略键」、「下发顺序重排」、「DB JSON 列键序重排」
11-必须产生同一 deploy_ver,否则会造成伪 A 类日落(2026-08-26 缺陷④教训:
12-MySQL JSON 列回读键序重排曾使暖 Pod 复用失效)。None 与空列表统一为 None
13-——util.fingerprint 只滤 None,以 [] 为默认会使全部存量模板指纹变化。
14-"""
15- 
16-from __future__ import annotations
17- 
18-import re
19-from typing import Any, Optional
20- 
21-from .errors import InvalidParams
22-from .mounts import (
23- canonical_configmap_mounts,
24- canonical_host_path_mounts,
25- canonical_nfs_mounts,
26- canonical_pvc_mounts,
27- check_resource_name,
28- find_mount_path_conflicts,
29-)
30- 
31-# K8s 容器名:DNS-1123 label(小写字母数字与 '-',首尾须字母数字,≤63)
32-SIDECAR_NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$")
33-SIDECAR_MAX = 8 # 单 Pod sidecar 条数上限(防御性,当前用户只需 1)
34-_PROBE_TYPES = frozenset({"tcp", "http"})
35-_MAX_IMAGE_LEN = 512
36-# envFrom prefix:K8s env 变量名前缀(C_IDENTIFIER 前缀语义)
37-_ENV_PREFIX_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
38- 
39-# 单项合法键(_canonical_sidecar 拒未知键:sidecar 是安全敏感面,
40-# 拼错的 capabilities_add 被静默吞掉 = "看似有特权实际没有"的运行期疑难)
41-_SIDECAR_KEYS = frozenset({
42- "name", "image", "port", "env", "env_from", "image_pull_policy",
43- "cpu_request", "memory_request", "cpu_limit", "memory_limit",
44- "privileged", "capabilities_add", "capabilities_drop",
45- "seccomp_unconfined", "apparmor_unconfined", "run_as_user", "run_as_group",
46- "host_path_mounts", "configmap_mounts", "pvc_mounts", "nfs_mounts",
47- "readiness_probe_type", "readiness_path",
48- "readiness_initial_delay", "readiness_period", "readiness_timeout_seconds",
49-})
50- 
51-_ENV_FROM_ITEM_KEYS = frozenset({"prefix", "secret_ref", "config_map_ref"})
52-_ENV_FROM_REF_KEYS = frozenset({"secret_ref", "config_map_ref"})
53- 
54- 
55-def canonical_env_from(value: Any, where: str) -> Optional[list[dict[str, Any]]]:
56- """envFrom → 内部规范形(secretRef/configMapRef 引用,值不落模板)。
57- 
58- 输入(内部 snake 形态;K8s wire 的 camelCase envFrom 由
59- session_manager/container_spec.py 翻译后再进来):
60- ``[{prefix?, secret_ref|config_map_ref: {name, optional?}}]``
61- 规范形:``[{prefix: str|None, <ref>: {name, optional}}]``;None/[] → None。
62- 
63- sidecar 规范形以**条件键**携带 ``env_from``(None 省略键)——与其他
64- 显式存 None 的键不同:env_from 是后加的,显式存 None 会改全部存量
65- sidecar 的指纹 → 伪 A 类日落。
66- """
67- if value is None:
68- return None
69- if not isinstance(value, list):
70- raise InvalidParams(
71- f"{where} must be a list of envFrom sources, got {value!r}")
72- if not value:
73- return None
74- out: list[dict[str, Any]] = []
75- for i, item in enumerate(value):
76- item_where = f"{where}[{i}]"
77- if not isinstance(item, dict):
78- raise InvalidParams(f"{item_where} must be an object, got {item!r}")
79- unknown = set(item) - _ENV_FROM_ITEM_KEYS
80- if unknown:
81- raise InvalidParams(
82- f"{item_where} unknown keys {sorted(unknown)}; allowed: "
83- f"{sorted(_ENV_FROM_ITEM_KEYS)}")
84- refs = [k for k in _ENV_FROM_REF_KEYS if item.get(k) is not None]
85- if len(refs) != 1:
86- raise InvalidParams(
87- f"{item_where} requires exactly one of secret_ref/config_map_ref, "
88- f"got {item!r}")
89- ref_key = refs[0]
90- ref = item[ref_key]
91- if not isinstance(ref, dict):
92- raise InvalidParams(
93- f"{item_where}.{ref_key} must be an object, got {ref!r}")
94- ref_unknown = set(ref) - {"name", "optional"}
95- if ref_unknown:
96- raise InvalidParams(
97- f"{item_where}.{ref_key} unknown keys {sorted(ref_unknown)}; "
98- "allowed: ['name', 'optional']")
99- name = check_resource_name(ref.get("name"), f"{item_where}.{ref_key}")
100- optional = ref.get("optional", False)
101- if not isinstance(optional, bool):
102- raise InvalidParams(
103- f"{item_where}.{ref_key}.optional must be a boolean, "
104- f"got {optional!r}")
105- prefix = item.get("prefix")
106- if prefix is not None and (
107- not isinstance(prefix, str) or not _ENV_PREFIX_RE.match(prefix)):
108- raise InvalidParams(
109- f"{item_where}.prefix must be an env-var-name prefix "
110- f"(letters/digits/'_', leading letter or '_'), got {prefix!r}")
111- out.append({"prefix": prefix,
112- ref_key: {"name": name, "optional": optional}})
113- return out
114- 
115- 
116-def _canonical_env(value: Any, where: str) -> dict[str, str]:
117- """env 校验(与 config_store 的 agent_env 同规则)→ 全 str 化 dict。"""
118- if not isinstance(value, dict) or any(
119- not isinstance(k, str) or isinstance(v, (list, dict)) or v is None
120- for k, v in value.items()):
121- raise InvalidParams(
122- f"{where}.env must be an object mapping string keys to "
123- f"scalar values, got {value!r}"
124- )
125- return {k: str(v) for k, v in value.items()}
126- 
127- 
128-def _canonical_int(value: Any, where: str, key: str, *, minimum: int,
129- maximum: int | None = None) -> int:
130- """int 字段校验(不接受 bool——bool 是 int 子类,显式排除)。"""
131- if isinstance(value, bool) or not isinstance(value, int):
132- raise InvalidParams(f"{where}.{key} must be an integer, got {value!r}")
133- if value < minimum or (maximum is not None and value > maximum):
134- bound = f"(0, {maximum}]" if maximum is not None else f">= {minimum}"
135- raise InvalidParams(f"{where}.{key} must be an integer in {bound}, got {value!r}")
136- return value
137- 
138- 
139-def _canonical_str(value: Any, where: str, key: str, *, max_len: int,
140- required: bool = True) -> Optional[str]:
141- """str 字段校验;required=False 时 None/缺省原样返回(None)。"""
142- if value is None:
143- if required:
144- raise InvalidParams(f"{where}.{key} requires a non-empty string")
145- return None
146- if not isinstance(value, str) or not value.strip() or len(value) > max_len:
147- raise InvalidParams(
148- f"{where}.{key} must be a non-empty string of at most "
149- f"{max_len} chars, got {value!r}"
150- )
151- return value
152- 
153- 
154-def _canonical_bool(value: Any, where: str, key: str) -> bool:
155- if not isinstance(value, bool):
156- raise InvalidParams(f"{where}.{key} must be a boolean, got {value!r}")
157- return value
158- 
159- 
160-def _canonical_caps(value: Any, where: str, key: str) -> list[str]:
161- if not isinstance(value, list) or any(
162- not isinstance(item, str) or not item for item in value):
163- raise InvalidParams(
164- f"{where}.{key} must be a list of non-empty strings, got {value!r}"
165- )
166- return list(value)
167- 
168- 
169-def _canonical_sidecar(item: Any, where: str) -> dict[str, Any]:
170- """单项 sidecar dict → 规范形(填满全部默认键);非法 raise InvalidParams。
171- 
172- 消息带 ``sidecars[{i}]`` 定位,风格对齐 config_store 的 agent_env 校验。
173- """
174- if not isinstance(item, dict):
175- raise InvalidParams(f"{where} must be an object, got {item!r}")
176- unknown = set(item) - _SIDECAR_KEYS
177- if unknown:
178- raise InvalidParams(
179- f"{where} unknown keys {sorted(unknown)}; allowed: "
180- f"{sorted(_SIDECAR_KEYS)}"
181- )
182- 
183- name = item.get("name")
184- if not isinstance(name, str) or not name:
185- raise InvalidParams(f"{where} requires a non-empty string name")
186- if not SIDECAR_NAME_RE.match(name):
187- raise InvalidParams(
188- f"{where}.name {name!r} must be a DNS-1123 label "
189- "(lowercase alphanumeric or '-'), max 63 chars"
190- )
191- image = _canonical_str(item.get("image"), where, "image", max_len=_MAX_IMAGE_LEN)
192- 
193- port = item.get("port")
194- if port is not None:
195- port = _canonical_int(port, where, "port", minimum=1, maximum=65535)
196- 
197- probe_type = item.get("readiness_probe_type")
198- if probe_type is not None and probe_type not in _PROBE_TYPES:
199- raise InvalidParams(
200- f"{where}.readiness_probe_type must be 'tcp' or 'http', got {probe_type!r}"
201- )
202- if probe_type is not None and port is None:
203- raise InvalidParams(f"{where} requires port when readiness_probe_type is set")
204- 
205- run_as_user = item.get("run_as_user")
206- if run_as_user is not None:
207- run_as_user = _canonical_int(run_as_user, where, "run_as_user", minimum=0)
208- run_as_group = item.get("run_as_group")
209- if run_as_group is not None:
210- run_as_group = _canonical_int(run_as_group, where, "run_as_group", minimum=0)
211- # envFrom:None/[] 归一 None(条件键,见 canonical_env_from docstring)
212- env_from = canonical_env_from(item.get("env_from"), f"{where}.env_from")
213- # NFS 挂载列表:与 pvc_mounts 同构(模板级 NFS 卷按名引用,规范形见
214- # mounts.py);条件键——空列表省略(后加键,存量 sidecar 指纹零扰动)
215- nfs_mounts = canonical_nfs_mounts(
216- item.get("nfs_mounts") or [], f"{where}.nfs_mounts")
217- 
218- result = {
219- "name": name,
220- "image": image,
221- "port": port,
222- "env": _canonical_env(item.get("env") or {}, where),
223- "image_pull_policy": _canonical_str(
224- item.get("image_pull_policy") or "IfNotPresent",
225- where, "image_pull_policy", max_len=64),
226- "cpu_request": _canonical_str(
227- item.get("cpu_request"), where, "cpu_request",
228- max_len=32, required=False),
229- "memory_request": _canonical_str(
230- item.get("memory_request"), where, "memory_request",
231- max_len=32, required=False),
232- "cpu_limit": _canonical_str(
233- item.get("cpu_limit"), where, "cpu_limit",
234- max_len=32, required=False),
235- "memory_limit": _canonical_str(
236- item.get("memory_limit"), where, "memory_limit",
237- max_len=32, required=False),
238- "privileged": _canonical_bool(item.get("privileged") or False,
239- where, "privileged"),
240- "capabilities_add": _canonical_caps(
241- item.get("capabilities_add") or [], where, "capabilities_add"),
242- "capabilities_drop": _canonical_caps(
243- item.get("capabilities_drop") or [], where, "capabilities_drop"),
244- "seccomp_unconfined": _canonical_bool(
245- item.get("seccomp_unconfined") or False, where, "seccomp_unconfined"),
246- "apparmor_unconfined": _canonical_bool(
247- item.get("apparmor_unconfined") or False, where, "apparmor_unconfined"),
248- "run_as_user": run_as_user,
249- "run_as_group": run_as_group,
250- "host_path_mounts": canonical_host_path_mounts(
251- item.get("host_path_mounts") or [], f"{where}.host_path_mounts"),
252- "configmap_mounts": canonical_configmap_mounts(
253- item.get("configmap_mounts") or [], f"{where}.configmap_mounts"),
254- "pvc_mounts": canonical_pvc_mounts(
255- item.get("pvc_mounts") or [], f"{where}.pvc_mounts"),
256- "readiness_probe_type": probe_type,
257- "readiness_path": _canonical_str(
258- item.get("readiness_path") or "/health",
259- where, "readiness_path", max_len=128),
260- "readiness_initial_delay": _canonical_int(
261- item.get("readiness_initial_delay") if item.get("readiness_initial_delay") is not None else 5,
262- where, "readiness_initial_delay", minimum=0),
263- "readiness_period": _canonical_int(
264- item.get("readiness_period") if item.get("readiness_period") is not None else 10,
265- where, "readiness_period", minimum=1),
266- "readiness_timeout_seconds": _canonical_int(
267- item.get("readiness_timeout_seconds") if item.get("readiness_timeout_seconds") is not None else 3,
268- where, "readiness_timeout_seconds", minimum=1, maximum=300),
269- }
270- # env_from 条件键:有值才出现(存量 sidecar 指纹零扰动)
271- if env_from is not None:
272- result["env_from"] = env_from
273- # nfs_mounts 条件键:非空才出现(同款指纹零扰动)
274- if nfs_mounts:
275- result["nfs_mounts"] = nfs_mounts
276- return result
277- 
278- 
279-def _sorted_canonical(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
280- """按 name 升序排列(指纹对列表顺序稳定;name 已保证唯一性由调用方校验)。"""
281- return sorted(items, key=lambda sc: sc["name"])
282- 
283- 
284-def normalize_sidecars(value: Any) -> Optional[list[dict[str, Any]]]:
285- """宽容归一(读路径防御,不抛异常):None/[]/非 list → None;
286- 非 dict 项或缺 name/image 的项静默丢弃;其余项走规范形后按 name 排序;
287- 结果为空 → None。与 template_from_row 的 agent_env 兜底同一语义。"""
288- if not isinstance(value, list):
289- return None
290- items: list[dict[str, Any]] = []
291- for item in value:
292- if not isinstance(item, dict):
293- continue
294- if not isinstance(item.get("name"), str) or not item.get("name"):
295- continue
296- if not isinstance(item.get("image"), str) or not item.get("image"):
297- continue
298- try:
299- items.append(_canonical_sidecar(item, "sidecars[n]"))
300- except InvalidParams:
301- continue
302- return _sorted_canonical(items) or None
303- 
304- 
305-def validate_sidecars(
306- value: Any,
307- *,
308- container_name: str,
309- sse_port: int,
310- container_port: int,
311-) -> Optional[list[dict[str, Any]]]:
312- """config_sync 下发校验(fail-fast 400);合法返回规范形列表,
313- None/空列表 → None。跨字段校验(端口/容器名冲突)委托 find_sidecar_conflict。"""
314- if value is None:
315- return None
316- if not isinstance(value, list):
317- raise InvalidParams(
318- f"sidecars must be a list of container objects, got {value!r}"
319- )
320- if len(value) > SIDECAR_MAX:
321- raise InvalidParams(
322- f"sidecars must have at most {SIDECAR_MAX} entries, got {len(value)}"
323- )
324- items = [
325- _canonical_sidecar(item, f"sidecars[{i}]") for i, item in enumerate(value)
326- ]
327- names = [sc["name"] for sc in items]
328- if len(set(names)) != len(names):
329- dupes = sorted({n for n in names if names.count(n) > 1})
330- raise InvalidParams(f"sidecars duplicate container names: {dupes}")
331- conflict = find_sidecar_conflict(items, container_name, sse_port, container_port)
332- if conflict:
333- raise InvalidParams(f"sidecars: {conflict}")
334- # 每个 sidecar 自身四类挂载的 mount_path 不得重复(K8s 会拒,这里 fail-fast)
335- for i, sc in enumerate(items):
336- mount_conflict = find_mount_path_conflicts([
337- (f"sidecars[{i}].host_path_mounts", sc["host_path_mounts"]),
338- (f"sidecars[{i}].configmap_mounts", sc["configmap_mounts"]),
339- (f"sidecars[{i}].pvc_mounts", sc["pvc_mounts"]),
340- (f"sidecars[{i}].nfs_mounts", sc.get("nfs_mounts")),
341- ])
342- if mount_conflict:
343- raise InvalidParams(f"sidecars[{i}]: {mount_conflict}")
344- return _sorted_canonical(items) or None
345- 
346- 
347-def find_sidecar_conflict(
348- sidecars: list[dict[str, Any]],
349- container_name: str,
350- sse_port: int,
351- container_port: int,
352-) -> Optional[str]:
353- """纯谓词:返回首个冲突描述(SM 包 InvalidParams、RM 包 DeployFailed 共用)。
354- 
355- - sidecar name == 主容器 container_name(K8s 同 Pod 容器名必须唯一)
356- - sidecar port 撞 sse_port / container_port / 兄弟 sidecar port
357- (同 Pod 共享网络命名空间,agent 经 127.0.0.1:port 访问 sidecar,
358- 撞号几乎必然是配错——有意的严格)
359- """
360- for i, sc in enumerate(sidecars):
361- if sc["name"] == container_name:
362- return (f"sidecars[{i}].name {sc['name']!r} conflicts with the "
363- f"agent container_name {container_name!r}")
364- agent_ports = {p for p in (sse_port, container_port) if p}
365- seen: dict[int, str] = {}
366- for i, sc in enumerate(sidecars):
367- port = sc.get("port")
368- if not port:
369- continue
370- if port in agent_ports:
371- return (f"sidecars[{i}].port {port} conflicts with the agent "
372- f"container ports {sorted(agent_ports)}; sidecar ports must "
373- "differ from sse_port/container_port and each other")
374- if port in seen:
375- return (f"sidecars[{i}].port {port} conflicts with "
376- f"{seen[port]}; sidecar ports must differ from each other")
377- seen[port] = f"sidecars[{i}].port"
378- return None
@@ -4,6 +4,13 @@
4- DEPLOY_FIELDS deploy 子集(A 类字段):值被烘焙进运行中的 Pod,变更需日落。4- DEPLOY_FIELDS deploy 子集(A 类字段):值被烘焙进运行中的 Pod,变更需日落。
5- POLICY_FIELDS 策略字段(B 类):控制面读时使用,变更不日落老 Pod。5- POLICY_FIELDS 策略字段(B 类):控制面读时使用,变更不日落老 Pod。
6 6 
7+2026-09 统一规范形起,容器级配置以**整体**进指纹:
8+``main_container`` / ``sidecars``(containers.py canonical,13 键全填满)。
9+**加/改容器字段不再动本文件**——canonical 整体序列化进 deploy_ver,
10+只需改 containers.py(canonical 定义)+ RM 渲染分支。RM 侧对旧缓存
11+(缺新键的 pod_spec_json)以 containers.normalize_pod_spec 补缺省后
12+同指纹(新键默认值 == 旧行为时零伪日落)。
13+ 
7deploy_ver = DEPLOY_VER_FIELDS 的 hash 指纹(不含 kubeconfig——虽在 deploy14deploy_ver = DEPLOY_VER_FIELDS 的 hash 指纹(不含 kubeconfig——虽在 deploy
8子集但例外:只影响新 deploy 操作,不日落)。SM(Template.deploy_ver)与 RM15子集但例外:只影响新 deploy 操作,不日落)。SM(Template.deploy_ver)与 RM
9(pod_spec 指纹)必须用同一字段集与算法(util.fingerprint)。16(pod_spec 指纹)必须用同一字段集与算法(util.fingerprint)。
@@ -12,34 +19,16 @@ deploy_ver = DEPLOY_VER_FIELDS 的 hash 指纹(不含 kubeconfig——虽在 d
12from __future__ import annotations19from __future__ import annotations
13 20 
14DEPLOY_FIELDS: tuple[str, ...] = (21DEPLOY_FIELDS: tuple[str, ...] = (
15- "agent_image",22+ # Pod 级字段(模板表模板级行列)
16 "namespace",23 "namespace",
17 "node_name",24 "node_name",
18- "run_as_user",25+ "fs_group", # Pod 级 securityContext.fsGroup(变更需重部署 Pod)
19- "run_as_group",
20- "fs_group", # Pod 级 securityContext.fsGroup(变更需重部署 Pod)
21 "pod_name",26 "pod_name",
22- "container_name",27+ "sse_path", # gateway 直连 URL 路径段(RM 拼 pod_sse_url)
23- "container_port",28+ # 容器级(canonical 整体;主容器含 nfs/sse 端口/探针等,sidecars 为
24- "sse_port",29+ # name 升序 canonical 列表,None = 无 sidecar)
25- "sse_path",30+ "main_container",
26- "health_path", # readiness 探针路径(默认 /health;真 AgentServer 为 /api/v1/health)31+ "sidecars",
27- "agent_env", # Agent 容器注入的 env(如 AGENT_HTTP_ENABLED/HOST/PORT)
28- "agent_env_from", # envFrom 引用(secretRef/configMapRef;None 不进指纹——存量零扰动)
29- "command", # 主容器 command 覆盖(None = 走镜像入口)
30- "args", # 主容器 args 覆盖(None = 走镜像入口)
31- "image_pull_policy",
32- "readiness_initial_delay",
33- "readiness_period",
34- "agent_cpu_request",
35- "agent_memory_request",
36- "agent_cpu_limit",
37- "agent_memory_limit",
38- "sidecars", # 同 Pod sidecar 容器列表(通用;首个用户 jiuwenbox)
39- "agent_host_path_mounts", # 主容器 hostPath 挂载(规范形见 mounts.py)
40- "agent_configmap_mounts", # 主容器 ConfigMap 挂载
41- "agent_pvc_mounts", # 主容器 PVC 挂载
42- "agent_nfs_mounts", # 主容器 NFS 挂载(与 PVC 同构,卷源在模板级 volumes)
43)32)
44 33 
45# deploy 指纹涵盖字段(deploy 子集 + ready 超时参数——影响 deploy 行为与版本)34# deploy 指纹涵盖字段(deploy 子集 + ready 超时参数——影响 deploy 行为与版本)
@@ -137,7 +137,9 @@ async def test_all_k8s_calls_carry_request_timeout():
137 core = _FakeCore()137 core = _FakeCore()
138 client = _armed_client(core)138 client = _armed_client(core)
139 139 
140- await client.deploy({"agent_image": "agentserver:1.0", "namespace": "default",140+ await client.deploy({"main_container": {"name": "agent",
141+ "image": "agentserver:1.0"},
142+ "namespace": "default",
141 "ready_timeout": 1, "ready_poll_interval": 0.01})143 "ready_timeout": 1, "ready_poll_interval": 0.01})
142 await client.get_pod("p1", "default")144 await client.get_pod("p1", "default")
143 await client.list_pods("default", "app=x")145 await client.list_pods("default", "app=x")
@@ -1,8 +1,9 @@
1# coding: utf-81# coding: utf-8
2-"""_build_pod_body 多容器渲染测试(单容器黄金断言 + sidecar 全量渲染)。2+"""_build_pod_body 统一容器渲染测试(单容器黄金断言 + sidecar 全量渲染)。
3 3 
4mock 手法:_V1 记录型替身注入 client._client——_build_pod_body 只做 kwargs4mock 手法:_V1 记录型替身注入 client._client——_build_pod_body 只做 kwargs
5透传,断言直接读 .kwargs 链,零环境依赖(不依赖 kubernetes_asyncio 安装)。5透传,断言直接读 .kwargs 链,零环境依赖(不依赖 kubernetes_asyncio 安装)。
6+pod_spec 为统一容器规范形(containers.py canonical):main_container + sidecars。
6"""7"""
7 8 
8from __future__ import annotations9from __future__ import annotations
@@ -16,26 +17,26 @@ from agent_runtime.resource_manager.k8s import (
16 RealK8sPodClient,17 RealK8sPodClient,
17 _host_path_volume_name,18 _host_path_volume_name,
18)19)
19-from agent_runtime.sidecars import validate_sidecars
20 20 
21+# canonical jiuwenbox 全量样例(渲染断言基准)
21JIUWENBOX = {22JIUWENBOX = {
22 "name": "jiuwenbox",23 "name": "jiuwenbox",
23 "image": "jiuwenbox-amd64:0.0.1",24 "image": "jiuwenbox-amd64:0.0.1",
24- "port": 8321,25+ "ports": [{"name": None, "container_port": 8321}],
25 "env": {"JIUWENBOX_LISTEN": "tcp://0.0.0.0:8321",26 "env": {"JIUWENBOX_LISTEN": "tcp://0.0.0.0:8321",
26 "JIUWENBOX_POLICY_PATH": "/app/configs/enterprise-policy.yaml"},27 "JIUWENBOX_POLICY_PATH": "/app/configs/enterprise-policy.yaml"},
27- "cpu_request": "100m",28+ "resources": {"cpu_request": "100m", "memory_request": None,
28- "memory_limit": "1Gi",29+ "cpu_limit": None, "memory_limit": "1Gi"},
29- "privileged": True,30+ "security_context": {"run_as_user": None, "run_as_group": None,
30- "capabilities_add": ["SYS_ADMIN", "NET_ADMIN"],31+ "privileged": True,
31- "seccomp_unconfined": True,32+ "capabilities_add": ["NET_ADMIN", "SYS_ADMIN"],
32- "apparmor_unconfined": True,33+ "capabilities_drop": [],
34+ "seccomp_unconfined": True,
35+ "apparmor_unconfined": True},
33 "host_path_mounts": [36 "host_path_mounts": [
34- {"host_path": "/sys/fs/cgroup", "mount_path": "/sys/fs/cgroup"},37+ {"host_path": "/sys/fs/cgroup", "mount_path": "/sys/fs/cgroup"}],
35- ],38+ "readiness_probe": {"probe_type": "tcp", "path": "/health",
36- "readiness_probe_type": "tcp",39+ "initial_delay": 10, "period": 5, "timeout": 3},
37- "readiness_initial_delay": 10,
38- "readiness_period": 5,
39}40}
40 41 
41 42 
@@ -55,6 +56,7 @@ def _fake_client_module() -> SimpleNamespace:
55 "V1HostPathVolumeSource", "V1ConfigMapVolumeSource", "V1KeyToPath",56 "V1HostPathVolumeSource", "V1ConfigMapVolumeSource", "V1KeyToPath",
56 "V1PersistentVolumeClaimVolumeSource",57 "V1PersistentVolumeClaimVolumeSource",
57 "V1EnvFromSource", "V1SecretEnvSource", "V1ConfigMapEnvSource",58 "V1EnvFromSource", "V1SecretEnvSource", "V1ConfigMapEnvSource",
59+ "V1PodSecurityContext",
58 )60 )
59 return SimpleNamespace(**{name: _V1 for name in names})61 return SimpleNamespace(**{name: _V1 for name in names})
60 62 
@@ -66,27 +68,27 @@ def client() -> RealK8sPodClient:
66 return c68 return c
67 69 
68 70 
69-def _base_spec(**overrides) -> dict:71+def _main(**overrides) -> dict:
70- spec = {72+ main = {
71- "agent_image": "agentserver:1.0",73+ "name": "agent",
72- "namespace": "default",74+ "image": "agentserver:1.0",
73- "sse_port": 8086,75+ "ports": [{"name": "sse", "container_port": 8086}],
74- "container_port": 8086,76+ "env": {"AGENT_HTTP_ENABLED": "true"},
75- "container_name": "agent",77+ "nfs_mounts": [{"server": "nfs.example", "path": "/export",
76- "agent_nfs_mounts": [{"server": "nfs.example", "path": "/export",78+ "mount_path": "/data", "read_only": False}],
77- "mount_path": "/data", "read_only": False}],79+ "readiness_probe": {"probe_type": "http", "path": "/health",
78- "health_path": "/health",80+ "initial_delay": 5, "period": 5, "timeout": None},
79- "agent_env": {"AGENT_HTTP_ENABLED": "true"},
80 }81 }
82+ main.update(overrides)
83+ return main
84+ 
85+ 
86+def _base_spec(**overrides) -> dict:
87+ spec = {"main_container": _main(), "namespace": "default"}
81 spec.update(overrides)88 spec.update(overrides)
82 return spec89 return spec
83 90 
84 91 
85-def _sidecars(validated: list[dict]) -> list[dict]:
86- return validate_sidecars(validated, container_name="agent",
87- sse_port=8086, container_port=8086)
88- 
89- 
90# -------------------------------------------------------------- 单容器黄金断言92# -------------------------------------------------------------- 单容器黄金断言
91 93 
92def test_build_pod_body_without_sidecars_unchanged(client):94def test_build_pod_body_without_sidecars_unchanged(client):
@@ -98,13 +100,7 @@ def test_build_pod_body_without_sidecars_unchanged(client):
98 assert len(containers) == 1100 assert len(containers) == 1
99 assert containers[0].kwargs["name"] == "agent"101 assert containers[0].kwargs["name"] == "agent"
100 volume_names = [v.kwargs["name"] for v in spec["volumes"]]102 volume_names = [v.kwargs["name"] for v in spec["volumes"]]
101- assert volume_names == ["nfs-agent-0-0"]103+ assert volume_names == ["nfs-agent-0-0"] # NFS 第四挂载族卷名(同 hp/cm/pvc 规则)
102- nfs_vol = spec["volumes"][0]
103- assert nfs_vol.kwargs["nfs"].kwargs == {"server": "nfs.example",
104- "path": "/export"}
105- main_mount = containers[0].kwargs["volume_mounts"][0].kwargs
106- assert main_mount == {"name": "nfs-agent-0-0", "mount_path": "/data",
107- "read_only": False}
108 assert spec["restart_policy"] == "Always"104 assert spec["restart_policy"] == "Always"
109 # 主容器探针/端口/env 不受 sidecar 改动影响105 # 主容器探针/端口/env 不受 sidecar 改动影响
110 main = containers[0].kwargs106 main = containers[0].kwargs
@@ -113,11 +109,18 @@ def test_build_pod_body_without_sidecars_unchanged(client):
113 assert [p.kwargs["container_port"] for p in main["ports"]] == [8086]109 assert [p.kwargs["container_port"] for p in main["ports"]] == [8086]
114 110 
115 111 
112+def test_build_pod_body_rejects_legacy_flat_spec(client):
113+ """缺 main_container 的旧扁平缓存 → DeployFailed(防渲染空镜像 Pod)。"""
114+ with pytest.raises(DeployFailed, match="no main_container"):
115+ client._build_pod_body("pod-1", {"agent_image": "x:1",
116+ "namespace": "default"})
117+ 
118+ 
116# -------------------------------------------------------------- sidecar 渲染119# -------------------------------------------------------------- sidecar 渲染
117 120 
118def test_build_pod_body_renders_full_sidecar(client):121def test_build_pod_body_renders_full_sidecar(client):
119 """jiuwenbox 全量:双容器 + 特权安全上下文 + hostPath 卷 + apparmor annotation。"""122 """jiuwenbox 全量:双容器 + 特权安全上下文 + hostPath 卷 + apparmor annotation。"""
120- spec = _base_spec(sidecars=_sidecars([JIUWENBOX]))123+ spec = _base_spec(sidecars=[JIUWENBOX])
121 pod = client._build_pod_body("pod-1", spec)124 pod = client._build_pod_body("pod-1", spec)
122 meta, pod_spec = pod.kwargs["metadata"].kwargs, pod.kwargs["spec"].kwargs125 meta, pod_spec = pod.kwargs["metadata"].kwargs, pod.kwargs["spec"].kwargs
123 126 
@@ -136,7 +139,7 @@ def test_build_pod_body_renders_full_sidecar(client):
136 # 安全上下文:特权 + caps + seccomp unconfined139 # 安全上下文:特权 + caps + seccomp unconfined
137 sec = box["security_context"].kwargs140 sec = box["security_context"].kwargs
138 assert sec["privileged"] is True141 assert sec["privileged"] is True
139- assert sec["capabilities"].kwargs["add"] == ["SYS_ADMIN", "NET_ADMIN"]142+ assert sec["capabilities"].kwargs["add"] == ["NET_ADMIN", "SYS_ADMIN"] # canonical 排序
140 assert sec["seccomp_profile"].kwargs == {"type": "Unconfined"}143 assert sec["seccomp_profile"].kwargs == {"type": "Unconfined"}
141 # apparmor → Pod annotation(不是 security_context)144 # apparmor → Pod annotation(不是 security_context)
142 assert meta["annotations"] == {145 assert meta["annotations"] == {
@@ -164,9 +167,10 @@ def test_build_pod_body_renders_full_sidecar(client):
164 167 
165 168 
166def test_build_pod_body_sidecar_readiness_http(client):169def test_build_pod_body_sidecar_readiness_http(client):
167- sc = dict(JIUWENBOX, readiness_probe_type="http", port=8321,170+ sc = dict(JIUWENBOX, readiness_probe={
168- readiness_path="/box/health")171+ "probe_type": "http", "path": "/box/health", "initial_delay": 5,
169- spec = _base_spec(sidecars=_sidecars([sc]))172+ "period": 10, "timeout": 3})
173+ spec = _base_spec(sidecars=[sc])
170 pod = client._build_pod_body("pod-1", spec)174 pod = client._build_pod_body("pod-1", spec)
171 probe = pod.kwargs["spec"].kwargs["containers"][1].kwargs["readiness_probe"]175 probe = pod.kwargs["spec"].kwargs["containers"][1].kwargs["readiness_probe"]
172 assert probe.kwargs["http_get"].kwargs == {"path": "/box/health", "port": 8321}176 assert probe.kwargs["http_get"].kwargs == {"path": "/box/health", "port": 8321}
@@ -175,7 +179,7 @@ def test_build_pod_body_sidecar_readiness_http(client):
175def test_build_pod_body_sidecar_without_port(client):179def test_build_pod_body_sidecar_without_port(client):
176 """无 port sidecar:ports=None、无探针(纯后台容器)。"""180 """无 port sidecar:ports=None、无探针(纯后台容器)。"""
177 sc = {"name": "logtail", "image": "logtail:1"}181 sc = {"name": "logtail", "image": "logtail:1"}
178- spec = _base_spec(sidecars=_sidecars([sc]))182+ spec = _base_spec(sidecars=[sc])
179 pod = client._build_pod_body("pod-1", spec)183 pod = client._build_pod_body("pod-1", spec)
180 box = pod.kwargs["spec"].kwargs["containers"][1].kwargs184 box = pod.kwargs["spec"].kwargs["containers"][1].kwargs
181 assert box["ports"] is None185 assert box["ports"] is None
@@ -184,9 +188,10 @@ def test_build_pod_body_sidecar_without_port(client):
184 188 
185 189 
186def test_build_pod_body_rejects_port_conflict(client):190def test_build_pod_body_rejects_port_conflict(client):
187- """脏缓存(绕过 SM 校验的 pod_spec):sidecar port 撞 sse_port → DeployFailed。"""191+ """脏缓存(绕过 SM 校验的 pod_spec):sidecar port 撞主容器端口 → DeployFailed。"""
188- spec = _base_spec(sidecars=[dict(JIUWENBOX, port=8086)]) # 原始 dict,未走校验192+ spec = _base_spec(sidecars=[dict(
189- with pytest.raises(DeployFailed, match="sidecars invalid"):193+ JIUWENBOX, ports=[{"name": None, "container_port": 8086}])])
194+ with pytest.raises(DeployFailed, match="containers invalid"):
190 client._build_pod_body("pod-1", spec)195 client._build_pod_body("pod-1", spec)
191 196 
192 197 
@@ -201,25 +206,22 @@ def test_build_pod_body_skips_corrupt_cached_sidecars(client):
201# -------------------------------------------------------------- 卷名规则206# -------------------------------------------------------------- 卷名规则
202 207 
203def test_build_pod_body_renders_main_container_mounts(client):208def test_build_pod_body_renders_main_container_mounts(client):
204- """主容器四类挂载:ConfigMap(sub_path+items)/hostPath/PVC/NFS 卷与挂载点。"""209+ """主容器三种挂载:ConfigMap(sub_path+items)/hostPath/PVC 卷与挂载点。"""
205- from agent_runtime.mounts import validate_agent_mounts210+ main = _main(
206- 211+ nfs_mounts=[{"server": "nfs.example", "path": "/export",
207- hp, cm, pvc, nfs = validate_agent_mounts(212+ "mount_path": "/nfs", "read_only": False}],
208- [{"host_path": "/host/cfg", "mount_path": "/etc/host"}],213+ host_path_mounts=[{"host_path": "/host/cfg", "mount_path": "/etc/host"}],
209- [{"config_map_name": "agent-cm", "mount_path": "/etc/agent/config.yaml",214+ configmap_mounts=[{"config_map_name": "agent-cm",
210- "sub_path": "config.yaml",215+ "mount_path": "/etc/agent/config.yaml",
211- "items": [{"key": "k1", "path": "config.yaml"}]}],216+ "sub_path": "config.yaml",
212- [{"claim_name": "agent-data", "mount_path": "/data"}],217+ "items": [{"key": "k1", "path": "config.yaml"}]}],
213- [{"server": "nfs.example", "path": "/export", "mount_path": "/nfs"}],218+ pvc_mounts=[{"claim_name": "agent-data", "mount_path": "/data"}],
214 )219 )
215- spec = _base_spec(agent_host_path_mounts=hp,220+ spec = _base_spec(main_container=main)
216- agent_configmap_mounts=cm,
217- agent_pvc_mounts=pvc,
218- agent_nfs_mounts=nfs)
219 pod = client._build_pod_body("pod-1", spec)221 pod = client._build_pod_body("pod-1", spec)
220 pod_spec = pod.kwargs["spec"].kwargs222 pod_spec = pod.kwargs["spec"].kwargs
221 vols = {v.kwargs["name"]: v.kwargs for v in pod_spec["volumes"]}223 vols = {v.kwargs["name"]: v.kwargs for v in pod_spec["volumes"]}
222- # NFS + 三种卷共存;主容器卷名 {hp,cm,pvc,nfs}-agent-0-{mount_idx}224+ # NFS(既有)+ 三种新卷共存;主容器卷名 {hp,cm,pvc}-agent-0-{mount_idx}
223 assert set(vols) == {"nfs-agent-0-0", "cm-agent-0-0", "hp-agent-0-0",225 assert set(vols) == {"nfs-agent-0-0", "cm-agent-0-0", "hp-agent-0-0",
224 "pvc-agent-0-0"}226 "pvc-agent-0-0"}
225 cm_vol = vols["cm-agent-0-0"]["config_map"].kwargs227 cm_vol = vols["cm-agent-0-0"]["config_map"].kwargs
@@ -230,11 +232,10 @@ def test_build_pod_body_renders_main_container_mounts(client):
230 "path": "/host/cfg", "type": None}232 "path": "/host/cfg", "type": None}
231 assert vols["pvc-agent-0-0"]["persistent_volume_claim"].kwargs == {233 assert vols["pvc-agent-0-0"]["persistent_volume_claim"].kwargs == {
232 "claim_name": "agent-data", "read_only": False}234 "claim_name": "agent-data", "read_only": False}
233- assert vols["nfs-agent-0-0"]["nfs"].kwargs == {
234- "server": "nfs.example", "path": "/export"}
235 # 主容器 volumeMounts:NFS + cm(sub_path+只读默认 True)+ hp + pvc235 # 主容器 volumeMounts:NFS + cm(sub_path+只读默认 True)+ hp + pvc
236- main = pod_spec["containers"][0].kwargs236+ main_kwargs = pod_spec["containers"][0].kwargs
237- mounts = {m.kwargs["mount_path"]: m.kwargs for m in main["volume_mounts"]}237+ mounts = {m.kwargs["mount_path"]: m.kwargs
238+ for m in main_kwargs["volume_mounts"]}
238 assert set(mounts) == {"/nfs", "/etc/host", "/etc/agent/config.yaml", "/data"}239 assert set(mounts) == {"/nfs", "/etc/host", "/etc/agent/config.yaml", "/data"}
239 assert mounts["/etc/agent/config.yaml"] == {240 assert mounts["/etc/agent/config.yaml"] == {
240 "name": "cm-agent-0-0", "mount_path": "/etc/agent/config.yaml",241 "name": "cm-agent-0-0", "mount_path": "/etc/agent/config.yaml",
@@ -243,15 +244,14 @@ def test_build_pod_body_renders_main_container_mounts(client):
243 244 
244 245 
245def test_build_pod_body_renders_sidecar_configmap_and_pvc(client):246def test_build_pod_body_renders_sidecar_configmap_and_pvc(client):
246- from agent_runtime.sidecars import validate_sidecars247+ sc = dict(JIUWENBOX,
247- 248+ configmap_mounts=[
248- sc = dict(JIUWENBOX, configmap_mounts=[249+ {"config_map_name": "box-policy",
249- {"config_map_name": "box-policy",250+ "mount_path": "/etc/jiuwenbox/policy.yaml",
250- "mount_path": "/etc/jiuwenbox/policy.yaml", "sub_path": "policy.yaml"}],251+ "sub_path": "policy.yaml"}],
251- pvc_mounts=[{"claim_name": "box-data", "mount_path": "/var/lib/box"}])252+ pvc_mounts=[{"claim_name": "box-data",
252- sidecars = validate_sidecars([sc], container_name="agent",253+ "mount_path": "/var/lib/box"}])
253- sse_port=8086, container_port=8086)254+ spec = _base_spec(sidecars=[sc])
254- spec = _base_spec(sidecars=sidecars)
255 pod = client._build_pod_body("pod-1", spec)255 pod = client._build_pod_body("pod-1", spec)
256 pod_spec = pod.kwargs["spec"].kwargs256 pod_spec = pod.kwargs["spec"].kwargs
257 vols = {v.kwargs["name"]: v.kwargs for v in pod_spec["volumes"]}257 vols = {v.kwargs["name"]: v.kwargs for v in pod_spec["volumes"]}
@@ -280,21 +280,36 @@ def test_host_path_volume_name_rules(name, idx, mount_idx, expected):
280# ---------------------------------------------- 主容器 securityContext / node_name280# ---------------------------------------------- 主容器 securityContext / node_name
281 281 
282def test_build_pod_body_main_security_context(client):282def test_build_pod_body_main_security_context(client):
283- """主容器 run_as_user/run_as_group → securityContext;未给则不设键(镜像 USER 生效)。"""283+ """决策 B:主容器 securityContext 与 sidecar 全量一致渲染。"""
284- spec = _base_spec(run_as_user=1000, run_as_group=1000)284+ spec = _base_spec(main_container=_main(
285+ security_context={"run_as_user": 1000, "run_as_group": 1000}))
285 main = client._build_pod_body("pod-1", spec).kwargs["spec"].kwargs[286 main = client._build_pod_body("pod-1", spec).kwargs["spec"].kwargs[
286 "containers"][0].kwargs287 "containers"][0].kwargs
287- assert main["security_context"].kwargs == {288+ effective = {k: v for k, v in
288- "run_as_user": 1000, "run_as_group": 1000}289+ main["security_context"].kwargs.items() if v is not None}
289- # 只给 user 不给 group:半渲染290+ assert effective == {"run_as_user": 1000, "run_as_group": 1000}
291+ # 只给 user 不给 group:半渲染(其余键 None=未设,K8s 渲染等价)
290 main = client._build_pod_body(292 main = client._build_pod_body(
291- "pod-1", _base_spec(run_as_user=1000)).kwargs["spec"].kwargs[293+ "pod-1", _base_spec(main_container=_main(
292- "containers"][0].kwargs294+ security_context={"run_as_user": 1000}))
293- assert main["security_context"].kwargs == {"run_as_user": 1000}295+ ).kwargs["spec"].kwargs["containers"][0].kwargs
294- # 默认:不设键(与历史 Pod 零差异)296+ effective = {k: v for k, v in
297+ main["security_context"].kwargs.items() if v is not None}
298+ assert effective == {"run_as_user": 1000}
299+ # 特权/caps/seccomp:主容器同 sidecar 渲染
300+ main = client._build_pod_body(
301+ "pod-1", _base_spec(main_container=_main(security_context={
302+ "privileged": True, "capabilities_add": ["SYS_ADMIN"],
303+ "seccomp_unconfined": True}))
304+ ).kwargs["spec"].kwargs["containers"][0].kwargs
305+ sec = main["security_context"].kwargs
306+ assert sec["privileged"] is True
307+ assert sec["capabilities"].kwargs == {"add": ["SYS_ADMIN"], "drop": None}
308+ assert sec["seccomp_profile"].kwargs == {"type": "Unconfined"}
309+ # 默认:security_context=None(走镜像默认;渲染出的 K8s 对象无该段)
295 main = client._build_pod_body(310 main = client._build_pod_body(
296 "pod-1", _base_spec()).kwargs["spec"].kwargs["containers"][0].kwargs311 "pod-1", _base_spec()).kwargs["spec"].kwargs["containers"][0].kwargs
297- assert "security_context" not in main312+ assert main["security_context"] is None
298 313 
299 314 
300def test_build_pod_body_node_name(client):315def test_build_pod_body_node_name(client):
@@ -310,17 +325,13 @@ def test_build_pod_body_node_name(client):
310# -------------------------------------------------------------- PVC 同 claim 去重325# -------------------------------------------------------------- PVC 同 claim 去重
311 326 
312def _spec_with_pvcs(client, main_pvc, sc_pvc):327def _spec_with_pvcs(client, main_pvc, sc_pvc):
313- """主容器 + jiuwenbox sidecar 各带 pvc_mounts 的 spec(均走规范形校验)。"""328+ """主容器 + jiuwenbox sidecar 各带 pvc_mounts 的 spec(canonical 形)。"""
314- from agent_runtime.mounts import validate_agent_mounts329+ main = _main(
315- from agent_runtime.sidecars import validate_sidecars330+ nfs_mounts=[{"server": "nfs.example", "path": "/export",
316- 331+ "mount_path": "/nfs", "read_only": False}],
317- hp, cm, pvc, _nfs = validate_agent_mounts([], [], main_pvc, None)332+ pvc_mounts=main_pvc)
318 sc = dict(JIUWENBOX, pvc_mounts=sc_pvc)333 sc = dict(JIUWENBOX, pvc_mounts=sc_pvc)
319- sidecars = validate_sidecars([sc], container_name="agent",334+ return _base_spec(main_container=main, sidecars=[sc])
320- sse_port=8086, container_port=8086)
321- # 关掉 base 的 NFS 挂载(默认 /data 会与 PVC 测试挂载点相撞)
322- return _base_spec(agent_nfs_mounts=None, agent_pvc_mounts=pvc,
323- sidecars=sidecars)
324 335 
325 336 
326def test_build_pod_body_pvc_same_claim_shared_across_containers(client):337def test_build_pod_body_pvc_same_claim_shared_across_containers(client):
@@ -382,86 +393,15 @@ def test_build_pod_body_pvc_shared_claim_read_only_first_wins(client):
382 assert box_mounts["/var/lib/box"]["read_only"] is False # mount 级原样393 assert box_mounts["/var/lib/box"]["read_only"] is False # mount 级原样
383 394 
384 395 
385-# -------------------------------------------------------------- NFS 同共享去重
386-# 白盒单测:直测渲染纯函数 _build_pod_body(同文件既有惯例),压制 G.CLS.11
387-# pylint: disable=protected-access
388- 
389-def _spec_with_nfs(main_nfs, sc_nfs):
390- """主容器 + jiuwenbox sidecar 各带 nfs_mounts 的 spec(均走规范形校验)。"""
391- from agent_runtime.mounts import validate_agent_mounts
392- 
393- hp, cm, pvc, nfs = validate_agent_mounts([], [], [], main_nfs)
394- sc = dict(JIUWENBOX, nfs_mounts=sc_nfs)
395- sidecars = validate_sidecars([sc], container_name="agent",
396- sse_port=8086, container_port=8086)
397- return _base_spec(agent_nfs_mounts=nfs, sidecars=sidecars)
398- 
399- 
400-def test_build_pod_body_nfs_same_share_shared_across_containers(client):
401- """同 server+path 的 NFS 共享跨主/sidecar:只建一个共享卷,双挂载点复用。
402- 
403- 企业版 jiuwenclaw 场景:agentserver(/root/.jiuwenswarm)与 jiuwenbox
404- sidecar(/home/app/.jiuwenswarm)共挂同一 NFS 数据目录。
405- """
406- spec = _spec_with_nfs(
407- [{"server": "10.0.0.1", "path": "/jiuwenclaw",
408- "mount_path": "/root/.jiuwenswarm"}],
409- [{"server": "10.0.0.1", "path": "/jiuwenclaw",
410- "mount_path": "/home/app/.jiuwenswarm"}])
411- ps = client._build_pod_body("pod-1", spec).kwargs["spec"].kwargs
412- nfs_vols = [v for v in ps["volumes"] if "nfs" in v.kwargs]
413- assert len(nfs_vols) == 1 # 去重:同共享一卷
414- assert nfs_vols[0].kwargs["name"] == "nfs-agent-0-0" # 首现=主容器(idx 0)
415- assert nfs_vols[0].kwargs["nfs"].kwargs == {
416- "server": "10.0.0.1", "path": "/jiuwenclaw"}
417- assert "nfs-jiuwenbox-0-0" not in [v.kwargs["name"] for v in ps["volumes"]]
418- main_mounts = {m.kwargs["mount_path"]: m.kwargs
419- for m in ps["containers"][0].kwargs["volume_mounts"]}
420- box_mounts = {m.kwargs["mount_path"]: m.kwargs
421- for m in ps["containers"][1].kwargs["volume_mounts"]}
422- assert main_mounts["/root/.jiuwenswarm"]["name"] == "nfs-agent-0-0"
423- assert box_mounts["/home/app/.jiuwenswarm"]["name"] == "nfs-agent-0-0"
424- 
425- 
426-def test_build_pod_body_nfs_different_shares_not_deduped(client):
427- """异共享不误伤:各建各卷、各引用各卷名。"""
428- spec = _spec_with_nfs(
429- [{"server": "10.0.0.1", "path": "/a", "mount_path": "/mnt/a"}],
430- [{"server": "10.0.0.2", "path": "/b", "mount_path": "/mnt/b"}])
431- ps = client._build_pod_body("pod-1", spec).kwargs["spec"].kwargs
432- vols = {v.kwargs["name"]: v.kwargs for v in ps["volumes"]}
433- assert set(vols) >= {"nfs-agent-0-0", "nfs-jiuwenbox-0-0"}
434- assert vols["nfs-agent-0-0"]["nfs"].kwargs == {
435- "server": "10.0.0.1", "path": "/a"}
436- assert vols["nfs-jiuwenbox-0-0"]["nfs"].kwargs == {
437- "server": "10.0.0.2", "path": "/b"}
438- 
439- 
440-def test_build_pod_body_nfs_sidecar_only_mount(client):
441- """仅 sidecar 挂 NFS(主容器不挂):卷照建、主容器零挂载(pod 级卷语义)。"""
442- sc = dict(JIUWENBOX, nfs_mounts=[
443- {"server": "10.0.0.1", "path": "/export", "mount_path": "/box/data"}])
444- sidecars = validate_sidecars([sc], container_name="agent",
445- sse_port=8086, container_port=8086)
446- spec = _base_spec(agent_nfs_mounts=None, sidecars=sidecars)
447- ps = client._build_pod_body("pod-1", spec).kwargs["spec"].kwargs
448- assert [v.kwargs["name"] for v in ps["volumes"]] == [
449- "hp-jiuwenbox-0-0", "nfs-jiuwenbox-0-0"]
450- assert ps["containers"][0].kwargs["volume_mounts"] is None
451- box_mounts = {m.kwargs["mount_path"]: m.kwargs
452- for m in ps["containers"][1].kwargs["volume_mounts"]}
453- assert box_mounts["/box/data"]["name"] == "nfs-jiuwenbox-0-0"
454-# pylint: enable=protected-access
455- 
456- 
457# -------------------------------------------------------------- envFrom 渲染396# -------------------------------------------------------------- envFrom 渲染
458 397 
459def test_build_pod_body_renders_main_env_from(client):398def test_build_pod_body_renders_main_env_from(client):
460 """主容器 envFrom:secretRef/configMapRef/prefix/optional 逐字段透传。"""399 """主容器 envFrom:secretRef/configMapRef/prefix/optional 逐字段透传。"""
461- spec = _base_spec(agent_env_from=[400+ spec = _base_spec(main_container=_main(env_from=[
462- {"prefix": "DB_", "secret_ref": {"name": "agent-secret", "optional": True}},401+ {"prefix": "DB_",
402+ "secret_ref": {"name": "agent-secret", "optional": True}},
463 {"config_map_ref": {"name": "agent-cm", "optional": False}},403 {"config_map_ref": {"name": "agent-cm", "optional": False}},
464- ])404+ ]))
465 pod = client._build_pod_body("pod-1", spec)405 pod = client._build_pod_body("pod-1", spec)
466 main = pod.kwargs["spec"].kwargs["containers"][0].kwargs406 main = pod.kwargs["spec"].kwargs["containers"][0].kwargs
467 env_from = main["env_from"]407 env_from = main["env_from"]
@@ -482,9 +422,8 @@ def test_build_pod_body_main_env_from_absent_is_none(client):
482 422 
483 423 
484def test_build_pod_body_renders_sidecar_env_from(client):424def test_build_pod_body_renders_sidecar_env_from(client):
485- box = dict(JIUWENBOX, env_from=[425+ box = dict(JIUWENBOX, env_from=[{"secret_ref": {"name": "box-secret"}}])
486- {"secret_ref": {"name": "box-secret"}}])426+ spec = _base_spec(sidecars=[box])
487- spec = _base_spec(sidecars=_sidecars([box]))
488 pod = client._build_pod_body("pod-1", spec)427 pod = client._build_pod_body("pod-1", spec)
489 box_container = pod.kwargs["spec"].kwargs["containers"][1].kwargs428 box_container = pod.kwargs["spec"].kwargs["containers"][1].kwargs
490 assert box_container["env_from"][0].kwargs["prefix"] is None429 assert box_container["env_from"][0].kwargs["prefix"] is None
@@ -510,42 +449,81 @@ def test_render_env_from_tolerates_corrupt_cache(client):
510 assert _render_env_from(c, [{"secret_ref": {"name": ""}}]) is None449 assert _render_env_from(c, [{"secret_ref": {"name": ""}}]) is None
511 450 
512 451 
452+# ---------------------------------------------- command/args(Pod 级 fsGroup)
453+ 
454+def test_build_pod_body_renders_command_args(client):
455+ """command/args 覆盖,主/sidecar 一致生效(缺省走镜像 ENTRYPOINT/CMD)。"""
456+ spec = _base_spec(main_container=_main(
457+ command=["/bin/agent"], args=["--port", "8086"]))
458+ main = client._build_pod_body("pod-1", spec).kwargs["spec"].kwargs[
459+ "containers"][0].kwargs
460+ assert main["command"] == ["/bin/agent"]
461+ assert main["args"] == ["--port", "8086"]
462+ # 缺省:不设键(镜像入口生效)
463+ plain = client._build_pod_body(
464+ "pod-1", _base_spec()).kwargs["spec"].kwargs["containers"][0].kwargs
465+ assert "command" not in plain and "args" not in plain
466+ # sidecar 同样生效(2026-09-11 双角色开放;不再静默吞键)
467+ sc = dict(JIUWENBOX, command=["/bin/box"], args=["--box-flag"])
468+ pod = client._build_pod_body("pod-1", _base_spec(sidecars=[sc]))
469+ box = pod.kwargs["spec"].kwargs["containers"][1].kwargs
470+ assert box["command"] == ["/bin/box"]
471+ assert box["args"] == ["--box-flag"]
472+ 
473+ 
474+def test_build_pod_body_renders_pod_fs_group(client):
475+ """模板级 fsGroup → Pod securityContext.fsGroup(NFS 卷属主官方修法)。"""
476+ spec = _base_spec(fs_group=2000)
477+ pod = client._build_pod_body("pod-1", spec)
478+ assert pod.kwargs["spec"].kwargs["security_context"].kwargs == {
479+ "fs_group": 2000}
480+ # 缺省:不设
481+ pod2 = client._build_pod_body("pod-1", _base_spec())
482+ assert pod2.kwargs["spec"].kwargs["security_context"] is None
483+ 
484+ 
485+def test_build_pod_body_nfs_shared_across_containers(client):
486+ """同 server+path 的 NFS 共享跨主/sidecar 只建一个卷,复用卷名。"""
487+ sc = dict(JIUWENBOX, nfs_mounts=[
488+ {"server": "nfs.example", "path": "/export",
489+ "mount_path": "/var/lib/box", "read_only": True}])
490+ pod = client._build_pod_body("pod-1", _base_spec(sidecars=[sc]))
491+ ps = pod.kwargs["spec"].kwargs
492+ nfs_vols = [v for v in ps["volumes"]
493+ if "nfs" in v.kwargs]
494+ assert len(nfs_vols) == 1
495+ assert nfs_vols[0].kwargs["name"] == "nfs-agent-0-0" # 首现=主容器(idx 0)
496+ assert nfs_vols[0].kwargs["nfs"].kwargs == {
497+ "server": "nfs.example", "path": "/export"}
498+ box_mounts = {m.kwargs["mount_path"]: m.kwargs
499+ for m in ps["containers"][1].kwargs["volume_mounts"]}
500+ assert box_mounts["/var/lib/box"]["name"] == "nfs-agent-0-0"
501+ assert box_mounts["/var/lib/box"]["read_only"] is True # mount 级原样
502+ 
503+ 
513# -------------------------------------------------------------- 跨容器同源卷共享504# -------------------------------------------------------------- 跨容器同源卷共享
514 505 
515# pylint: disable=protected-access506# pylint: disable=protected-access
516 507 
517 508 
518-def _hp_volumes(pod) -> list:509+def _prefix_volumes(pod, prefix: str) -> list:
519- """收集 Pod 级 hp- 前缀卷(简单过滤,保持调用方断言聚焦)。"""510+ """收集 Pod 级指定前缀卷(简单过滤,保持调用方断言聚焦)。"""
520- out = []511+ return [v for v in pod.kwargs["spec"].kwargs["volumes"]
521- for v in pod.kwargs["spec"].kwargs["volumes"]:512+ if v.kwargs["name"].startswith(f"{prefix}-")]
522- if v.kwargs["name"].startswith("hp-"):
523- out.append(v)
524- return out
525- 
526- 
527-def _cm_volumes(pod) -> list:
528- """收集 Pod 级 cm- 前缀卷(简单过滤,保持调用方断言聚焦)。"""
529- out = []
530- for v in pod.kwargs["spec"].kwargs["volumes"]:
531- if v.kwargs["name"].startswith("cm-"):
532- out.append(v)
533- return out
534 513 
535 514 
536def test_build_pod_body_dedupes_shared_hostpath_across_containers(client):515def test_build_pod_body_dedupes_shared_hostpath_across_containers(client):
537- """主容器与 sidecar 引用同一 hostPath(同 path+type)→ Pod 级只建一个卷,两侧 volumeMounts 复用同一卷名(对齐 pvc_seen/nfs_seen 语义)。"""516+ """主容器与 sidecar 同 hostPath(同 path+type)→ 一个 Pod 级卷,两侧复用。"""
538 shared_hp = [{"host_path": "/root/chenhui/jiuwenclaw",517 shared_hp = [{"host_path": "/root/chenhui/jiuwenclaw",
539 "mount_path": "/app/jiuwenswarm", "read_only": False,518 "mount_path": "/app/jiuwenswarm", "read_only": False,
540 "host_path_type": "Directory"}]519 "host_path_type": "Directory"}]
541- sc = dict(JIUWENBOX)520+ sc = dict(JIUWENBOX, host_path_mounts=shared_hp) # 与主容器同源
542- sc["host_path_mounts"] = shared_hp # 与主容器同源521+ spec = _base_spec(main_container=_main(host_path_mounts=shared_hp),
543- spec = _base_spec(agent_host_path_mounts=shared_hp,522+ sidecars=[sc])
544- sidecars=_sidecars([sc]))
545 pod = client._build_pod_body("pod-1", spec)523 pod = client._build_pod_body("pod-1", spec)
546 pod_spec = pod.kwargs["spec"].kwargs524 pod_spec = pod.kwargs["spec"].kwargs
547 525 
548- hp_vols = _hp_volumes(pod)526+ hp_vols = _prefix_volumes(pod, "hp")
549 assert len(hp_vols) == 1527 assert len(hp_vols) == 1
550 vol_name = hp_vols[0].kwargs["name"]528 vol_name = hp_vols[0].kwargs["name"]
551 assert hp_vols[0].kwargs["host_path"].kwargs == {529 assert hp_vols[0].kwargs["host_path"].kwargs == {
@@ -562,13 +540,13 @@ def test_build_pod_body_hostpath_differs_by_type_not_shared(client):
562 """同 path 不同 host_path_type:卷定义不同 → 不共享。"""540 """同 path 不同 host_path_type:卷定义不同 → 不共享。"""
563 hp_a = [{"host_path": "/data", "mount_path": "/a", "read_only": False,541 hp_a = [{"host_path": "/data", "mount_path": "/a", "read_only": False,
564 "host_path_type": "Directory"}]542 "host_path_type": "Directory"}]
565- sc = dict(JIUWENBOX)543+ sc = dict(JIUWENBOX, host_path_mounts=[
566- sc["host_path_mounts"] = [{"host_path": "/data", "mount_path": "/b",544+ {"host_path": "/data", "mount_path": "/b", "read_only": False,
567- "read_only": False, "host_path_type": None}]545+ "host_path_type": None}])
568- spec = _base_spec(agent_host_path_mounts=hp_a,546+ spec = _base_spec(main_container=_main(host_path_mounts=hp_a),
569- sidecars=_sidecars([sc]))547+ sidecars=[sc])
570 pod = client._build_pod_body("pod-1", spec)548 pod = client._build_pod_body("pod-1", spec)
571- assert len(_hp_volumes(pod)) == 2549+ assert len(_prefix_volumes(pod, "hp")) == 2
572 550 
573 551 
574def test_build_pod_body_dedupes_shared_configmap_across_containers(client):552def test_build_pod_body_dedupes_shared_configmap_across_containers(client):
@@ -576,21 +554,20 @@ def test_build_pod_body_dedupes_shared_configmap_across_containers(client):
576 cm = [{"config_map_name": "app-config", "mount_path": "/etc/app",554 cm = [{"config_map_name": "app-config", "mount_path": "/etc/app",
577 "read_only": True, "sub_path": None,555 "read_only": True, "sub_path": None,
578 "items": [{"key": "a", "path": "a"}]}]556 "items": [{"key": "a", "path": "a"}]}]
579- sc = dict(JIUWENBOX)557+ sc = dict(JIUWENBOX, configmap_mounts=cm)
580- sc["configmap_mounts"] = cm558+ spec = _base_spec(main_container=_main(configmap_mounts=cm),
581- spec = _base_spec(agent_configmap_mounts=cm, sidecars=_sidecars([sc]))559+ sidecars=[sc])
582 pod = client._build_pod_body("pod-1", spec)560 pod = client._build_pod_body("pod-1", spec)
583- assert len(_cm_volumes(pod)) == 1561+ assert len(_prefix_volumes(pod, "cm")) == 1
584 562 
585- sc_diff = dict(JIUWENBOX)563+ sc_diff = dict(JIUWENBOX, configmap_mounts=[
586- sc_diff["configmap_mounts"] = [{"config_map_name": "app-config",564+ {"config_map_name": "app-config", "mount_path": "/etc/app2",
587- "mount_path": "/etc/app2", "read_only": True,565+ "read_only": True, "sub_path": None,
588- "sub_path": None,566+ "items": [{"key": "b", "path": "b"}]}])
589- "items": [{"key": "b", "path": "b"}]}]567+ spec2 = _base_spec(main_container=_main(configmap_mounts=cm),
590- spec2 = _base_spec(agent_configmap_mounts=cm,568+ sidecars=[sc_diff])
591- sidecars=_sidecars([sc_diff]))
592 pod2 = client._build_pod_body("pod-2", spec2)569 pod2 = client._build_pod_body("pod-2", spec2)
593- assert len(_cm_volumes(pod2)) == 2570+ assert len(_prefix_volumes(pod2, "cm")) == 2
594 571 
595 572 
596# pylint: enable=protected-access573# pylint: enable=protected-access
@@ -58,8 +58,9 @@ class _SlowK8s:
58def nat_cfg():58def nat_cfg():
59 """合法 pod_spec(autoscale 预热依赖 pod_spec_json 非空)。"""59 """合法 pod_spec(autoscale 预热依赖 pod_spec_json 非空)。"""
60 return {60 return {
61- "agent_image": "agentserver:1.0", "namespace": "default",61+ "main_container": {"name": "agent", "image": "agentserver:1.0",
62- "sse_port": 8080, "sse_path": "/sse",62+ "ports": [{"name": "sse", "container_port": 8080}]},
63+ "namespace": "default", "sse_path": "/sse",
63 }64 }
64 65 
65 66 
@@ -174,7 +175,7 @@ async def test_acquire_no_config_is_bounded(rm_state, k8s, monkeypatch):
174 t0 = time.monotonic()175 t0 = time.monotonic()
175 with pytest.raises(DeployFailed, match="no pool config"):176 with pytest.raises(DeployFailed, match="no pool config"):
176 await orchestrator.acquire(177 await orchestrator.acquire(
177- SCOPE, {"agent_image": "agentserver:1.0"},178+ SCOPE, {"main_container": {"name": "agent", "image": "agentserver:1.0"}},
178 {"max_pods": 2}, request_id="req-nc",179 {"max_pods": 2}, request_id="req-nc",
179 )180 )
180 assert time.monotonic() - t0 < 2, "no_config 重跑应有界退出"181 assert time.monotonic() - t0 < 2, "no_config 重跑应有界退出"
@@ -278,7 +278,8 @@ async def test_update_pool_config_with_pod_spec_refreshes_deploy_ver(runtime):
278 278 
279 from agent_runtime.session_manager.models import Template279 from agent_runtime.session_manager.models import Template
280 280 
281- new_template = Template(template_id="tpl-1", agent_image="agentserver:9.0")281+ new_template = Template(template_id="tpl-1", main_container={
282+ "name": "agent", "image": "agentserver:9.0"})
282 await runtime.rm_facade.update_pool_config(283 await runtime.rm_facade.update_pool_config(
283 SCOPE, {"min_idle_pods": 0, "max_pods": 2, "pod_ttl": 300},284 SCOPE, {"min_idle_pods": 0, "max_pods": 2, "pod_ttl": 300},
284 pod_spec=new_template.deploy_subset(),285 pod_spec=new_template.deploy_subset(),
@@ -528,6 +528,32 @@ _SIDECAR = {
528 "readiness_probe_type": "tcp",528 "readiness_probe_type": "tcp",
529}529}
530 530 
531+# _SIDECAR 经 wire → canonical 的期望形态(13 键全填满)
532+_SIDECAR_CANONICAL = {
533+ "name": "jiuwenbox",
534+ "image": "jiuwenbox-amd64:0.0.1",
535+ "image_pull_policy": "IfNotPresent",
536+ "command": None,
537+ "args": None,
538+ "ports": [{"name": None, "container_port": 8321}],
539+ "env": {"JIUWENBOX_LISTEN": "tcp://0.0.0.0:8321"},
540+ "env_from": None,
541+ "resources": {"cpu_request": None, "memory_request": None,
542+ "cpu_limit": None, "memory_limit": None},
543+ "security_context": {"run_as_user": None, "run_as_group": None,
544+ "privileged": True,
545+ "capabilities_add": ["NET_ADMIN", "SYS_ADMIN"],
546+ "capabilities_drop": [],
547+ "seccomp_unconfined": True,
548+ "apparmor_unconfined": True},
549+ "host_path_mounts": [{"host_path": "/sys/fs/cgroup",
550+ "mount_path": "/sys/fs/cgroup",
551+ "read_only": False, "host_path_type": None}],
552+ "configmap_mounts": [], "pvc_mounts": [], "nfs_mounts": [],
553+ "readiness_probe": {"probe_type": "tcp", "path": "/health",
554+ "initial_delay": 5, "period": 10, "timeout": 3},
555+}
556+ 
531 557 
532@requires_lua558@requires_lua
533async def test_config_sync_persists_and_roundtrips_sidecars(runtime):559async def test_config_sync_persists_and_roundtrips_sidecars(runtime):
@@ -537,30 +563,7 @@ async def test_config_sync_persists_and_roundtrips_sidecars(runtime):
537 [_scope(SCOPE, "tpl-box")],563 [_scope(SCOPE, "tpl-box")],
538 ))564 ))
539 t = await runtime.config_store.get_template("tpl-box")565 t = await runtime.config_store.get_template("tpl-box")
540- assert t.sidecars == [{566+ assert t.sidecars == [_SIDECAR_CANONICAL]
541- "name": "jiuwenbox",
542- "image": "jiuwenbox-amd64:0.0.1",
543- "port": 8321,
544- "env": {"JIUWENBOX_LISTEN": "tcp://0.0.0.0:8321"},
545- "image_pull_policy": "IfNotPresent",
546- "cpu_request": None, "memory_request": None,
547- "cpu_limit": None, "memory_limit": None,
548- "privileged": True,
549- "capabilities_add": ["SYS_ADMIN", "NET_ADMIN"],
550- "capabilities_drop": [],
551- "seccomp_unconfined": True,
552- "apparmor_unconfined": True,
553- "run_as_user": None, "run_as_group": None,
554- "host_path_mounts": [{"host_path": "/sys/fs/cgroup",
555- "mount_path": "/sys/fs/cgroup",
556- "read_only": False, "host_path_type": None}],
557- "configmap_mounts": [], "pvc_mounts": [],
558- "readiness_probe_type": "tcp",
559- "readiness_path": "/health",
560- "readiness_initial_delay": 5,
561- "readiness_period": 10,
562- "readiness_timeout_seconds": 3,
563- }]
564 # deploy_subset 携带 sidecars;json 可序列化(RM 缓存 pod_spec_json 用)567 # deploy_subset 携带 sidecars;json 可序列化(RM 缓存 pod_spec_json 用)
565 subset = t.deploy_subset()568 subset = t.deploy_subset()
566 import json569 import json
@@ -600,11 +603,12 @@ async def test_config_sync_persists_and_roundtrips_pod_placing_fields(runtime):
600 [_scope(SCOPE, "tpl-pin")],603 [_scope(SCOPE, "tpl-pin")],
601 ))604 ))
602 t = await runtime.config_store.get_template("tpl-pin")605 t = await runtime.config_store.get_template("tpl-pin")
603- assert (t.node_name, t.run_as_user, t.run_as_group) == (606+ secctx = t.main_container["security_context"]
604- "ecs-38b3-0001", 1000, 1000)607+ assert (t.node_name, secctx["run_as_user"],
608+ secctx["run_as_group"]) == ("ecs-38b3-0001", 1000, 1000)
605 subset = t.deploy_subset()609 subset = t.deploy_subset()
606- assert (subset["node_name"], subset["run_as_user"],610+ assert subset["node_name"] == "ecs-38b3-0001"
607- subset["run_as_group"]) == ("ecs-38b3-0001", 1000, 1000)611+ assert subset["main_container"]["security_context"]["run_as_user"] == 1000
608 612 
609 613 
610@requires_lua614@requires_lua
@@ -696,8 +700,12 @@ async def test_config_sync_sidecars_removal_triggers_a_class_sunset(runtime):
696 assert runtime.k8s.deployed_specs[-1]["sidecars"] is None700 assert runtime.k8s.deployed_specs[-1]["sidecars"] is None
697 701 
698 702 
699-def test_template_from_row_normalizes_sidecars():703+def test_template_from_row_rejects_legacy_inline_rows():
700- """DB 行兜底:sidecars None/[]/坏值 → Template.sidecars 统一 None。"""704+ """legacy 内联行(无 main_container_id)不再水合 → None(fail-closed)。
705+ 
706+ wire 已三段式独占,此类行 = 拆分后未收敛的残骸,重放 config_sync 收敛;
707+ 容错归一职责移至 containers.normalize_*(见 test_containers)。
708+ """
701 from types import SimpleNamespace709 from types import SimpleNamespace
702 710 
703 from agent_runtime.session_manager.config_store import (711 from agent_runtime.session_manager.config_store import (
@@ -705,42 +713,54 @@ def test_template_from_row_normalizes_sidecars():
705 template_from_row,713 template_from_row,
706 )714 )
707 715 
708- def _row(sidecars_value):716+ base = {column: None for column in _COLUMN_OF.values()}
709- base = {column: None for column in _COLUMN_OF.values()}717+ base.update(agent_image="img:1", sidecars=[dict(_SIDECAR)])
710- base.update(agent_image="img:1", sidecars=sidecars_value)718+ assert template_from_row(SimpleNamespace(**base)) is None
711- return SimpleNamespace(**base)
712 719 
713- assert template_from_row(_row(None)).sidecars is None720+ 
714- assert template_from_row(_row([])).sidecars is None721+def test_template_from_row_skips_corrupt_container_sections():
715- assert template_from_row(_row("garbage")).sidecars is None722+ """split 行 + 容器段落坏值(手改 DB)→ 水合校验失败 → None(fail-closed)。"""
716- assert template_from_row(_row([{"garbage": 1}])).sidecars is None723+ from types import SimpleNamespace
717- assert template_from_row(_row([dict(_SIDECAR)])).sidecars is not None724+ 
725+ from agent_runtime.session_manager.config_store import (
726+ _COLUMN_OF,
727+ template_from_row,
728+ )
729+ from agent_runtime.session_manager.container_spec import parse_container_spec
730+ 
731+ base = {column: None for column in _COLUMN_OF.values()}
732+ base.update(template_id="tpl-x", main_container_id="c-main-1")
733+ row = SimpleNamespace(**base)
734+ spec = parse_container_spec(
735+ {"container_id": "c-main-1", "image": "i:1"}, "c", role="main")
736+ # 挂载引用不存在的卷 → canonical 校验拒 → 整模板跳过
737+ bad = dict(spec, volume_mounts=[{"name": "ghost", "mount_path": "/g"}])
738+ assert template_from_row(row, {"c-main-1": bad}) is None
739+ # 干净容器(无挂载)→ 水合成功
740+ out = template_from_row(row, {"c-main-1": dict(spec)})
741+ assert out is not None and out.main_container["host_path_mounts"] == []
718 742 
719 743 
720@requires_lua744@requires_lua
721async def test_route_and_pool_push_carry_sidecars_end_to_end(runtime):745async def test_route_and_pool_push_carry_sidecars_end_to_end(runtime):
722 """端到端:seed(sidecars) → route → FakeK8s 收到的 pod_spec 含规范形 sidecars。"""746 """端到端:seed(sidecars) → route → FakeK8s 收到的 pod_spec 含规范形 sidecars。"""
723- from agent_runtime.sidecars import validate_sidecars
724- 
725 await runtime.seed_template(sidecars=[_SIDECAR])747 await runtime.seed_template(sidecars=[_SIDECAR])
726 result = await runtime.route("sess-e2e")748 result = await runtime.route("sess-e2e")
727 assert result["pod_id"]749 assert result["pod_id"]
728 750 
729- canonical = validate_sidecars([_SIDECAR], container_name="agent",751+ # FakeK8s 录制:deploy 真正收到 canonical sidecars
730- sse_port=8080, container_port=8080)
731- # FakeK8s 录制:deploy 真正收到 sidecars
732 assert runtime.k8s.deployed_specs, "FakeK8s.deployed_specs 未录制"752 assert runtime.k8s.deployed_specs, "FakeK8s.deployed_specs 未录制"
733- assert runtime.k8s.deployed_specs[0]["sidecars"] == canonical753+ assert runtime.k8s.deployed_specs[0]["sidecars"] == [_SIDECAR_CANONICAL]
734 # RM scope:config 缓存的 pod_spec_json 同样携带754 # RM scope:config 缓存的 pod_spec_json 同样携带
735 cfg = await runtime.rm_state.load_scope_config(SCOPE)755 cfg = await runtime.rm_state.load_scope_config(SCOPE)
736 import json756 import json
737 cached = json.loads(cfg["pod_spec_json"])757 cached = json.loads(cfg["pod_spec_json"])
738- assert cached["sidecars"] == canonical758+ assert cached["sidecars"] == [_SIDECAR_CANONICAL]
739 759 
740 760 
741@requires_lua761@requires_lua
742async def test_config_sync_roundtrips_agent_and_sidecar_mounts(runtime):762async def test_config_sync_roundtrips_agent_and_sidecar_mounts(runtime):
743- """主容器四类挂载 + sidecar cm/pvc/nfs 下发 → DB JSON 列回读 = 规范形。"""763+ """主容器三种挂载 + sidecar cm/pvc 下发 → DB JSON 列回读 = 规范形。"""
744 await runtime.config_store.config_sync(_payload(764 await runtime.config_store.config_sync(_payload(
745 [_tpl("tpl-mnt",765 [_tpl("tpl-mnt",
746 agent_host_path_mounts=[{"host_path": "/host/c", "mount_path": "/etc/host"}],766 agent_host_path_mounts=[{"host_path": "/host/c", "mount_path": "/etc/host"}],
@@ -748,38 +768,27 @@ async def test_config_sync_roundtrips_agent_and_sidecar_mounts(runtime):
748 "mount_path": "/etc/agent/config.yaml",768 "mount_path": "/etc/agent/config.yaml",
749 "sub_path": "config.yaml"}],769 "sub_path": "config.yaml"}],
750 agent_pvc_mounts=[{"claim_name": "agent-data", "mount_path": "/data"}],770 agent_pvc_mounts=[{"claim_name": "agent-data", "mount_path": "/data"}],
751- agent_nfs_mounts=[{"server": "10.0.0.1", "path": "/jiuwenclaw",
752- "mount_path": "/mnt/nfs"}],
753 sidecars=[dict(_SIDECAR,771 sidecars=[dict(_SIDECAR,
754 configmap_mounts=[{"config_map_name": "box-policy",772 configmap_mounts=[{"config_map_name": "box-policy",
755 "mount_path": "/etc/box/policy.yaml",773 "mount_path": "/etc/box/policy.yaml",
756- "sub_path": "policy.yaml"}],774+ "sub_path": "policy.yaml"}])])],
757- nfs_mounts=[{"server": "10.0.0.1",
758- "path": "/jiuwenclaw",
759- "mount_path": "/box/data"}])])],
760 [_scope(SCOPE, "tpl-mnt")],775 [_scope(SCOPE, "tpl-mnt")],
761 ))776 ))
762 t = await runtime.config_store.get_template("tpl-mnt")777 t = await runtime.config_store.get_template("tpl-mnt")
763- assert t.agent_host_path_mounts == [778+ main = t.main_container
779+ assert main["host_path_mounts"] == [
764 {"host_path": "/host/c", "mount_path": "/etc/host",780 {"host_path": "/host/c", "mount_path": "/etc/host",
765 "read_only": False, "host_path_type": None}]781 "read_only": False, "host_path_type": None}]
766- assert t.agent_configmap_mounts[0]["sub_path"] == "config.yaml"782+ assert main["configmap_mounts"][0]["sub_path"] == "config.yaml"
767- assert t.agent_configmap_mounts[0]["read_only"] is True783+ assert main["configmap_mounts"][0]["read_only"] is True
768- assert t.agent_pvc_mounts == [{"claim_name": "agent-data",784+ assert main["pvc_mounts"] == [{"claim_name": "agent-data",
769 "mount_path": "/data", "read_only": False}]785 "mount_path": "/data", "read_only": False}]
770- assert t.agent_nfs_mounts == [{"server": "10.0.0.1", "path": "/jiuwenclaw",
771- "mount_path": "/mnt/nfs", "read_only": False}]
772 assert t.sidecars[0]["configmap_mounts"][0]["config_map_name"] == "box-policy"786 assert t.sidecars[0]["configmap_mounts"][0]["config_map_name"] == "box-policy"
773- assert t.sidecars[0]["nfs_mounts"] == [{"server": "10.0.0.1",787+ # deploy_subset 携带 canonical 容器段,整体 json 可序列化
774- "path": "/jiuwenclaw",
775- "mount_path": "/box/data",
776- "read_only": False}]
777- # deploy_subset 携带四列表,整体 json 可序列化
778 subset = t.deploy_subset()788 subset = t.deploy_subset()
779 import json789 import json
780 json.loads(json.dumps(subset))790 json.loads(json.dumps(subset))
781- assert subset["agent_pvc_mounts"] == t.agent_pvc_mounts791+ assert subset["main_container"]["pvc_mounts"] == main["pvc_mounts"]
782- assert subset["agent_nfs_mounts"] == t.agent_nfs_mounts
783 792 
784 793 
785@requires_lua794@requires_lua
@@ -797,55 +806,6 @@ async def test_config_sync_agent_mount_change_is_a_class(runtime):
797 assert await runtime.sm_state.scope_pod_ids(SCOPE) == []806 assert await runtime.sm_state.scope_pod_ids(SCOPE) == []
798 807 
799 808 
800-def test_template_from_row_normalizes_agent_mounts():
801- """DB 行兜底:三种主容器挂载坏值 → None(同 sidecars 单点归一)。"""
802- from types import SimpleNamespace
803- 
804- from agent_runtime.session_manager.config_store import (
805- _COLUMN_OF,
806- template_from_row,
807- )
808- 
809- def _row(**kw):
810- base = {column: None for column in _COLUMN_OF.values()}
811- base.update(agent_image="img:1", **kw)
812- return SimpleNamespace(**base)
813- 
814- assert template_from_row(_row(agent_pvc_mounts="garbage")).agent_pvc_mounts is None
815- assert template_from_row(_row(agent_pvc_mounts=[])).agent_pvc_mounts is None
816- assert template_from_row(
817- _row(agent_pvc_mounts=[{"claim_name": "p", "mount_path": "/v"}])
818- ).agent_pvc_mounts == [{"claim_name": "p", "mount_path": "/v", "read_only": False}]
819- 
820- 
821-def test_template_from_row_legacy_nfs_triple_becomes_mounts():
822- """legacy 内联行的 NFS 三元组 → agent_nfs_mounts 规范形(RM 只认列表形态)。
823- 
824- mount_path 缺省落 "/data"(沿旧 RM 缺省);新契约行三元组列恒 NULL 不受影响。
825- """
826- from types import SimpleNamespace
827- 
828- from agent_runtime.session_manager.config_store import (
829- _COLUMN_OF,
830- template_from_row,
831- )
832- 
833- def _row(**kw):
834- base = {column: None for column in _COLUMN_OF.values()}
835- base.update(agent_image="img:1", **kw)
836- return SimpleNamespace(**base)
837- 
838- t = template_from_row(_row(nfs_server="10.0.0.1", nfs_path="/export",
839- nfs_mount_path="/mnt/nfs"))
840- assert t.agent_nfs_mounts == [{"server": "10.0.0.1", "path": "/export",
841- "mount_path": "/mnt/nfs", "read_only": False}]
842- t = template_from_row(_row(nfs_server="10.0.0.1"))
843- assert t.agent_nfs_mounts == [{"server": "10.0.0.1", "path": None,
844- "mount_path": "/data", "read_only": False}]
845- # 新契约(无三元组)零影响
846- assert template_from_row(_row()).agent_nfs_mounts is None
847- 
848- 
849# -------------------------------------------------------------- 三段式契约(容器表拆分)809# -------------------------------------------------------------- 三段式契约(容器表拆分)
850 810 
851def _main_container(container_id="c-main-1", **overrides) -> dict:811def _main_container(container_id="c-main-1", **overrides) -> dict:
@@ -876,7 +836,6 @@ async def test_split_contract_deploy_ver_identical_to_inline(runtime):
876 载荷不可再下发;双路径等价性在 2026-08-31 收紧前经实测锁定)。"""836 载荷不可再下发;双路径等价性在 2026-08-31 收紧前经实测锁定)。"""
877 from agent_runtime.session_manager.models import Template837 from agent_runtime.session_manager.models import Template
878 from agent_runtime.session_manager.routing import template_to_json838 from agent_runtime.session_manager.routing import template_to_json
879- from agent_runtime.sidecars import validate_sidecars
880 839 
881 # 三段式下发(含 sidecar + env + 资源 + 探针 + 挂载)840 # 三段式下发(含 sidecar + env + 资源 + 探针 + 挂载)
882 containers = [841 containers = [
@@ -900,21 +859,27 @@ async def test_split_contract_deploy_ver_identical_to_inline(runtime):
900 split_template = await runtime.config_store.get_template("tpl-x")859 split_template = await runtime.config_store.get_template("tpl-x")
901 assert split_template is not None860 assert split_template is not None
902 861 
903- # 逐字段等价的内联构造(等值基准)862+ # 逐字段等价的手构 canonical Template(等值基准;__post_init__ 归一到
863+ # 与三段式水合同一 canonical → deploy_ver/快照 JSON 逐字节相等)
904 inline_template = Template(864 inline_template = Template(
905 template_id="tpl-x",865 template_id="tpl-x",
906- agent_image="agentserver:1.0", sse_port=8086, container_port=8086,866+ main_container={
907- agent_env={"K": "v"}, agent_cpu_request="500m", run_as_user=1000,867+ "name": "agent", "image": "agentserver:1.0",
908- health_path="/api/v1/health", readiness_period=7,868+ "ports": [{"name": "sse", "container_port": 8086}],
909- agent_configmap_mounts=[{869+ "env": {"K": "v"},
910- "config_map_name": "agent-cm", "mount_path": "/etc/agent",870+ "resources": {"cpu_request": "500m"},
911- "sub_path": None, "items": None, "read_only": True}],871+ "security_context": {"run_as_user": 1000},
912- sidecars=validate_sidecars([{872+ "readiness_probe": {"probe_type": "http",
913- "name": "jiuwenbox", "image": "box:1", "port": 8321,873+ "path": "/api/v1/health", "period": 7},
914- "privileged": True,874+ "configmap_mounts": [{"config_map_name": "agent-cm",
915- "host_path_mounts": [{"host_path": "/h", "mount_path": "/m",875+ "mount_path": "/etc/agent"}],
916- "read_only": False, "host_path_type": None}]}],876+ },
917- container_name="agent", sse_port=8086, container_port=8086),877+ sidecars=[{
878+ "name": "jiuwenbox", "image": "box:1",
879+ "ports": [{"name": None, "container_port": 8321}],
880+ "security_context": {"privileged": True},
881+ "host_path_mounts": [{"host_path": "/h", "mount_path": "/m"}],
882+ }],
918 )883 )
919 assert split_template.deploy_ver() == inline_template.deploy_ver()884 assert split_template.deploy_ver() == inline_template.deploy_ver()
920 assert template_to_json(split_template) == template_to_json(inline_template)885 assert template_to_json(split_template) == template_to_json(inline_template)
@@ -955,7 +920,7 @@ async def test_container_image_change_updates_deploy_ver(runtime):
955 assert new.agent_image == "agentserver:2.0"920 assert new.agent_image == "agentserver:2.0"
956 # 最后一拍推送带新 pod_spec(RM 侧 pod_spec_json/deploy_ver 收敛)921 # 最后一拍推送带新 pod_spec(RM 侧 pod_spec_json/deploy_ver 收敛)
957 scope_id, pool, pod_spec = runtime.pool_pushes[-1]922 scope_id, pool, pod_spec = runtime.pool_pushes[-1]
958- assert pod_spec["agent_image"] == "agentserver:2.0"923+ assert pod_spec["main_container"]["image"] == "agentserver:2.0"
959 924 
960 925 
961@requires_lua926@requires_lua
@@ -998,7 +963,7 @@ async def test_container_shared_across_templates(runtime):
998 for tid in ("tpl-1", "tpl-2"):963 for tid in ("tpl-1", "tpl-2"):
999 t = await runtime.config_store.get_template(tid)964 t = await runtime.config_store.get_template(tid)
1000 assert t is not None965 assert t is not None
1001- assert t.agent_host_path_mounts == [966+ assert t.main_container["host_path_mounts"] == [
1002 {"host_path": "/h", "mount_path": "/m", "read_only": False,967 {"host_path": "/h", "mount_path": "/m", "read_only": False,
1003 "host_path_type": None}]968 "host_path_type": None}]
1004 assert [sc["name"] for sc in t.sidecars] == ["sharer"]969 assert [sc["name"] for sc in t.sidecars] == ["sharer"]
@@ -7,24 +7,22 @@ sidecars.py 既有规范形输出(逐字节);fused 挂载 == mounts.py 规范形
7 7 
8from __future__ import annotations8from __future__ import annotations
9 9 
10-import json
11 10 
12import pytest11import pytest
13 12 
13+from agent_runtime.containers import validate_pod_containers
14from agent_runtime.errors import InvalidParams14from agent_runtime.errors import InvalidParams
15from agent_runtime.session_manager.container_spec import (15from agent_runtime.session_manager.container_spec import (
16 MAIN_ROLE,16 MAIN_ROLE,
17 SIDECAR_ROLE,17 SIDECAR_ROLE,
18+ build_canonical,
18 canonical_volumes,19 canonical_volumes,
19 container_row_from_spec,20 container_row_from_spec,
20 container_spec_from_row,21 container_spec_from_row,
21 fuse_mounts,22 fuse_mounts,
22- main_template_kwargs,
23 parse_container_spec,23 parse_container_spec,
24- sidecar_wire_input,
25)24)
26from agent_runtime.session_manager.models import Template25from agent_runtime.session_manager.models import Template
27-from agent_runtime.sidecars import validate_sidecars
28 26 
29# K8s wire 全量主容器样例(与 wire 契约文档同形态)27# K8s wire 全量主容器样例(与 wire 契约文档同形态)
30MAIN_FULL = {28MAIN_FULL = {
@@ -32,6 +30,8 @@ MAIN_FULL = {
32 "name": "agent",30 "name": "agent",
33 "image": "agentserver:2.1",31 "image": "agentserver:2.1",
34 "imagePullPolicy": "IfNotPresent",32 "imagePullPolicy": "IfNotPresent",
33+ "command": ["/bin/agent", "--foreground"],
34+ "args": ["--port", "8086"],
35 "ports": [{"name": "sse", "containerPort": 8086},35 "ports": [{"name": "sse", "containerPort": 8086},
36 {"name": "http", "containerPort": 9000}],36 {"name": "http", "containerPort": 9000}],
37 "env": [{"name": "AGENT_HTTP_PORT", "value": "8086"}],37 "env": [{"name": "AGENT_HTTP_PORT", "value": "8086"}],
@@ -52,25 +52,6 @@ MAIN_VOLUMES = {
52 "persistentVolumeClaim": {"claimName": "agent-data"}},52 "persistentVolumeClaim": {"claimName": "agent-data"}},
53}53}
54 54 
55-# 与 MAIN_FULL 同值的 legacy 内联 sidecar(24 键形态,对照基准)
56-LEGACY_BOX = {
57- "name": "jiuwenbox",
58- "image": "jiuwenbox-amd64:0.0.1",
59- "port": 8321,
60- "env": {"JIUWENBOX_LISTEN": "tcp://0.0.0.0:8321"},
61- "cpu_request": "100m",
62- "memory_limit": "1Gi",
63- "privileged": True,
64- "capabilities_add": ["SYS_ADMIN", "NET_ADMIN"],
65- "seccomp_unconfined": True,
66- "apparmor_unconfined": True,
67- "host_path_mounts": [
68- {"host_path": "/sys/fs/cgroup", "mount_path": "/sys/fs/cgroup"}],
69- "readiness_probe_type": "tcp",
70- "readiness_initial_delay": 10,
71- "readiness_period": 5,
72-}
73- 
74K8S_BOX = {55K8S_BOX = {
75 "container_id": "c-box-1",56 "container_id": "c-box-1",
76 "name": "jiuwenbox",57 "name": "jiuwenbox",
@@ -95,108 +76,58 @@ BOX_VOLUMES = {
95}76}
96 77 
97 78 
98-def _validate_sidecars(items, **kw):
99- kw.setdefault("container_name", "agent")
100- kw.setdefault("sse_port", 8086)
101- kw.setdefault("container_port", 8086)
102- return validate_sidecars(items, **kw)
103- 
104- 
105# -------------------------------------------------------------- 主容器投影(承重)79# -------------------------------------------------------------- 主容器投影(承重)
106 80 
107-def test_main_container_all_fields_roundtrip():81+def test_main_container_defaults_equal_omitted():
108- spec = parse_container_spec(MAIN_FULL, "containers[0]", role=MAIN_ROLE)82+ """「显式给默认值」与「省略键」→ 同 canonical → 同 deploy_ver(指纹承重)。"""
109- volumes = canonical_volumes(list(MAIN_VOLUMES.values()), "volumes")83+ minimal = {"name": "agent", "image": "img:1"}
110- kwargs = main_template_kwargs(spec, volumes, "containers[0]")84+ explicit = build_canonical(parse_container_spec(
111- assert kwargs == {85+ {"container_id": "c", "image": "img:1"}, "c", role=MAIN_ROLE),
112- "container_name": "agent",86+ {}, "c", role=MAIN_ROLE)
113- "agent_image": "agentserver:2.1",87+ omitted = build_canonical(parse_container_spec(
114- "image_pull_policy": "IfNotPresent",88+ {"container_id": "c", "image": "img:1", "name": "agent",
115- "sse_port": 8086,89+ "imagePullPolicy": "IfNotPresent"}, "c", role=MAIN_ROLE),
116- "container_port": 9000,90+ {}, "c", role=MAIN_ROLE)
117- "agent_env": {"AGENT_HTTP_PORT": "8086"},91+ assert explicit == omitted
118- "agent_env_from": [92+ assert (Template(template_id="t", main_container=explicit).deploy_ver()
119- {"prefix": "DB_",93+ == Template(template_id="t",
120- "secret_ref": {"name": "agent-secret", "optional": False}},94+ main_container=dict(minimal)).deploy_ver())
121- {"prefix": None,
122- "config_map_ref": {"name": "agent-cm", "optional": True}}],
123- "agent_cpu_request": "500m",
124- "agent_memory_request": "1Gi",
125- "agent_cpu_limit": "2",
126- "agent_memory_limit": "4Gi",
127- "run_as_user": 1000,
128- "run_as_group": 1000,
129- "command": None,
130- "args": None,
131- "health_path": "/api/v1/health",
132- "readiness_initial_delay": 6,
133- "readiness_period": 7,
134- "agent_host_path_mounts": None,
135- "agent_configmap_mounts": None,
136- "agent_pvc_mounts": [
137- {"claim_name": "agent-data", "mount_path": "/var/lib/agent",
138- "read_only": False}],
139- "agent_nfs_mounts": [
140- {"server": "10.0.0.1", "path": "/export",
141- "mount_path": "/mnt/nfs", "read_only": False}],
142- }
143 95 
144 96 
145-def test_main_container_defaults_match_template_defaults():97+def test_main_ports_and_http_defaults():
146- """缺省落定与 Template 默认逐项相等 → 同值必同 deploy_ver(指纹承重)。"""
147- spec = parse_container_spec(
148- {"container_id": "c", "image": "img:1"}, "c", role=MAIN_ROLE)
149- kwargs = main_template_kwargs(spec, {}, "c")
150- defaults = Template(template_id="t")
151- for key, value in kwargs.items():
152- if key == "agent_image":
153- continue # 新契约必填(Template 缺省 "" 不适用)
154- assert value == getattr(defaults, key), key
155- assert (Template(template_id="t", **kwargs).deploy_ver()
156- == Template(template_id="t", agent_image="img:1").deploy_ver())
157- 
158- 
159-def test_main_http_port_defaults_to_sse():
160 spec = parse_container_spec(98 spec = parse_container_spec(
161 {"container_id": "c", "image": "i:1",99 {"container_id": "c", "image": "i:1",
162 "ports": [{"name": "sse", "containerPort": 8086}]},100 "ports": [{"name": "sse", "containerPort": 8086}]},
163 "c", role=MAIN_ROLE)101 "c", role=MAIN_ROLE)
164- assert main_template_kwargs(spec, {}, "c")["container_port"] == 8086102+ cont = build_canonical(spec, {}, "c", role=MAIN_ROLE)
103+ assert [p["name"] for p in cont["ports"]] == ["sse"]
104+ assert cont["ports"][0]["container_port"] == 8086
165 # sse 端口本身缺省 8080105 # sse 端口本身缺省 8080
166- spec2 = parse_container_spec({"container_id": "c", "image": "i:1"},106+ cont2 = build_canonical(parse_container_spec(
167- "c", role=MAIN_ROLE)107+ {"container_id": "c", "image": "i:1"}, "c", role=MAIN_ROLE),
168- kwargs2 = main_template_kwargs(spec2, {}, "c")108+ {}, "c", role=MAIN_ROLE)
169- assert kwargs2["sse_port"] == 8080 and kwargs2["container_port"] == 8080109+ assert cont2["ports"] == [{"name": "sse", "container_port": 8080}]
110+ # http 端口号 == sse → canonical 丢弃(RM 渲染同名端口去重的约定)
111+ cont3 = build_canonical(parse_container_spec(
112+ {"container_id": "c", "image": "i:1",
113+ "ports": [{"name": "sse", "containerPort": 8086},
114+ {"name": "http", "containerPort": 8086}]},
115+ "c", role=MAIN_ROLE), {}, "c", role=MAIN_ROLE)
116+ assert cont3["ports"] == [{"name": "sse", "container_port": 8086}]
117+ assert cont3 == cont
170 118 
171 119 
172# -------------------------------------------------------------- sidecar 投影(承重)120# -------------------------------------------------------------- sidecar 投影(承重)
173 121 
174-def test_sidecar_projection_byte_identical_to_canonical():
175- """K8s wire sidecar → 投影 == legacy 24 键输入的规范形,逐字节相等。"""
176- expected = _validate_sidecars([LEGACY_BOX])[0]
177- spec = parse_container_spec(K8S_BOX, "containers[1]", role=SIDECAR_ROLE)
178- volumes = canonical_volumes(list(BOX_VOLUMES.values()), "volumes")
179- projected = _validate_sidecars(
180- [sidecar_wire_input(spec, volumes, "containers[1]")])[0]
181- assert projected == expected
182- assert (json.dumps(projected, sort_keys=True, ensure_ascii=False)
183- == json.dumps(expected, sort_keys=True, ensure_ascii=False))
184- 
185- 
186def test_sidecar_empty_collections_survive_projection():122def test_sidecar_empty_collections_survive_projection():
187- """空集合语义:env 恒 dict、mounts/caps 恒 list(空也进指纹)。"""123+ """空集合语义:env 恒 dict、mounts/caps 恒 list(空也进指纹,恒为键)。"""
188- spec = parse_container_spec(124+ cont = build_canonical(parse_container_spec(
189 {"container_id": "c", "name": "box", "image": "x:1"},125 {"container_id": "c", "name": "box", "image": "x:1"},
190- "c", role=SIDECAR_ROLE)126+ "c", role=SIDECAR_ROLE), {}, "c", role=SIDECAR_ROLE)
191- wire_input = sidecar_wire_input(spec, {}, "c")127+ assert cont["env"] == {}
192- assert wire_input["env"] == {}128+ assert cont["host_path_mounts"] == []
193- assert wire_input["host_path_mounts"] == []129+ assert cont["security_context"]["capabilities_add"] == []
194- assert wire_input["capabilities_add"] == []130+ assert cont["env_from"] is None # 全键携带(条件键已废除)
195- canonical = _validate_sidecars([wire_input])[0]
196- assert canonical["env"] == {}
197- assert canonical["host_path_mounts"] == []
198- assert canonical["capabilities_add"] == []
199- assert "env_from" not in canonical
200 131 
201 132 
202def test_sidecar_env_from_projected():133def test_sidecar_env_from_projected():
@@ -204,17 +135,31 @@ def test_sidecar_env_from_projected():
204 {"container_id": "c", "name": "box", "image": "x:1",135 {"container_id": "c", "name": "box", "image": "x:1",
205 "envFrom": [{"secretRef": {"name": "s"}}]},136 "envFrom": [{"secretRef": {"name": "s"}}]},
206 "c", role=SIDECAR_ROLE)137 "c", role=SIDECAR_ROLE)
207- canonical = _validate_sidecars([sidecar_wire_input(spec, {}, "c")])[0]138+ cont = build_canonical(spec, {}, "c", role=SIDECAR_ROLE)
208- assert canonical["env_from"] == [139+ assert cont["env_from"] == [
209 {"prefix": None, "secret_ref": {"name": "s", "optional": False}}]140 {"prefix": None, "secret_ref": {"name": "s", "optional": False}}]
210 141 
211 142 
143+def test_validate_pod_containers_accepts_built_canonical():
144+ """build_canonical 产物可直接过 validate_pod_containers(同直径收敛)。"""
145+ main = build_canonical(parse_container_spec(
146+ MAIN_FULL, "containers[0]", role=MAIN_ROLE),
147+ canonical_volumes(list(MAIN_VOLUMES.values()), "volumes"),
148+ "containers[0]", role=MAIN_ROLE)
149+ box = build_canonical(parse_container_spec(
150+ K8S_BOX, "containers[1]", role=SIDECAR_ROLE),
151+ canonical_volumes(list(BOX_VOLUMES.values()), "volumes"),
152+ "containers[1]", role=SIDECAR_ROLE)
153+ out_main, out_sidecars = validate_pod_containers(main, [box], "t")
154+ assert out_main == main and out_sidecars == [box]
155+ 
156+ 
212# -------------------------------------------------------------- wire 拒绝矩阵157# -------------------------------------------------------------- wire 拒绝矩阵
213 158 
214def test_unknown_container_keys_rejected():159def test_unknown_container_keys_rejected():
215- with pytest.raises(InvalidParams, match=r"unknown keys.*stdin"):160+ with pytest.raises(InvalidParams, match=r"unknown keys.*workingDir"):
216 parse_container_spec(161 parse_container_spec(
217- {"container_id": "c", "image": "i:1", "stdin": True},162+ {"container_id": "c", "image": "i:1", "workingDir": "/w"},
218 "containers[0]", role=MAIN_ROLE)163 "containers[0]", role=MAIN_ROLE)
219 164 
220 165 
@@ -284,18 +229,15 @@ def test_resource_rules_rejected(resources, match):
284 "c", role=MAIN_ROLE)229 "c", role=MAIN_ROLE)
285 230 
286 231 
287-def test_main_security_context_role_restriction():232+def test_main_security_context_full_parity():
288- """主容器 securityContext 只许 runAs 两键(越角色 400,防静默丢特权)。"""233+ """决策 B:主容器 securityContext 与 sidecar 同一白名单(特权面放开)。"""
289- with pytest.raises(InvalidParams, match=r"unknown keys.*privileged"):234+ spec = parse_container_spec(
290- parse_container_spec(235+ {"container_id": "c", "image": "i:1",
291- {"container_id": "c", "image": "i:1",236+ "securityContext": {"privileged": True,
292- "securityContext": {"privileged": True}},237+ "seccompProfile": {"type": "Unconfined"}}},
293- "c", role=MAIN_ROLE)238+ "c", role=MAIN_ROLE)
294- with pytest.raises(InvalidParams, match=r"seccompProfile"):239+ assert spec["security_context"]["privileged"] is True
295- parse_container_spec(240+ assert spec["security_context"]["seccomp_unconfined"] is True
296- {"container_id": "c", "image": "i:1",
297- "securityContext": {"seccompProfile": {"type": "Unconfined"}}},
298- "c", role=MAIN_ROLE)
299 241 
300 242 
301@pytest.mark.parametrize("profile,match", [243@pytest.mark.parametrize("profile,match", [
@@ -344,17 +286,15 @@ def test_main_probe_rules_rejected(probe, match):
344 "c", role=MAIN_ROLE)286 "c", role=MAIN_ROLE)
345 287 
346 288 
347-def test_sidecar_probe_defaults_match_sidecar_canonical():289+def test_sidecar_probe_defaults_match_canonical():
348- """sidecar 探针缺省(period=10/timeout=3)与 _canonical_sidecar 默认逐项相等。"""290+ """sidecar 探针缺省(probe_type None/period=10/timeout=3)。"""
349- spec = parse_container_spec(291+ cont = build_canonical(parse_container_spec(
350 {"container_id": "c", "name": "box", "image": "i:1",292 {"container_id": "c", "name": "box", "image": "i:1",
351 "ports": [{"containerPort": 8321}]},293 "ports": [{"containerPort": 8321}]},
352- "c", role=SIDECAR_ROLE)294+ "c", role=SIDECAR_ROLE), {}, "c", role=SIDECAR_ROLE)
353- canonical = _validate_sidecars([sidecar_wire_input(spec, {}, "c")])[0]295+ assert cont["readiness_probe"] == {"probe_type": None, "path": "/health",
354- assert canonical["readiness_probe_type"] is None296+ "initial_delay": 5, "period": 10,
355- assert canonical["readiness_initial_delay"] == 5297+ "timeout": 3}
356- assert canonical["readiness_period"] == 10
357- assert canonical["readiness_timeout_seconds"] == 3
358 298 
359 299 
360# -------------------------------------------------------------- 卷 join300# -------------------------------------------------------------- 卷 join
@@ -380,22 +320,22 @@ def test_volume_join_fused_mounts_canonical():
380 {"name": "nfs", "mountPath": "/mnt/nfs"},320 {"name": "nfs", "mountPath": "/mnt/nfs"},
381 {"name": "data", "mountPath": "/var/lib/agent"},321 {"name": "data", "mountPath": "/var/lib/agent"},
382 ]}, "c", role=MAIN_ROLE)322 ]}, "c", role=MAIN_ROLE)
383- kwargs = main_template_kwargs(spec, volumes, "c")323+ cont = build_canonical(spec, volumes, "c", role=MAIN_ROLE)
384- assert kwargs["agent_host_path_mounts"] == [324+ assert cont["host_path_mounts"] == [
385 {"host_path": "/mnt/host", "mount_path": "/zz", "read_only": False,325 {"host_path": "/mnt/host", "mount_path": "/zz", "read_only": False,
386 "host_path_type": "DirectoryOrCreate"}]326 "host_path_type": "DirectoryOrCreate"}]
387 # configMap:read_only 缺省 true、items 按 key 排序327 # configMap:read_only 缺省 true、items 按 key 排序
388- assert kwargs["agent_configmap_mounts"] == [328+ assert cont["configmap_mounts"] == [
389 {"config_map_name": "agent-cm", "mount_path": "/etc/agent",329 {"config_map_name": "agent-cm", "mount_path": "/etc/agent",
390 "sub_path": None,330 "sub_path": None,
391 "items": [{"key": "a", "path": "a.yaml"}, {"key": "b", "path": "b.yaml"}],331 "items": [{"key": "a", "path": "a.yaml"}, {"key": "b", "path": "b.yaml"}],
392 "read_only": True}]332 "read_only": True}]
393- assert kwargs["agent_pvc_mounts"] == [333+ assert cont["pvc_mounts"] == [
394 {"claim_name": "agent-data", "mount_path": "/var/lib/agent",334 {"claim_name": "agent-data", "mount_path": "/var/lib/agent",
395 "read_only": False}]335 "read_only": False}]
396- assert kwargs["agent_nfs_mounts"] == [336+ assert cont["nfs_mounts"] == [{"server": "10.0.0.1", "path": "/export",
397- {"server": "10.0.0.1", "path": "/export",337+ "mount_path": "/mnt/nfs",
398- "mount_path": "/mnt/nfs", "read_only": False}]338+ "read_only": False}]
399 339 
400 340 
401def test_volume_join_read_only_overrides():341def test_volume_join_read_only_overrides():
@@ -408,9 +348,9 @@ def test_volume_join_read_only_overrides():
408 {"name": "cfg", "mountPath": "/c", "readOnly": False},348 {"name": "cfg", "mountPath": "/c", "readOnly": False},
409 {"name": "hp", "mountPath": "/h2", "readOnly": True}]},349 {"name": "hp", "mountPath": "/h2", "readOnly": True}]},
410 "c", role=MAIN_ROLE)350 "c", role=MAIN_ROLE)
411- kwargs = main_template_kwargs(spec, volumes, "c")351+ cont = build_canonical(spec, volumes, "c", role=MAIN_ROLE)
412- assert kwargs["agent_configmap_mounts"][0]["read_only"] is False352+ assert cont["configmap_mounts"][0]["read_only"] is False
413- assert kwargs["agent_host_path_mounts"][0]["read_only"] is True353+ assert cont["host_path_mounts"][0]["read_only"] is True
414 354 
415 355 
416@pytest.mark.parametrize("volumes,mounts,where_role,match", [356@pytest.mark.parametrize("volumes,mounts,where_role,match", [
@@ -432,50 +372,36 @@ def test_volume_join_rules_rejected(volumes, mounts, where_role, match):
432 fuse_mounts(spec, canonical_volumes(volumes, "v"), "c", where_role)372 fuse_mounts(spec, canonical_volumes(volumes, "v"), "c", where_role)
433 373 
434 374 
375+ 
435def test_volume_join_nfs_same_as_pvc():376def test_volume_join_nfs_same_as_pvc():
436- """NFS 与 PVC 同构:主/sidecar 均可按名挂载,条数不限,readOnly 透传(K8s 语义)。"""377+ """NFS 与 PVC 同构(上游 bef82fc4 放宽):主/sidecar 均可挂、条数不限、
378+ readOnly 透传——K8s 语义,不再自设窄约束。"""
437 volumes = canonical_volumes([379 volumes = canonical_volumes([
438 {"name": "n1", "nfs": {"server": "10.0.0.1", "path": "/export"}},380 {"name": "n1", "nfs": {"server": "10.0.0.1", "path": "/export"}},
439 {"name": "n2", "nfs": {"server": "10.0.0.2"}},381 {"name": "n2", "nfs": {"server": "10.0.0.2"}},
440 ], "volumes")382 ], "volumes")
441- main_spec = parse_container_spec(383+ spec = parse_container_spec(
442 {"container_id": "c", "name": "agent", "image": "i:1",384 {"container_id": "c", "name": "agent", "image": "i:1",
443 "ports": [{"name": "sse", "containerPort": 8086}],385 "ports": [{"name": "sse", "containerPort": 8086}],
444- "volumeMounts": [{"name": "n1", "mountPath": "/mnt/n1", "readOnly": True},386+ "volumeMounts": [{"name": "n1", "mountPath": "/mnt/n1",
387+ "readOnly": True},
445 {"name": "n2", "mountPath": "/mnt/n2"}]},388 {"name": "n2", "mountPath": "/mnt/n2"}]},
446 "c", role=MAIN_ROLE)389 "c", role=MAIN_ROLE)
447- fused = fuse_mounts(main_spec, volumes, "c", MAIN_ROLE)390+ cont = build_canonical(spec, volumes, "c", role=MAIN_ROLE)
448- assert fused["nfs_mounts"] == [ # raw 条目保持 wire 顺序391+ assert cont["nfs_mounts"] == [ # 规范形按 mount_path 升序
449 {"server": "10.0.0.1", "path": "/export", "mount_path": "/mnt/n1",392 {"server": "10.0.0.1", "path": "/export", "mount_path": "/mnt/n1",
450 "read_only": True},393 "read_only": True},
451 {"server": "10.0.0.2", "path": None, "mount_path": "/mnt/n2",394 {"server": "10.0.0.2", "path": None, "mount_path": "/mnt/n2",
452 "read_only": False}]395 "read_only": False}]
453- kwargs = main_template_kwargs(main_spec, volumes, "c")396+ # sidecar 挂 NFS 合法
454- assert kwargs["agent_nfs_mounts"] == [ # 规范形按 mount_path 升序397+ sc_spec = parse_container_spec(
455- {"server": "10.0.0.1", "path": "/export", "mount_path": "/mnt/n1",398+ {"container_id": "c2", "name": "box", "image": "i:1",
456- "read_only": True},399+ "volumeMounts": [{"name": "n1", "mountPath": "/box/n1"}]},
457- {"server": "10.0.0.2", "path": None, "mount_path": "/mnt/n2",
458- "read_only": False}]
459- 
460- side_spec = parse_container_spec(
461- {"container_id": "c2", "name": "box", "image": "i:2",
462- "volumeMounts": [{"name": "n1", "mountPath": "/box/data"}]},
463 "c2", role=SIDECAR_ROLE)400 "c2", role=SIDECAR_ROLE)
464- wire_input = sidecar_wire_input(side_spec, volumes, "c2")401+ sc = build_canonical(sc_spec, volumes, "c2", role=SIDECAR_ROLE)
465- canonical = _validate_sidecars([wire_input])[0]402+ assert sc["nfs_mounts"] == [
466- assert canonical["nfs_mounts"] == [403+ {"server": "10.0.0.1", "path": "/export", "mount_path": "/box/n1",
467- {"server": "10.0.0.1", "path": "/export",404+ "read_only": False}]
468- "mount_path": "/box/data", "read_only": False}]
469- 
470- 
471-def test_sidecar_without_nfs_mounts_key_is_fingerprint_stable():
472- """未挂 NFS 的 sidecar 规范形不含 nfs_mounts 键(条件键,存量指纹零扰动)。"""
473- spec = parse_container_spec(K8S_BOX, "c-box-1", role=SIDECAR_ROLE)
474- canonical = _validate_sidecars(
475- [sidecar_wire_input(spec, canonical_volumes(list(BOX_VOLUMES.values()),
476- "v"), "c-box-1")])[0]
477- assert "nfs_mounts" not in canonical
478- 
479 405 
480@pytest.mark.parametrize("volumes,match", [406@pytest.mark.parametrize("volumes,match", [
481 ([{"hostPath": {"path": "/h"}}], r"DNS-1123"), # 缺 name407 ([{"hostPath": {"path": "/h"}}], r"DNS-1123"), # 缺 name
@@ -502,9 +428,9 @@ def test_container_row_roundtrip():
502 spec = parse_container_spec(MAIN_FULL, "c", role=MAIN_ROLE)428 spec = parse_container_spec(MAIN_FULL, "c", role=MAIN_ROLE)
503 row = container_row_from_spec(spec)429 row = container_row_from_spec(spec)
504 assert set(row) == {430 assert set(row) == {
505- "container_id", "name", "image", "image_pull_policy", "ports", "env",431+ "container_id", "name", "image", "image_pull_policy", "command",
506- "env_from", "resources", "volume_mounts", "security_context",432+ "args", "ports", "env", "env_from", "resources", "volume_mounts",
507- "command", "args", "readiness_probe"}433+ "security_context", "readiness_probe"}
508 from types import SimpleNamespace434 from types import SimpleNamespace
509 restored = container_spec_from_row(SimpleNamespace(**row))435 restored = container_spec_from_row(SimpleNamespace(**row))
510 assert restored == spec436 assert restored == spec
@@ -523,3 +449,87 @@ def test_container_spec_from_row_none_and_corrupt():
523 assert spec["ports"] is None and spec["env"] == {}449 assert spec["ports"] is None and spec["env"] == {}
524 assert spec["volume_mounts"] == []450 assert spec["volume_mounts"] == []
525 assert spec["readiness_probe"]["period"] == 10 # sidecar 缺省口径451 assert spec["readiness_probe"]["period"] == 10 # sidecar 缺省口径
452+ 
453+ 
454+# -------------------------------------------------------------- build_canonical(C2 内核)
455+ 
456+def test_build_canonical_main_golden():
457+ """wire 主容器 + volumes → canonical 黄金 dict(13 键,指纹/传输/渲染同形)。"""
458+ from agent_runtime.session_manager.container_spec import build_canonical
459+ spec = parse_container_spec(MAIN_FULL, "containers[0]", role=MAIN_ROLE)
460+ volumes = canonical_volumes(list(MAIN_VOLUMES.values()), "volumes")
461+ cont = build_canonical(spec, volumes, "containers[0]", role=MAIN_ROLE)
462+ assert cont == {
463+ "name": "agent",
464+ "image": "agentserver:2.1",
465+ "image_pull_policy": "IfNotPresent",
466+ "command": ["/bin/agent", "--foreground"],
467+ "args": ["--port", "8086"],
468+ "ports": [{"name": "sse", "container_port": 8086},
469+ {"name": "http", "container_port": 9000}],
470+ "env": {"AGENT_HTTP_PORT": "8086"},
471+ "env_from": [
472+ {"prefix": "DB_",
473+ "secret_ref": {"name": "agent-secret", "optional": False}},
474+ {"prefix": None,
475+ "config_map_ref": {"name": "agent-cm", "optional": True}}],
476+ "resources": {"cpu_request": "500m", "memory_request": "1Gi",
477+ "cpu_limit": "2", "memory_limit": "4Gi"},
478+ "host_path_mounts": [],
479+ "configmap_mounts": [],
480+ "pvc_mounts": [{"claim_name": "agent-data",
481+ "mount_path": "/var/lib/agent", "read_only": False}],
482+ "nfs_mounts": [{"server": "10.0.0.1", "path": "/export",
483+ "mount_path": "/mnt/nfs", "read_only": False}],
484+ "security_context": {"run_as_user": 1000, "run_as_group": 1000,
485+ "privileged": False, "capabilities_add": [],
486+ "capabilities_drop": [],
487+ "seccomp_unconfined": False,
488+ "apparmor_unconfined": False},
489+ "readiness_probe": {"probe_type": "http", "path": "/api/v1/health",
490+ "initial_delay": 6, "period": 7, "timeout": None},
491+ }
492+ 
493+ 
494+def test_build_canonical_defaults_and_idempotence():
495+ from agent_runtime.containers import MAIN_PROBE_DEFAULT
496+ from agent_runtime.session_manager.container_spec import build_canonical
497+ spec = parse_container_spec({"container_id": "c", "image": "x:1"},
498+ "containers[0]", role=MAIN_ROLE)
499+ cont = build_canonical(spec, {}, "containers[0]", role=MAIN_ROLE)
500+ assert cont["name"] == "agent"
501+ assert cont["ports"] == [{"name": "sse", "container_port": 8080}]
502+ assert cont["readiness_probe"] == dict(MAIN_PROBE_DEFAULT)
503+ assert cont["nfs_mounts"] == [] and cont["env_from"] is None
504+ assert cont["command"] is None and cont["args"] is None
505+ # 幂等:canonical 再过 build_canonical 的收口层不变
506+ from agent_runtime.containers import canonical_container
507+ assert canonical_container(cont, "w", role=MAIN_ROLE) == cont
508+ 
509+ 
510+def test_build_canonical_sidecar_matches_containers_module():
511+ """wire sidecar → canonical == containers.canonical_container 直构(同直径)。"""
512+ from agent_runtime.containers import canonical_container
513+ from agent_runtime.session_manager.container_spec import build_canonical
514+ spec = parse_container_spec(K8S_BOX, "containers[1]", role=SIDECAR_ROLE)
515+ volumes = canonical_volumes(list(BOX_VOLUMES.values()), "volumes")
516+ cont = build_canonical(spec, volumes, "containers[1]",
517+ role=SIDECAR_ROLE)
518+ assert cont == canonical_container({
519+ "name": "jiuwenbox",
520+ "image": "jiuwenbox-amd64:0.0.1",
521+ "ports": [{"name": None, "container_port": 8321}],
522+ "env": {"JIUWENBOX_LISTEN": "tcp://0.0.0.0:8321"},
523+ "resources": {"cpu_request": "100m", "memory_request": None,
524+ "cpu_limit": None, "memory_limit": "1Gi"},
525+ "host_path_mounts": [{"host_path": "/sys/fs/cgroup",
526+ "mount_path": "/sys/fs/cgroup"}],
527+ "security_context": {"run_as_user": None, "run_as_group": None,
528+ "privileged": True,
529+ "capabilities_add": ["NET_ADMIN", "SYS_ADMIN"],
530+ "capabilities_drop": [],
531+ "seccomp_unconfined": True,
532+ "apparmor_unconfined": True},
533+ "readiness_probe": {"probe_type": "tcp", "path": "/health",
534+ "initial_delay": 10, "period": 5, "timeout": 3},
535+ }, "w", role=SIDECAR_ROLE)