已合并
feat(containers): 新增单一容器规范形模块(主/sidecar 统一) 上游 fsgroup/command/args/nfs_mounts 吸收进 canonical #521
feat(containers): 新增单一容器规范形模块(主/sidecar 统一) 上游 fsgroup/command/args/nfs_mounts 吸收进 canonical #521
已合并
王明琦创建于 22 天前
共 30 个文件变更+2346-1879
Mapplications/agent_runtime/CLAUDE.md+1-1文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/docs/api/config-plane-api.md+2-2文件内容审核中,请稍后刷新重试
@@ -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 
@@ -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 用例。
Mapplications/agent_runtime/docs/feature/README.md+2-1文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/docs/spec/resource-manager.md+5-5文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/docs/spec/service-core.md+1-1文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/docs/spec/session-manager.md+6-6文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/scripts/load_test.py+58-8文件内容审核中,请稍后刷新重试
Aapplications/agent_runtime/src/agent_runtime/containers.py+726-0文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/src/agent_runtime/resource_manager/k8s.py+165-169文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/src/agent_runtime/resource_manager/sweeper.py+17-2文件内容审核中,请稍后刷新重试
@@ -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 层脱敏)。"""
Mapplications/agent_runtime/src/agent_runtime/session_manager/container_spec.py+53-129文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/src/agent_runtime/session_manager/models.py+50-66文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/src/agent_runtime/session_manager/routing.py+11-1文件内容审核中,请稍后刷新重试
@@ -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
Mapplications/agent_runtime/src/agent_runtime/spec_fields.py+14-25文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/resource_manager/test_k8s_io_timeouts.py+3-1文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/resource_manager/test_k8s_pod_body.py+189-212文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/resource_manager/test_resilience.py+4-3文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/resource_manager/test_rm_business.py+2-1文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/session_manager/test_config_store.py+96-131文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/session_manager/test_container_spec.py+188-178文件内容审核中,请稍后刷新重试
Aapplications/agent_runtime/tests/session_manager/test_containers.py+549-0文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/session_manager/test_mounts.py+22-20文件内容审核中,请稍后刷新重试
Mapplications/agent_runtime/tests/session_manager/test_routing.py+15-4文件内容审核中,请稍后刷新重试