已合并
feat(containers): 新增单一容器规范形模块(主/sidecar 统一) 上游 fsgroup/command/args/nfs_mounts 吸收进 canonical #521
王明琦创建于 22 天前
feat(containers): 新增单一容器规范形模块(主/sidecar 统一) 上游 fsgroup/command/args/nfs_mounts 吸收进 canonical #521
已合并
共 30 个文件变更+2346-1879
| @@ -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 用例。 | ||
| @@ -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 | ||
| 45 | from ..errors import ConfigNotFound, ConfigSyncBusy, InvalidParams | 46 | from ..errors import ConfigNotFound, ConfigSyncBusy, InvalidParams |
| 46 | -from ..sidecars import validate_sidecars | ||
| 47 | from ..util import key_unsafe, now_ts, parse_datetime, s, utc_now | 47 | from ..util import key_unsafe, now_ts, parse_datetime, s, utc_now |
| 48 | from .container_spec import ( | 48 | from .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。 | ||
| 225 | TEMPLATE_LEVEL_FIELDS: tuple[str, ...] = ( | 214 | TEMPLATE_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 | ||
| 239 | def _scope_row(scope: RoutingScopeDef) -> dict[str, Any]: | 229 | def _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 | + | ||
| 254 | def template_from_row(row: Any, | 262 | def 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 None | 293 | + 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 None | 319 | 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), volumes | 446 | 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 | ||
| 569 | def _validate_pod_placing_fields(template_id: str, kwargs: dict[str, Any]) -> None: | 546 | def _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 None | 593 | 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文件内容审核中,请稍后刷新重试
| @@ -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 | ||