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