已合并
feat: 部署工具+applications迁移:数据库读写、Redis读写、Kubernetes Pod操作接口demo #421
Wal1et创建于 8月14日
feat: 部署工具+applications迁移:数据库读写、Redis读写、Kubernetes Pod操作接口demo #421
已合并
共 14 个文件变更+1853-0
| @@ -0,0 +1,406 @@ | |||
| 1 | +# Runtime Capabilities | ||
| 2 | + | ||
| 3 | +`runtime_capabilities` 是基于 `openjiuwen_runtime.service` 构建的可部署应用,用于通过一组明确的 | ||
| 4 | +REST API 验证运行时框架的数据库、Redis、Envelope 解析和 Kubernetes Pod 管理能力。 | ||
| 5 | + | ||
| 6 | +它位于 `applications/`,属于可独立安装、启动和部署的应用;`service/examples/` 中的框架示例仍然 | ||
| 7 | +保持原样。应用复用 `service` 提供的 `App`、`SystemContext`、请求上下文、数据库抽象、Redis | ||
| 8 | +原语和 Kubernetes 抽象,不在应用目录中重复实现这些通用能力。 | ||
| 9 | + | ||
| 10 | +## 1. 功能范围 | ||
| 11 | + | ||
| 12 | +应用启动后提供 `/docs`、`/openapi.json`、`/health`,以及以下 8 个业务接口: | ||
| 13 | + | ||
| 14 | +| 分组 | 方法与路径 | 作用 | | ||
| 15 | +|---|---|---| | ||
| 16 | +| Database | `POST /api/db/write` | 创建或更新一条记录 | | ||
| 17 | +| Database | `POST /api/db/read` | 按 ID 查询记录 | | ||
| 18 | +| Redis | `POST /api/redis/write` | 写入带 TTL 的字符串值 | | ||
| 19 | +| Redis | `POST /api/redis/read` | 查询字符串值 | | ||
| 20 | +| Envelope | `POST /api/envelope/inspect` | 解析并返回 Envelope、Metadata 和请求上下文 | | ||
| 21 | +| Kubernetes | `POST /api/k8s/pod/create` | 按受限模板创建一个 Pod | | ||
| 22 | +| Kubernetes | `POST /api/k8s/pod/read` | 查询应用管理的 Pod | | ||
| 23 | +| Kubernetes | `POST /api/k8s/pod/delete` | 删除应用管理的 Pod | | ||
| 24 | + | ||
| 25 | +所有业务接口都接收完整的 OpenJiuwen Envelope。REST 路径和请求体中的 `type` 是同一个处理器 | ||
| 26 | +标识的两种表达:例如 `/api/db/write` 只接受 `type: "db/write"`。路径与 `type` 不一致时会被 | ||
| 27 | +FastAPI/OpenAPI 请求模型拒绝,未知路径返回 HTTP 404。 | ||
| 28 | + | ||
| 29 | +## 2. 运行模式 | ||
| 30 | + | ||
| 31 | +启动时必须显式指定一个模式: | ||
| 32 | + | ||
| 33 | +| 模式 | 数据库 | Redis | Kubernetes | 适用场景 | | ||
| 34 | +|---|---|---|---|---| | ||
| 35 | +| `local` | SQLite 文件 | FakeRedis | 内存中的 Fake Kubernetes | 本地开发、接口联调、自动化测试 | | ||
| 36 | +| `server` | 真实 MySQL | 真实 Redis | 真实 Kubernetes API | 服务器和集群能力验证 | | ||
| 37 | + | ||
| 38 | +模式由命令行 `--mode` 或部署脚本的第一个参数选择。应用会把它同步到 | ||
| 39 | +`RUNTIME_CAPABILITIES_MODE`。一个进程内不会混用真假资源:`local` 始终使用全部本地资源, | ||
| 40 | +`server` 始终要求真实 MySQL、Redis 和 Kubernetes。 | ||
| 41 | + | ||
| 42 | +`local` 模式中的 FakeRedis 和 Fake Kubernetes 数据仅在当前进程生命周期内存在;SQLite 数据 | ||
| 43 | +保存在 `RUNTIME_CAPABILITIES_SQLITE_PATH` 指向的文件中。`server` 模式启动时会校验数据库类型 | ||
| 44 | +必须为 `mysql`,并校验 Redis URL 已配置且合法,防止误以为正在验证真实资源而实际落到本地 | ||
| 45 | +替代实现。 | ||
| 46 | + | ||
| 47 | +## 3. 目录结构 | ||
| 48 | + | ||
| 49 | +```text | ||
| 50 | +applications/runtime_capabilities/ | ||
| 51 | +├── README.md | ||
| 52 | +├── pyproject.toml | ||
| 53 | +├── runtime_capabilities.local.env.example | ||
| 54 | +├── runtime_capabilities.server.env.example | ||
| 55 | +├── scripts/ | ||
| 56 | +│ └── deploy.sh | ||
| 57 | +├── src/runtime_capabilities/ | ||
| 58 | +│ ├── __init__.py | ||
| 59 | +│ ├── __main__.py | ||
| 60 | +│ ├── application.py | ||
| 61 | +│ └── cli.py | ||
| 62 | +└── tests/ | ||
| 63 | + └── test_application.py | ||
| 64 | +``` | ||
| 65 | + | ||
| 66 | +- `application.py`:配置策略、请求/响应模型、8 个 Handler 和应用工厂。 | ||
| 67 | +- `cli.py`:统一命令行入口,负责加载 dotenv、选择模式并启动 Uvicorn。 | ||
| 68 | +- `scripts/deploy.sh`:面向开发者的安装与启动辅助脚本。 | ||
| 69 | +- 两个 `*.env.example`:可提交的配置模板;实际 `.env.*.local` 文件不会进入 Git。 | ||
| 70 | +- `tests/`:配置单元测试和经 ASGI 发起真实 HTTP 请求的系统测试。 | ||
| 71 | + | ||
| 72 | +## 4. 本地启动 | ||
| 73 | + | ||
| 74 | +要求 Python `>=3.11.4`,并已安装 `uv`。在仓库根目录执行: | ||
| 75 | + | ||
| 76 | +```bash | ||
| 77 | +cp applications/runtime_capabilities/runtime_capabilities.local.env.example \ | ||
| 78 | + applications/runtime_capabilities/.env.development.local | ||
| 79 | + | ||
| 80 | +./applications/runtime_capabilities/scripts/deploy.sh local | ||
| 81 | +``` | ||
| 82 | + | ||
| 83 | +首次执行时,即使不手动复制模板,脚本也会创建默认的 | ||
| 84 | +`applications/runtime_capabilities/.env.development.local`。随后脚本会: | ||
| 85 | + | ||
| 86 | +1. 通过 `uv sync --extra local` 同步应用及本仓 `foundation`、`service` 依赖; | ||
| 87 | +2. 加载本地配置文件; | ||
| 88 | +3. 以单 worker 启动应用。 | ||
| 89 | + | ||
| 90 | +默认访问地址: | ||
| 91 | + | ||
| 92 | +- Swagger UI:`http://127.0.0.1:8090/docs` | ||
| 93 | +- OpenAPI:`http://127.0.0.1:8090/openapi.json` | ||
| 94 | +- 健康检查:`http://127.0.0.1:8090/health` | ||
| 95 | + | ||
| 96 | +也可以绕过脚本直接启动: | ||
| 97 | + | ||
| 98 | +```bash | ||
| 99 | +cd applications/runtime_capabilities | ||
| 100 | +uv sync --extra local | ||
| 101 | +uv run runtime-capabilities \ | ||
| 102 | + --mode local \ | ||
| 103 | + --env-file .env.development.local | ||
| 104 | +``` | ||
| 105 | + | ||
| 106 | +## 5. 服务器模式 | ||
| 107 | + | ||
| 108 | +先复制服务器模板,并按目标环境填写真实资源: | ||
| 109 | + | ||
| 110 | +```bash | ||
| 111 | +cp applications/runtime_capabilities/runtime_capabilities.server.env.example \ | ||
| 112 | + applications/runtime_capabilities/.env.production.local | ||
| 113 | +``` | ||
| 114 | + | ||
| 115 | +至少需要确认以下配置: | ||
| 116 | + | ||
| 117 | +```dotenv | ||
| 118 | +RUNTIME_CAPABILITIES_MODE=server | ||
| 119 | +RUNTIME_CAPABILITIES_KUBERNETES_NAMESPACE=runtime-capabilities | ||
| 120 | +RUNTIME_CAPABILITIES_KUBERNETES_POD_IMAGE=redis:7-alpine | ||
| 121 | +RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_USER=999 | ||
| 122 | +RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_GROUP=1000 | ||
| 123 | + | ||
| 124 | +OPENJIUWEN_SERVICE_DB_TYPE=mysql | ||
| 125 | +OPENJIUWEN_SERVICE_DB_HOST=mysql.example.internal | ||
| 126 | +OPENJIUWEN_SERVICE_DB_PORT=3306 | ||
| 127 | +OPENJIUWEN_SERVICE_DB_NAME=runtime_capabilities | ||
| 128 | +OPENJIUWEN_SERVICE_DB_USER=runtime_capabilities | ||
| 129 | +OPENJIUWEN_SERVICE_DB_PASSWORD=change-me | ||
| 130 | + | ||
| 131 | +OPENJIUWEN_SERVICE_REDIS_URL=redis://redis.example.internal:6379/0 | ||
| 132 | +OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX=runtime-capabilities | ||
| 133 | +``` | ||
| 134 | + | ||
| 135 | +然后在仓库根目录执行: | ||
| 136 | + | ||
| 137 | +```bash | ||
| 138 | +./applications/runtime_capabilities/scripts/deploy.sh server | ||
| 139 | +``` | ||
| 140 | + | ||
| 141 | +辅助脚本只同步依赖、加载配置并启动应用,不会创建 MySQL、Redis、Kubernetes namespace、 | ||
| 142 | +ServiceAccount 或 RBAC。外部基础设施应由环境管理员预先准备。 | ||
| 143 | + | ||
| 144 | +### 5.1 MySQL 要求 | ||
| 145 | + | ||
| 146 | +配置的 MySQL 账户需要具备目标数据库的连接权限,以及应用表的建表和 CRUD 权限。框架启动时会 | ||
| 147 | +确保数据库及 `runtime_capability_records` 表存在;若目标环境禁止应用创建数据库,应由管理员 | ||
| 148 | +预先创建 `OPENJIUWEN_SERVICE_DB_NAME` 指定的数据库,并授予必要的表级权限。 | ||
| 149 | + | ||
| 150 | +### 5.2 Kubernetes 连接与权限 | ||
| 151 | + | ||
| 152 | +应用只操作 `RUNTIME_CAPABILITIES_KUBERNETES_NAMESPACE` 指定 namespace 中、带有本应用管理标签 | ||
| 153 | +的 Pod。 | ||
| 154 | + | ||
| 155 | +- 应用运行在 Kubernetes 集群内时,客户端优先使用 Pod 的 ServiceAccount。 | ||
| 156 | +- 应用作为服务器本地进程运行时,通过 `KUBECONFIG` 指向可访问目标集群的 kubeconfig;未显式 | ||
| 157 | + 设置时,Kubernetes 客户端使用默认 kubeconfig 查找规则。 | ||
| 158 | +- 身份至少需要目标 namespace 中 Pod 的 `get`、`list`、`create`、`delete` 权限。 | ||
| 159 | + | ||
| 160 | +生产或共享测试环境中应按 namespace 最小授权,不要授予集群管理员权限。一个最小 Role 的规则 | ||
| 161 | +部分如下,具体 ServiceAccount 和 RoleBinding 名称由部署环境决定: | ||
| 162 | + | ||
| 163 | +```yaml | ||
| 164 | +rules: | ||
| 165 | + - apiGroups: [""] | ||
| 166 | + resources: ["pods"] | ||
| 167 | + verbs: ["get", "list", "create", "delete"] | ||
| 168 | +``` | ||
| 169 | + | ||
| 170 | +Pod 模板固定启用 `runAsNonRoot`、禁止提权并丢弃 Linux capabilities。如果所选镜像的镜像元数据 | ||
| 171 | +没有声明非 root 用户,可以同时配置: | ||
| 172 | + | ||
| 173 | +```dotenv | ||
| 174 | +RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_USER=<镜像内有效的非 root UID> | ||
| 175 | +RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_GROUP=<镜像内有效的非 root GID> | ||
| 176 | +``` | ||
| 177 | + | ||
| 178 | +UID 和 GID 必须成对配置,且应从所选镜像本身确认,不能把一个镜像的数值直接套用到另一个镜像。 | ||
| 179 | + | ||
| 180 | +### 5.3 对外访问 | ||
| 181 | + | ||
| 182 | +服务器模板默认监听 `0.0.0.0:8090`,方便通过受控网络、反向代理或端口转发访问。当前应用用于 | ||
| 183 | +能力验证,没有内置业务身份认证;共享环境应通过防火墙、内网策略、反向代理认证等方式限制 | ||
| 184 | +访问,尤其是 Kubernetes 写接口。 | ||
| 185 | + | ||
| 186 | +## 6. 在 Swagger UI 中验证接口 | ||
| 187 | + | ||
| 188 | +打开 `/docs`,展开接口,点击 **Try it out**,粘贴对应请求体并点击 **Execute**。以下请求均为 | ||
| 189 | +完整、可直接执行的 Envelope 示例。 | ||
| 190 | + | ||
| 191 | +### 6.1 写数据库:`POST /api/db/write` | ||
| 192 | + | ||
| 193 | +```json | ||
| 194 | +{ | ||
| 195 | + "type": "db/write", | ||
| 196 | + "metadata": { | ||
| 197 | + "request_id": "db-write-1" | ||
| 198 | + }, | ||
| 199 | + "rawdata": { | ||
| 200 | + "id": "record-1", | ||
| 201 | + "value": "hello database" | ||
| 202 | + }, | ||
| 203 | + "version": "1" | ||
| 204 | +} | ||
| 205 | +``` | ||
| 206 | + | ||
| 207 | +首次写入返回 `operation: "created"`;用相同 `id` 再次写入会更新记录并返回 | ||
| 208 | +`operation: "updated"`。 | ||
| 209 | + | ||
| 210 | +### 6.2 读数据库:`POST /api/db/read` | ||
| 211 | + | ||
| 212 | +```json | ||
| 213 | +{ | ||
| 214 | + "type": "db/read", | ||
| 215 | + "metadata": { | ||
| 216 | + "request_id": "db-read-1" | ||
| 217 | + }, | ||
| 218 | + "rawdata": { | ||
| 219 | + "id": "record-1" | ||
| 220 | + }, | ||
| 221 | + "version": "1" | ||
| 222 | +} | ||
| 223 | +``` | ||
| 224 | + | ||
| 225 | +存在时返回 `id`、`value`、`updated_at`;记录不存在时返回 HTTP 404 和统一错误信封。 | ||
| 226 | + | ||
| 227 | +### 6.3 写 Redis:`POST /api/redis/write` | ||
| 228 | + | ||
| 229 | +```json | ||
| 230 | +{ | ||
| 231 | + "type": "redis/write", | ||
| 232 | + "metadata": { | ||
| 233 | + "request_id": "redis-write-1" | ||
| 234 | + }, | ||
| 235 | + "rawdata": { | ||
| 236 | + "key": "message-1", | ||
| 237 | + "value": "hello redis", | ||
| 238 | + "ttl_seconds": 300 | ||
| 239 | + }, | ||
| 240 | + "version": "1" | ||
| 241 | +} | ||
| 242 | +``` | ||
| 243 | + | ||
| 244 | +`ttl_seconds` 可省略;省略时使用 | ||
| 245 | +`RUNTIME_CAPABILITIES_REDIS_DEFAULT_TTL_SECONDS`。返回中的 `backend_mode` 可用于确认当前访问的 | ||
| 246 | +是 `fake` 还是真实 `real` 后端。 | ||
| 247 | + | ||
| 248 | +### 6.4 读 Redis:`POST /api/redis/read` | ||
| 249 | + | ||
| 250 | +```json | ||
| 251 | +{ | ||
| 252 | + "type": "redis/read", | ||
| 253 | + "metadata": { | ||
| 254 | + "request_id": "redis-read-1" | ||
| 255 | + }, | ||
| 256 | + "rawdata": { | ||
| 257 | + "key": "message-1" | ||
| 258 | + }, | ||
| 259 | + "version": "1" | ||
| 260 | +} | ||
| 261 | +``` | ||
| 262 | + | ||
| 263 | +存在时返回 `found: true` 和 `value`;不存在时仍返回 HTTP 200,但 `found` 为 `false`、`value` | ||
| 264 | +为 `null`。 | ||
| 265 | + | ||
| 266 | +### 6.5 解析 Envelope:`POST /api/envelope/inspect` | ||
| 267 | + | ||
| 268 | +```json | ||
| 269 | +{ | ||
| 270 | + "type": "envelope/inspect", | ||
| 271 | + "metadata": { | ||
| 272 | + "request_id": "inspect-1", | ||
| 273 | + "user_id": "user-001", | ||
| 274 | + "chat_id": "chat-001", | ||
| 275 | + "session_id": "session-001", | ||
| 276 | + "bot_id": "bot-001", | ||
| 277 | + "channel": "swagger", | ||
| 278 | + "trace_id": "trace-001", | ||
| 279 | + "instance_id": "instance-001", | ||
| 280 | + "extra": { | ||
| 281 | + "tenant": "example-tenant", | ||
| 282 | + "source": "manual-test" | ||
| 283 | + } | ||
| 284 | + }, | ||
| 285 | + "rawdata": { | ||
| 286 | + "message": "inspect this envelope", | ||
| 287 | + "attributes": { | ||
| 288 | + "environment": "server" | ||
| 289 | + } | ||
| 290 | + }, | ||
| 291 | + "version": "1" | ||
| 292 | +} | ||
| 293 | +``` | ||
| 294 | + | ||
| 295 | +响应分为三部分: | ||
| 296 | + | ||
| 297 | +- `envelope`:框架解析后的完整 Envelope; | ||
| 298 | +- `context`:框架从 Metadata 建立的请求上下文字段,以及当前副本 `replica_id`; | ||
| 299 | +- `service`:应用模式和 Redis/Kubernetes 后端模式。 | ||
| 300 | + | ||
| 301 | +### 6.6 创建 Pod:`POST /api/k8s/pod/create` | ||
| 302 | + | ||
| 303 | +```json | ||
| 304 | +{ | ||
| 305 | + "type": "k8s/pod/create", | ||
| 306 | + "metadata": { | ||
| 307 | + "request_id": "pod-create-1" | ||
| 308 | + }, | ||
| 309 | + "rawdata": { | ||
| 310 | + "name": "capability-pod-1" | ||
| 311 | + }, | ||
| 312 | + "version": "1" | ||
| 313 | +} | ||
| 314 | +``` | ||
| 315 | + | ||
| 316 | +镜像、namespace 和安全上下文由服务端配置决定,调用方只能指定符合 DNS-1123 的 Pod 名称。 | ||
| 317 | +同名且由本应用管理的 Pod 已存在时返回 HTTP 409。 | ||
| 318 | + | ||
| 319 | +### 6.7 查询 Pod:`POST /api/k8s/pod/read` | ||
| 320 | + | ||
| 321 | +```json | ||
| 322 | +{ | ||
| 323 | + "type": "k8s/pod/read", | ||
| 324 | + "metadata": { | ||
| 325 | + "request_id": "pod-read-1" | ||
| 326 | + }, | ||
| 327 | + "rawdata": { | ||
| 328 | + "name": "capability-pod-1" | ||
| 329 | + }, | ||
| 330 | + "version": "1" | ||
| 331 | +} | ||
| 332 | +``` | ||
| 333 | + | ||
| 334 | +返回 `name`、`namespace`、`phase`、`ready` 和 `image`。真实集群中,创建接口刚返回时 Pod | ||
| 335 | +可能处于 `Pending`,镜像拉取并通过就绪检查后再查询会看到 `Running` 和 `ready: true`。 | ||
| 336 | + | ||
| 337 | +### 6.8 删除 Pod:`POST /api/k8s/pod/delete` | ||
| 338 | + | ||
| 339 | +```json | ||
| 340 | +{ | ||
| 341 | + "type": "k8s/pod/delete", | ||
| 342 | + "metadata": { | ||
| 343 | + "request_id": "pod-delete-1" | ||
| 344 | + }, | ||
| 345 | + "rawdata": { | ||
| 346 | + "name": "capability-pod-1" | ||
| 347 | + }, | ||
| 348 | + "version": "1" | ||
| 349 | +} | ||
| 350 | +``` | ||
| 351 | + | ||
| 352 | +`state` 可能为: | ||
| 353 | + | ||
| 354 | +- `delete_requested`:已向 Kubernetes 提交删除; | ||
| 355 | +- `deletion_in_progress`:Pod 已处于终止过程; | ||
| 356 | +- `already_absent`:Pod 已不存在。 | ||
| 357 | + | ||
| 358 | +## 7. 自动化测试 | ||
| 359 | + | ||
| 360 | +在仓库根目录执行: | ||
| 361 | + | ||
| 362 | +```bash | ||
| 363 | +uv sync --project applications/runtime_capabilities --all-extras | ||
| 364 | +uv run --project applications/runtime_capabilities pytest -q \ | ||
| 365 | + applications/runtime_capabilities/tests | ||
| 366 | +``` | ||
| 367 | + | ||
| 368 | +只运行配置等单元测试: | ||
| 369 | + | ||
| 370 | +```bash | ||
| 371 | +uv run --project applications/runtime_capabilities pytest -q -m unit \ | ||
| 372 | + applications/runtime_capabilities/tests | ||
| 373 | +``` | ||
| 374 | + | ||
| 375 | +只运行通过 ASGI/HTTP 验证全部接口、错误码和 OpenAPI 的系统测试: | ||
| 376 | + | ||
| 377 | +```bash | ||
| 378 | +uv run --project applications/runtime_capabilities pytest -q -m system \ | ||
| 379 | + applications/runtime_capabilities/tests | ||
| 380 | +``` | ||
| 381 | + | ||
| 382 | +自动化测试不会连接真实 MySQL、Redis 或 Kubernetes。真实资源验证应使用 `server` 模式,并在 | ||
| 383 | +受控 namespace 和专用测试数据范围内按第 6 节顺序执行。 | ||
| 384 | + | ||
| 385 | +## 8. 配置参考 | ||
| 386 | + | ||
| 387 | +### 应用配置 | ||
| 388 | + | ||
| 389 | +| 变量 | 默认值 | 说明 | | ||
| 390 | +|---|---|---| | ||
| 391 | +| `RUNTIME_CAPABILITIES_MODE` | `local` | `local` 或 `server`;脚本会按启动参数设置 | | ||
| 392 | +| `RUNTIME_CAPABILITIES_SERVICE_LABEL` | `runtime-capabilities` | Envelope 检查响应中的服务标识 | | ||
| 393 | +| `RUNTIME_CAPABILITIES_REDIS_DEFAULT_TTL_SECONDS` | `300` | Redis 写接口的默认 TTL,必须为正整数 | | ||
| 394 | +| `RUNTIME_CAPABILITIES_KUBERNETES_NAMESPACE` | `runtime-capabilities` | 允许管理 Pod 的 namespace | | ||
| 395 | +| `RUNTIME_CAPABILITIES_KUBERNETES_POD_IMAGE` | 见模板 | 创建 Pod 使用的固定镜像 | | ||
| 396 | +| `RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_USER` | 未设置 | 可选非 root UID,必须与 GID 成对配置 | | ||
| 397 | +| `RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_GROUP` | 未设置 | 可选非 root GID,必须与 UID 成对配置 | | ||
| 398 | +| `RUNTIME_CAPABILITIES_SQLITE_PATH` | `./runtime_capabilities.db` | 仅本地模式使用的 SQLite 文件 | | ||
| 399 | + | ||
| 400 | +### 框架配置 | ||
| 401 | + | ||
| 402 | +网络、数据库、Redis、锁和缓存使用 `OPENJIUWEN_SERVICE_*` 配置。完整定义以 | ||
| 403 | +`service/openjiuwen_runtime/service/config.py` 为准;本应用最常用的配置已列在两个环境模板中。 | ||
| 404 | + | ||
| 405 | +实际密码、token、证书和 kubeconfig 不应写入 README、模板或 Git,应只保存在受控的本地环境 | ||
| 406 | +文件或部署平台 Secret 中。 | ||
| @@ -0,0 +1,61 @@ | |||
| 1 | +[build-system] | ||
| 2 | +requires = ["setuptools>=61.0", "wheel"] | ||
| 3 | +build-backend = "setuptools.build_meta" | ||
| 4 | + | ||
| 5 | +[project] | ||
| 6 | +name = "runtime-capabilities" | ||
| 7 | +version = "0.1.0" | ||
| 8 | +description = "Deployable application for validating OpenJiuwen Runtime capabilities" | ||
| 9 | +requires-python = ">=3.11.4" | ||
| 10 | +dependencies = [ | ||
| 11 | + "openjiuwen-runtime-foundation==0.1.0", | ||
| 12 | + "openjiuwen-runtime-service==0.1.0", | ||
| 13 | + "pydantic==2.11.7", | ||
| 14 | + "python-dotenv>=1.0.0", | ||
| 15 | + "uvicorn[standard]==0.42.0", | ||
| 16 | +] | ||
| 17 | + | ||
| 18 | +[project.optional-dependencies] | ||
| 19 | +local = [ | ||
| 20 | + "fakeredis>=2.20", | ||
| 21 | +] | ||
| 22 | +server = [ | ||
| 23 | + "kubernetes_asyncio==35.0.0", | ||
| 24 | +] | ||
| 25 | + | ||
| 26 | +[project.scripts] | ||
| 27 | +runtime-capabilities = "runtime_capabilities.cli:main" | ||
| 28 | + | ||
| 29 | +[dependency-groups] | ||
| 30 | +dev = [ | ||
| 31 | + "fakeredis>=2.20", | ||
| 32 | + "httpx>=0.27", | ||
| 33 | + "pytest==8.3.5", | ||
| 34 | + "pytest-asyncio==1.2.0", | ||
| 35 | + "pytest-mock==3.14.0", | ||
| 36 | + "ruff==0.9.10", | ||
| 37 | +] | ||
| 38 | + | ||
| 39 | +[tool.uv] | ||
| 40 | +default-groups = ["dev"] | ||
| 41 | + | ||
| 42 | +[tool.uv.sources] | ||
| 43 | +openjiuwen-runtime-foundation = { path = "../../foundation", editable = true } | ||
| 44 | +openjiuwen-runtime-service = { path = "../../service", editable = true } | ||
| 45 | + | ||
| 46 | +[[tool.uv.index]] | ||
| 47 | +url = "https://mirrors.aliyun.com/pypi/simple/" | ||
| 48 | + | ||
| 49 | +[tool.setuptools.packages.find] | ||
| 50 | +where = ["src"] | ||
| 51 | +include = ["runtime_capabilities*"] | ||
| 52 | + | ||
| 53 | +[tool.pytest.ini_options] | ||
| 54 | +testpaths = ["tests"] | ||
| 55 | +pythonpath = ["src"] | ||
| 56 | +asyncio_mode = "auto" | ||
| 57 | +asyncio_default_fixture_loop_scope = "function" | ||
| 58 | +markers = [ | ||
| 59 | + "unit: isolated application tests", | ||
| 60 | + "system: end-to-end HTTP application tests", | ||
| 61 | +] | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | +RUNTIME_CAPABILITIES_MODE=local | ||
| 2 | +RUNTIME_CAPABILITIES_SERVICE_LABEL=runtime-capabilities-local | ||
| 3 | +RUNTIME_CAPABILITIES_REDIS_DEFAULT_TTL_SECONDS=300 | ||
| 4 | +RUNTIME_CAPABILITIES_KUBERNETES_NAMESPACE=runtime-capabilities-local | ||
| 5 | +RUNTIME_CAPABILITIES_KUBERNETES_POD_IMAGE=runtime-capabilities-pod:local | ||
| 6 | +RUNTIME_CAPABILITIES_SQLITE_PATH=./runtime_capabilities.db | ||
| 7 | + | ||
| 8 | +OPENJIUWEN_SERVICE_HOST=127.0.0.1 | ||
| 9 | +OPENJIUWEN_SERVICE_PORT=8090 | ||
| 10 | +OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX=runtime-capabilities-local | ||
| 11 | +OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS=30 | ||
| @@ -0,0 +1,27 @@ | |||
| 1 | +RUNTIME_CAPABILITIES_MODE=server | ||
| 2 | +RUNTIME_CAPABILITIES_SERVICE_LABEL=runtime-capabilities-server | ||
| 3 | +RUNTIME_CAPABILITIES_REDIS_DEFAULT_TTL_SECONDS=300 | ||
| 4 | +RUNTIME_CAPABILITIES_KUBERNETES_NAMESPACE=runtime-capabilities | ||
| 5 | +RUNTIME_CAPABILITIES_KUBERNETES_POD_IMAGE=redis:7-alpine | ||
| 6 | +RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_USER=999 | ||
| 7 | +RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_GROUP=1000 | ||
| 8 | + | ||
| 9 | +# Set this when the process runs outside Kubernetes and the default kubeconfig is not used. | ||
| 10 | +# KUBECONFIG=/path/to/kubeconfig | ||
| 11 | + | ||
| 12 | +OPENJIUWEN_SERVICE_HOST=0.0.0.0 | ||
| 13 | +OPENJIUWEN_SERVICE_PORT=8090 | ||
| 14 | + | ||
| 15 | +OPENJIUWEN_SERVICE_DB_TYPE=mysql | ||
| 16 | +OPENJIUWEN_SERVICE_DB_HOST=127.0.0.1 | ||
| 17 | +OPENJIUWEN_SERVICE_DB_PORT=3306 | ||
| 18 | +OPENJIUWEN_SERVICE_DB_NAME=runtime_capabilities | ||
| 19 | +OPENJIUWEN_SERVICE_DB_USER=root | ||
| 20 | +OPENJIUWEN_SERVICE_DB_PASSWORD=change-me | ||
| 21 | + | ||
| 22 | +OPENJIUWEN_SERVICE_REDIS_URL=redis://127.0.0.1:6379/0 | ||
| 23 | +OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX=runtime-capabilities | ||
| 24 | +OPENJIUWEN_SERVICE_LOCK_BACKEND=redis | ||
| 25 | +OPENJIUWEN_SERVICE_CACHE_BACKEND=memory | ||
| 26 | +OPENJIUWEN_SERVICE_DEPLOY_REPLICAS=1 | ||
| 27 | +OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS=30 | ||
| @@ -0,0 +1,37 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +set -euo pipefail | ||
| 3 | + | ||
| 4 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| 5 | +APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" | ||
| 6 | +MODE="${1:-}" | ||
| 7 | + | ||
| 8 | +if [[ "${MODE}" != "local" && "${MODE}" != "server" ]]; then | ||
| 9 | + echo "Usage: $0 <local|server> [env-file]" >&2 | ||
| 10 | + exit 2 | ||
| 11 | +fi | ||
| 12 | + | ||
| 13 | +if [[ "${MODE}" == "local" ]]; then | ||
| 14 | + DEFAULT_ENV_FILE="${APP_DIR}/.env.development.local" | ||
| 15 | + TEMPLATE_FILE="${APP_DIR}/runtime_capabilities.local.env.example" | ||
| 16 | +else | ||
| 17 | + DEFAULT_ENV_FILE="${APP_DIR}/.env.production.local" | ||
| 18 | + TEMPLATE_FILE="${APP_DIR}/runtime_capabilities.server.env.example" | ||
| 19 | +fi | ||
| 20 | + | ||
| 21 | +ENV_FILE="${2:-${DEFAULT_ENV_FILE}}" | ||
| 22 | +if [[ ! -f "${ENV_FILE}" ]]; then | ||
| 23 | + if [[ "${MODE}" == "local" && "${ENV_FILE}" == "${DEFAULT_ENV_FILE}" ]]; then | ||
| 24 | + cp "${TEMPLATE_FILE}" "${ENV_FILE}" | ||
| 25 | + echo "Created local configuration: ${ENV_FILE}" | ||
| 26 | + else | ||
| 27 | + echo "Environment file does not exist: ${ENV_FILE}" >&2 | ||
| 28 | + echo "Copy and edit the template first: ${TEMPLATE_FILE}" >&2 | ||
| 29 | + exit 2 | ||
| 30 | + fi | ||
| 31 | +fi | ||
| 32 | + | ||
| 33 | +uv sync --project "${APP_DIR}" --extra "${MODE}" | ||
| 34 | +cd "${APP_DIR}" | ||
| 35 | +exec uv run --project "${APP_DIR}" --no-sync runtime-capabilities \ | ||
| 36 | + --mode "${MODE}" \ | ||
| 37 | + --env-file "${ENV_FILE}" | ||
| @@ -0,0 +1,3 @@ | |||
| 1 | +"""Runtime capabilities application package.""" | ||
| 2 | + | ||
| 3 | +__all__: list[str] = [] | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | +from .cli import main | ||
| 2 | + | ||
| 3 | + | ||
| 4 | +if __name__ == "__main__": | ||
| 5 | + main() | ||
| @@ -0,0 +1,661 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Database, Redis, Envelope, and Kubernetes capability application.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import os | ||
| 9 | +import re | ||
| 10 | +from collections.abc import Mapping | ||
| 11 | +from dataclasses import asdict, dataclass | ||
| 12 | +from datetime import datetime, timezone | ||
| 13 | +from typing import Any, Literal | ||
| 14 | +from urllib.parse import urlparse | ||
| 15 | + | ||
| 16 | +from pydantic import BaseModel, ConfigDict, Field, field_serializer | ||
| 17 | + | ||
| 18 | +from openjiuwen_runtime.foundation.db.table_def import ( | ||
| 19 | + ColumnDefinition, | ||
| 20 | + TableDefinition, | ||
| 21 | +) | ||
| 22 | +from openjiuwen_runtime.service import ( | ||
| 23 | + App, | ||
| 24 | + DatabaseUnavailable, | ||
| 25 | + Envelope, | ||
| 26 | + FakeKubernetesOperations, | ||
| 27 | + KubernetesAsyncioOperations, | ||
| 28 | + NotFoundError, | ||
| 29 | + PodCreateSpec, | ||
| 30 | + RedisUnavailable, | ||
| 31 | + ServiceConfig, | ||
| 32 | + SystemContext, | ||
| 33 | + TypedAppContext, | ||
| 34 | + build_system_context, | ||
| 35 | +) | ||
| 36 | + | ||
| 37 | + | ||
| 38 | +RUNTIME_CAPABILITIES_TABLE_NAME = "runtime_capability_records" | ||
| 39 | +RUNTIME_CAPABILITIES_TABLE = TableDefinition( | ||
| 40 | + table_name=RUNTIME_CAPABILITIES_TABLE_NAME, | ||
| 41 | + columns=[ | ||
| 42 | + ColumnDefinition("id", "string", primary_key=True, nullable=False, length=64), | ||
| 43 | + ColumnDefinition("value", "string", nullable=False, length=4096), | ||
| 44 | + ColumnDefinition("updated_at", "datetime", nullable=False), | ||
| 45 | + ], | ||
| 46 | +) | ||
| 47 | + | ||
| 48 | +_DISABLED_REDIS_URLS = {"disabled", "none"} | ||
| 49 | +_DNS_1123_LABEL_PATTERN = r"^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$" | ||
| 50 | +_DNS_1123_SUBDOMAIN_PATTERN = ( | ||
| 51 | + r"^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?" | ||
| 52 | + r"(?:\.[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?)*$" | ||
| 53 | +) | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +@dataclass(frozen=True) | ||
| 57 | +class RuntimeCapabilitiesConfig: | ||
| 58 | + """Application settings and the local/server resource policy.""" | ||
| 59 | + | ||
| 60 | + mode: Literal["local", "server"] | ||
| 61 | + service_label: str | ||
| 62 | + redis_default_ttl_seconds: int | ||
| 63 | + kubernetes_namespace: str | ||
| 64 | + kubernetes_pod_image: str | ||
| 65 | + kubernetes_pod_run_as_user: int | None = None | ||
| 66 | + kubernetes_pod_run_as_group: int | None = None | ||
| 67 | + sqlite_path: str = "./runtime_capabilities.db" | ||
| 68 | + | ||
| 69 | + def __post_init__(self) -> None: | ||
| 70 | + mode = str(self.mode).strip().lower() | ||
| 71 | + service_label = str(self.service_label).strip() | ||
| 72 | + kubernetes_namespace = str(self.kubernetes_namespace).strip() | ||
| 73 | + kubernetes_pod_image = str(self.kubernetes_pod_image).strip() | ||
| 74 | + sqlite_path = str(self.sqlite_path).strip() | ||
| 75 | + if mode not in {"local", "server"}: | ||
| 76 | + raise ValueError("RUNTIME_CAPABILITIES_MODE must be local or server") | ||
| 77 | + if not service_label: | ||
| 78 | + raise ValueError("RUNTIME_CAPABILITIES_SERVICE_LABEL must not be empty") | ||
| 79 | + if not re.fullmatch(_DNS_1123_LABEL_PATTERN, kubernetes_namespace): | ||
| 80 | + raise ValueError( | ||
| 81 | + "RUNTIME_CAPABILITIES_KUBERNETES_NAMESPACE must be a valid " | ||
| 82 | + "DNS-1123 label" | ||
| 83 | + ) | ||
| 84 | + if not kubernetes_pod_image: | ||
| 85 | + raise ValueError( | ||
| 86 | + "RUNTIME_CAPABILITIES_KUBERNETES_POD_IMAGE must not be empty" | ||
| 87 | + ) | ||
| 88 | + if not sqlite_path: | ||
| 89 | + raise ValueError("RUNTIME_CAPABILITIES_SQLITE_PATH must not be empty") | ||
| 90 | + ttl = self.redis_default_ttl_seconds | ||
| 91 | + if isinstance(ttl, bool) or not isinstance(ttl, int) or ttl <= 0: | ||
| 92 | + raise ValueError( | ||
| 93 | + "RUNTIME_CAPABILITIES_REDIS_DEFAULT_TTL_SECONDS must be a " | ||
| 94 | + "positive integer" | ||
| 95 | + ) | ||
| 96 | + run_as_user = self.kubernetes_pod_run_as_user | ||
| 97 | + run_as_group = self.kubernetes_pod_run_as_group | ||
| 98 | + if (run_as_user is None) != (run_as_group is None): | ||
| 99 | + raise ValueError( | ||
| 100 | + "RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_USER and " | ||
| @@ -0,0 +1,67 @@ | |||
| 1 | +"""Command-line launcher for the runtime capabilities application.""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import argparse | ||
| 6 | +import os | ||
| 7 | +from pathlib import Path | ||
| 8 | +from typing import Sequence | ||
| 9 | + | ||
| 10 | +from dotenv import load_dotenv | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def _parser() -> argparse.ArgumentParser: | ||
| 14 | + parser = argparse.ArgumentParser( | ||
| 15 | + description="Run the Runtime Capabilities application", | ||
| 16 | + ) | ||
| 17 | + parser.add_argument( | ||
| 18 | + "--mode", | ||
| 19 | + choices=("local", "server"), | ||
| 20 | + required=True, | ||
| 21 | + help="local uses SQLite/FakeRedis/Fake Kubernetes; server uses real resources", | ||
| 22 | + ) | ||
| 23 | + parser.add_argument( | ||
| 24 | + "--env-file", | ||
| 25 | + type=Path, | ||
| 26 | + help="dotenv file loaded before the application is created", | ||
| 27 | + ) | ||
| 28 | + parser.add_argument("--host", help="override OPENJIUWEN_SERVICE_HOST") | ||
| 29 | + parser.add_argument("--port", type=int, help="override OPENJIUWEN_SERVICE_PORT") | ||
| 30 | + return parser | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def main(argv: Sequence[str] | None = None) -> None: | ||
| 34 | + args = _parser().parse_args(argv) | ||
| 35 | + if args.env_file is not None: | ||
| 36 | + env_file = args.env_file.expanduser().resolve() | ||
| 37 | + if not env_file.is_file(): | ||
| 38 | + raise FileNotFoundError(f"environment file does not exist: {env_file}") | ||
| 39 | + load_dotenv(env_file, override=False) | ||
| 40 | + | ||
| 41 | + os.environ["RUNTIME_CAPABILITIES_MODE"] = args.mode | ||
| 42 | + if args.host is not None: | ||
| 43 | + os.environ["OPENJIUWEN_SERVICE_HOST"] = args.host | ||
| 44 | + if args.port is not None: | ||
| 45 | + os.environ["OPENJIUWEN_SERVICE_PORT"] = str(args.port) | ||
| 46 | + | ||
| 47 | + from .application import ( | ||
| 48 | + RuntimeCapabilitiesConfig, | ||
| 49 | + build_service_config, | ||
| 50 | + create_app, | ||
| 51 | + ) | ||
| 52 | + | ||
| 53 | + config = RuntimeCapabilitiesConfig.from_env() | ||
| 54 | + service_config = build_service_config(config) | ||
| 55 | + application = create_app(config) | ||
| 56 | + | ||
| 57 | + import uvicorn | ||
| 58 | + | ||
| 59 | + uvicorn.run( | ||
| 60 | + application.asgi, | ||
| 61 | + host=service_config.host, | ||
| 62 | + port=service_config.port, | ||
| 63 | + workers=1, | ||
| 64 | + ) | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +__all__ = ["main"] | ||
| @@ -0,0 +1,479 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Unit and HTTP system tests for the runtime capabilities application.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import os | ||
| 9 | +from pathlib import Path | ||
| 10 | +from unittest.mock import patch | ||
| 11 | + | ||
| 12 | +import pytest | ||
| 13 | +from starlette.testclient import TestClient | ||
| 14 | + | ||
| 15 | +from runtime_capabilities.application import ( | ||
| 16 | + RuntimeCapabilitiesConfig, | ||
| 17 | + build_service_config, | ||
| 18 | + create_app, | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +@pytest.fixture | ||
| 23 | +def local_config(tmp_path: Path) -> RuntimeCapabilitiesConfig: | ||
| 24 | + return RuntimeCapabilitiesConfig( | ||
| 25 | + mode="local", | ||
| 26 | + service_label="runtime-capabilities-test", | ||
| 27 | + redis_default_ttl_seconds=25, | ||
| 28 | + kubernetes_namespace="runtime-capabilities-test", | ||
| 29 | + kubernetes_pod_image="runtime-capabilities-pod:test", | ||
| 30 | + sqlite_path=str(tmp_path / "runtime-capabilities.db"), | ||
| 31 | + ) | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +@pytest.fixture | ||
| 35 | +def application_client(local_config: RuntimeCapabilitiesConfig): | ||
| 36 | + environment = { | ||
| 37 | + "OPENJIUWEN_SERVICE_HOST": "127.0.0.1", | ||
| 38 | + "OPENJIUWEN_SERVICE_PORT": "8090", | ||
| 39 | + "OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX": "runtime-capabilities-test", | ||
| 40 | + "OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS": "30", | ||
| 41 | + } | ||
| 42 | + with patch.dict(os.environ, environment, clear=False): | ||
| 43 | + application = create_app(local_config) | ||
| 44 | + with TestClient(application.asgi) as client: | ||
| 45 | + yield client | ||
| 46 | + | ||
| 47 | + | ||
| 48 | +def _envelope( | ||
| 49 | + msg_type: str, | ||
| 50 | + rawdata: dict, | ||
| 51 | + *, | ||
| 52 | + request_id: str, | ||
| 53 | + full_metadata: bool = False, | ||
| 54 | +) -> dict: | ||
| 55 | + metadata = {"request_id": request_id} | ||
| 56 | + if full_metadata: | ||
| 57 | + metadata.update( | ||
| 58 | + { | ||
| 59 | + "user_id": "example-user", | ||
| 60 | + "chat_id": "example-chat", | ||
| 61 | + "session_id": "example-session", | ||
| 62 | + "bot_id": "example-bot", | ||
| 63 | + "channel": "swagger", | ||
| 64 | + "timestamp": 1786500000, | ||
| 65 | + "trace_id": "trace-1", | ||
| 66 | + "instance_id": "example-instance", | ||
| 67 | + "extra": {"tenant": "example-tenant"}, | ||
| 68 | + } | ||
| 69 | + ) | ||
| 70 | + return { | ||
| 71 | + "type": msg_type, | ||
| 72 | + "metadata": metadata, | ||
| 73 | + "rawdata": rawdata, | ||
| 74 | + "version": "1", | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + | ||
| 78 | +@pytest.mark.unit | ||
| 79 | +def test_config_reads_local_mode_and_optional_security_context(): | ||
| 80 | + environment = { | ||
| 81 | + "RUNTIME_CAPABILITIES_MODE": "local", | ||
| 82 | + "RUNTIME_CAPABILITIES_SERVICE_LABEL": "runtime-capabilities-test", | ||
| 83 | + "RUNTIME_CAPABILITIES_REDIS_DEFAULT_TTL_SECONDS": "45", | ||
| 84 | + "RUNTIME_CAPABILITIES_KUBERNETES_NAMESPACE": "runtime-capabilities-test", | ||
| 85 | + "RUNTIME_CAPABILITIES_KUBERNETES_POD_IMAGE": "example:test", | ||
| 86 | + "RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_USER": "999", | ||
| 87 | + "RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_GROUP": "1000", | ||
| 88 | + "RUNTIME_CAPABILITIES_SQLITE_PATH": "./example.db", | ||
| 89 | + } | ||
| 90 | + with patch.dict(os.environ, environment, clear=True): | ||
| 91 | + config = RuntimeCapabilitiesConfig.from_env() | ||
| 92 | + | ||
| 93 | + assert config.mode == "local" | ||
| 94 | + assert config.backend_mode == "fake" | ||
| 95 | + assert config.redis_default_ttl_seconds == 45 | ||
| 96 | + assert config.kubernetes_pod_run_as_user == 999 | ||
| 97 | + assert config.kubernetes_pod_run_as_group == 1000 | ||
| 98 | + assert config.sqlite_path == "./example.db" | ||
| 99 | + | ||
| 100 | + | ||
| 101 | +@pytest.mark.unit | ||
| 102 | +@pytest.mark.parametrize( | ||
| 103 | + ("overrides", "message"), | ||
| 104 | + [ | ||
| 105 | + ({"RUNTIME_CAPABILITIES_MODE": "invalid"}, "MODE"), | ||
| 106 | + ( | ||
| 107 | + {"RUNTIME_CAPABILITIES_REDIS_DEFAULT_TTL_SECONDS": "0"}, | ||
| 108 | + "REDIS_DEFAULT_TTL_SECONDS", | ||
| 109 | + ), | ||
| 110 | + ( | ||
| 111 | + {"RUNTIME_CAPABILITIES_REDIS_DEFAULT_TTL_SECONDS": "invalid"}, | ||
| 112 | + "REDIS_DEFAULT_TTL_SECONDS", | ||
| 113 | + ), | ||
| 114 | + ( | ||
| 115 | + {"RUNTIME_CAPABILITIES_KUBERNETES_NAMESPACE": "Invalid_Name"}, | ||
| 116 | + "KUBERNETES_NAMESPACE", | ||
| 117 | + ), | ||
| 118 | + ( | ||
| 119 | + {"RUNTIME_CAPABILITIES_KUBERNETES_POD_IMAGE": ""}, | ||
| 120 | + "KUBERNETES_POD_IMAGE", | ||
| 121 | + ), | ||
| 122 | + ( | ||
| 123 | + {"RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_USER": "999"}, | ||
| 124 | + "configured together", | ||
| 125 | + ), | ||
| 126 | + ( | ||
| 127 | + {"RUNTIME_CAPABILITIES_KUBERNETES_POD_RUN_AS_USER": "invalid"}, | ||
| 128 | + "POD_RUN_AS_USER", | ||
| 129 | + ), | ||
| 130 | + ], | ||
| 131 | +) | ||
| 132 | +def test_config_rejects_invalid_values(overrides: dict[str, str], message: str): | ||
| 133 | + environment = { | ||
| 134 | + "RUNTIME_CAPABILITIES_MODE": "local", | ||
| 135 | + "RUNTIME_CAPABILITIES_SERVICE_LABEL": "runtime-capabilities-test", | ||
| 136 | + "RUNTIME_CAPABILITIES_REDIS_DEFAULT_TTL_SECONDS": "30", | ||
| 137 | + "RUNTIME_CAPABILITIES_KUBERNETES_NAMESPACE": "runtime-capabilities-test", | ||
| 138 | + "RUNTIME_CAPABILITIES_KUBERNETES_POD_IMAGE": "example:test", | ||
| 139 | + "RUNTIME_CAPABILITIES_SQLITE_PATH": "./example.db", | ||
| 140 | + **overrides, | ||
| 141 | + } | ||
| 142 | + with ( | ||
| 143 | + patch.dict(os.environ, environment, clear=True), | ||
| 144 | + pytest.raises(ValueError, match=message), | ||
| 145 | + ): | ||
| 146 | + RuntimeCapabilitiesConfig.from_env() | ||
| 147 | + | ||
| 148 | + | ||
| 149 | +@pytest.mark.unit | ||
| 150 | +def test_local_mode_builds_sqlite_memory_service_config( | ||
| 151 | + local_config: RuntimeCapabilitiesConfig, | ||
| 152 | +): | ||
| 153 | + with patch.dict(os.environ, {}, clear=True): | ||
| 154 | + service_config = build_service_config(local_config) | ||
| 155 | + | ||
| 156 | + assert service_config.host == "127.0.0.1" | ||
| 157 | + assert service_config.port == 8090 | ||
| 158 | + assert service_config.db_type == "sqlite" | ||
| 159 | + assert service_config.db_name == local_config.sqlite_path | ||
| 160 | + assert service_config.redis_url == "disabled" | ||
| 161 | + assert service_config.lock_backend == "memory" | ||
| 162 | + assert service_config.cache_backend == "memory" | ||
| 163 | + | ||
| 164 | + | ||
| 165 | +@pytest.mark.unit | ||
| 166 | +def test_server_mode_requires_mysql_and_redis(): | ||
| 167 | + config = RuntimeCapabilitiesConfig( | ||
| 168 | + mode="server", | ||
| 169 | + service_label="runtime-capabilities-test", | ||
| 170 | + redis_default_ttl_seconds=30, | ||
| 171 | + kubernetes_namespace="runtime-capabilities-test", | ||
| 172 | + kubernetes_pod_image="example:test", | ||
| 173 | + ) | ||
| 174 | + base_environment = { | ||
| 175 | + "OPENJIUWEN_SERVICE_DB_TYPE": "mysql", | ||
| 176 | + "OPENJIUWEN_SERVICE_DB_HOST": "mysql.internal", | ||
| 177 | + "OPENJIUWEN_SERVICE_DB_PORT": "3306", | ||
| 178 | + "OPENJIUWEN_SERVICE_DB_NAME": "runtime_capabilities", | ||
| 179 | + "OPENJIUWEN_SERVICE_DB_USER": "runtime_capabilities", | ||
| 180 | + "OPENJIUWEN_SERVICE_DB_PASSWORD": "test-password", | ||
| 181 | + "OPENJIUWEN_SERVICE_REDIS_URL": "redis://redis.internal:6379/0", | ||
| 182 | + "OPENJIUWEN_SERVICE_LOCK_BACKEND": "redis", | ||
| 183 | + } | ||
| 184 | + with patch.dict(os.environ, base_environment, clear=True): | ||
| 185 | + service_config = build_service_config(config) | ||
| 186 | + assert service_config.db_type == "mysql" | ||
| 187 | + assert service_config.redis_url == "redis://redis.internal:6379/0" | ||
| 188 | + | ||
| 189 | + with patch.dict( | ||
| 190 | + os.environ, | ||
| 191 | + {**base_environment, "OPENJIUWEN_SERVICE_DB_TYPE": "sqlite"}, | ||
| 192 | + clear=True, | ||
| 193 | + ): | ||
| 194 | + with pytest.raises(ValueError, match="DB_TYPE=mysql"): | ||
| 195 | + build_service_config(config) | ||
| 196 | + | ||
| 197 | + with patch.dict( | ||
| 198 | + os.environ, | ||
| 199 | + {**base_environment, "OPENJIUWEN_SERVICE_REDIS_URL": "disabled"}, | ||
| 200 | + clear=True, | ||
| 201 | + ): | ||
| 202 | + with pytest.raises(ValueError, match="REDIS_URL"): | ||
| 203 | + build_service_config(config) | ||
| 204 | + | ||
| 205 | + | ||
| 206 | +@pytest.mark.system | ||
| 207 | +def test_database_create_update_read_and_missing(application_client: TestClient): | ||
| 208 | + created = application_client.post( | ||
| 209 | + "/api/db/write", | ||
| 210 | + json=_envelope( | ||
| 211 | + "db/write", | ||
| 212 | + {"id": "record-1", "value": "first value"}, | ||
| 213 | + request_id="db-write-1", | ||
| 214 | + ), | ||
| 215 | + ) | ||
| 216 | + updated = application_client.post( | ||
| 217 | + "/api/db/write", | ||
| 218 | + json=_envelope( | ||
| 219 | + "db/write", | ||
| 220 | + {"id": "record-1", "value": "final value"}, | ||
| 221 | + request_id="db-write-2", | ||
| 222 | + ), | ||
| 223 | + ) | ||
| 224 | + read = application_client.post( | ||
| 225 | + "/api/db/read", | ||
| 226 | + json=_envelope( | ||
| 227 | + "db/read", | ||
| 228 | + {"id": "record-1"}, | ||
| 229 | + request_id="db-read-1", | ||
| 230 | + ), | ||
| 231 | + ) | ||
| 232 | + missing = application_client.post( | ||
| 233 | + "/api/db/read", | ||
| 234 | + json=_envelope( | ||
| 235 | + "db/read", | ||
| 236 | + {"id": "missing"}, | ||
| 237 | + request_id="db-read-2", | ||
| 238 | + ), | ||
| 239 | + ) | ||
| 240 | + | ||
| 241 | + assert created.status_code == 200 | ||
| 242 | + assert created.json()["rawdata"]["operation"] == "created" | ||
| 243 | + assert created.json()["rawdata"]["record"]["updated_at"].endswith("Z") | ||
| 244 | + assert updated.status_code == 200 | ||
| 245 | + assert updated.json()["rawdata"]["operation"] == "updated" | ||
| 246 | + assert read.status_code == 200 | ||
| 247 | + assert read.json()["rawdata"]["record"]["value"] == "final value" | ||
| 248 | + assert missing.status_code == 404 | ||
| 249 | + assert missing.json()["error_code"] == "not_found" | ||
| 250 | + | ||
| 251 | + | ||
| 252 | +@pytest.mark.system | ||
| 253 | +def test_redis_write_read_ttl_defaults_and_missing(application_client: TestClient): | ||
| 254 | + explicit = application_client.post( | ||
| 255 | + "/api/redis/write", | ||
| 256 | + json=_envelope( | ||
| 257 | + "redis/write", | ||
| 258 | + {"key": "explicit", "value": "hello", "ttl_seconds": 120}, | ||
| 259 | + request_id="redis-write-1", | ||
| 260 | + ), | ||
| 261 | + ) | ||
| 262 | + defaulted = application_client.post( | ||
| 263 | + "/api/redis/write", | ||
| 264 | + json=_envelope( | ||
| 265 | + "redis/write", | ||
| 266 | + {"key": "default", "value": "world"}, | ||
| 267 | + request_id="redis-write-2", | ||
| 268 | + ), | ||
| 269 | + ) | ||
| 270 | + read = application_client.post( | ||
| 271 | + "/api/redis/read", | ||
| 272 | + json=_envelope( | ||
| 273 | + "redis/read", | ||
| 274 | + {"key": "explicit"}, | ||
| 275 | + request_id="redis-read-1", | ||
| 276 | + ), | ||
| 277 | + ) | ||
| 278 | + missing = application_client.post( | ||
| 279 | + "/api/redis/read", | ||
| 280 | + json=_envelope( | ||
| 281 | + "redis/read", | ||
| 282 | + {"key": "missing"}, | ||
| 283 | + request_id="redis-read-2", | ||
| 284 | + ), | ||
| 285 | + ) | ||
| 286 | + | ||
| 287 | + redis = application_client.app.state.sysctx.redis | ||
| 288 | + explicit_ttl = application_client.portal.call( | ||
| 289 | + redis.ttl, | ||
| 290 | + "runtime-capabilities-test:kv:explicit", | ||
| 291 | + ) | ||
| 292 | + default_ttl = application_client.portal.call( | ||
| 293 | + redis.ttl, | ||
| 294 | + "runtime-capabilities-test:kv:default", | ||
| 295 | + ) | ||
| 296 | + | ||
| 297 | + assert explicit.status_code == 200 | ||
| 298 | + assert explicit.json()["rawdata"]["ttl_seconds"] == 120 | ||
| 299 | + assert 0 < explicit_ttl <= 120 | ||
| 300 | + assert defaulted.json()["rawdata"]["ttl_seconds"] == 25 | ||
| 301 | + assert 0 < default_ttl <= 25 | ||
| 302 | + assert read.json()["rawdata"] == { | ||
| 303 | + "key": "explicit", | ||
| 304 | + "found": True, | ||
| 305 | + "value": "hello", | ||
| 306 | + "backend_mode": "fake", | ||
| 307 | + } | ||
| 308 | + assert missing.status_code == 200 | ||
| 309 | + assert missing.json()["rawdata"]["found"] is False | ||
| 310 | + assert missing.json()["rawdata"]["value"] is None | ||
| 311 | + | ||
| 312 | + | ||
| 313 | +@pytest.mark.system | ||
| 314 | +def test_envelope_inspect_returns_envelope_context_and_service( | ||
| 315 | + application_client: TestClient, | ||
| 316 | +): | ||
| 317 | + response = application_client.post( | ||
| 318 | + "/api/envelope/inspect", | ||
| 319 | + json=_envelope( | ||
| 320 | + "envelope/inspect", | ||
| 321 | + { | ||
| 322 | + "message": "inspect this envelope", | ||
| 323 | + "attributes": {"source": "swagger"}, | ||
| 324 | + }, | ||
| 325 | + request_id="inspect-1", | ||
| 326 | + full_metadata=True, | ||
| 327 | + ), | ||
| 328 | + ) | ||
| 329 | + | ||
| 330 | + assert response.status_code == 200 | ||
| 331 | + result = response.json()["rawdata"] | ||
| 332 | + assert result["envelope"]["metadata"]["request_id"] == "inspect-1" | ||
| 333 | + assert result["envelope"]["metadata"]["extra"] == {"tenant": "example-tenant"} | ||
| 334 | + assert result["context"]["msg_type"] == "envelope/inspect" | ||
| 335 | + assert result["context"]["user_id"] == "example-user" | ||
| 336 | + assert result["context"]["replica_id"] | ||
| 337 | + assert result["service"] == { | ||
| 338 | + "mode": "local", | ||
| 339 | + "service_label": "runtime-capabilities-test", | ||
| 340 | + "redis_mode": "fake", | ||
| 341 | + "kubernetes_mode": "fake", | ||
| 342 | + } | ||
| 343 | + | ||
| 344 | + | ||
| 345 | +@pytest.mark.system | ||
| 346 | +def test_kubernetes_pod_create_read_conflict_delete_and_missing( | ||
| 347 | + application_client: TestClient, | ||
| 348 | +): | ||
| 349 | + created = application_client.post( | ||
| 350 | + "/api/k8s/pod/create", | ||
| 351 | + json=_envelope( | ||
| 352 | + "k8s/pod/create", | ||
| 353 | + {"name": "capability-pod-1"}, | ||
| 354 | + request_id="pod-create-1", | ||
| 355 | + ), | ||
| 356 | + ) | ||
| 357 | + read = application_client.post( | ||
| 358 | + "/api/k8s/pod/read", | ||
| 359 | + json=_envelope( | ||
| 360 | + "k8s/pod/read", | ||
| 361 | + {"name": "capability-pod-1"}, | ||
| 362 | + request_id="pod-read-1", | ||
| 363 | + ), | ||
| 364 | + ) | ||
| 365 | + conflict = application_client.post( | ||
| 366 | + "/api/k8s/pod/create", | ||
| 367 | + json=_envelope( | ||
| 368 | + "k8s/pod/create", | ||
| 369 | + {"name": "capability-pod-1"}, | ||
| 370 | + request_id="pod-create-2", | ||
| 371 | + ), | ||
| 372 | + ) | ||
| 373 | + deleted = application_client.post( | ||
| 374 | + "/api/k8s/pod/delete", | ||
| 375 | + json=_envelope( | ||
| 376 | + "k8s/pod/delete", | ||
| 377 | + {"name": "capability-pod-1"}, | ||
| 378 | + request_id="pod-delete-1", | ||
| 379 | + ), | ||
| 380 | + ) | ||
| 381 | + absent = application_client.post( | ||
| 382 | + "/api/k8s/pod/delete", | ||
| 383 | + json=_envelope( | ||
| 384 | + "k8s/pod/delete", | ||
| 385 | + {"name": "capability-pod-1"}, | ||
| 386 | + request_id="pod-delete-2", | ||
| 387 | + ), | ||
| 388 | + ) | ||
| 389 | + missing = application_client.post( | ||
| 390 | + "/api/k8s/pod/read", | ||
| 391 | + json=_envelope( | ||
| 392 | + "k8s/pod/read", | ||
| 393 | + {"name": "capability-pod-1"}, | ||
| 394 | + request_id="pod-read-2", | ||
| 395 | + ), | ||
| 396 | + ) | ||
| 397 | + | ||
| 398 | + assert created.status_code == 200 | ||
| 399 | + assert created.json()["rawdata"] == { | ||
| 400 | + "operation": "created", | ||
| 401 | + "pod": { | ||
| 402 | + "name": "capability-pod-1", | ||
| 403 | + "namespace": "runtime-capabilities-test", | ||
| 404 | + "phase": "Running", | ||
| 405 | + "ready": True, | ||
| 406 | + "image": "runtime-capabilities-pod:test", | ||
| 407 | + }, | ||
| 408 | + } | ||
| 409 | + assert read.status_code == 200 | ||
| 410 | + assert read.json()["rawdata"]["pod"] == created.json()["rawdata"]["pod"] | ||
| 411 | + assert conflict.status_code == 409 | ||
| 412 | + assert conflict.json()["error_code"] == "conflict" | ||
| 413 | + assert deleted.json()["rawdata"]["state"] == "delete_requested" | ||
| 414 | + assert absent.json()["rawdata"]["state"] == "already_absent" | ||
| 415 | + assert missing.status_code == 404 | ||
| 416 | + assert missing.json()["error_code"] == "not_found" | ||
| 417 | + | ||
| 418 | + | ||
| 419 | +@pytest.mark.system | ||
| 420 | +def test_docs_health_validation_and_path_type_binding( | ||
| 421 | + application_client: TestClient, | ||
| 422 | +): | ||
| 423 | + health = application_client.get("/health") | ||
| 424 | + docs = application_client.get("/docs") | ||
| 425 | + openapi_response = application_client.get("/openapi.json") | ||
| 426 | + schema = openapi_response.json() | ||
| 427 | + expected = { | ||
| 428 | + "/api/db/write": "db/write", | ||
| 429 | + "/api/db/read": "db/read", | ||
| 430 | + "/api/redis/write": "redis/write", | ||
| 431 | + "/api/redis/read": "redis/read", | ||
| 432 | + "/api/envelope/inspect": "envelope/inspect", | ||
| 433 | + "/api/k8s/pod/read": "k8s/pod/read", | ||
| 434 | + "/api/k8s/pod/create": "k8s/pod/create", | ||
| 435 | + "/api/k8s/pod/delete": "k8s/pod/delete", | ||
| 436 | + } | ||
| 437 | + | ||
| 438 | + assert health.status_code == 200 | ||
| 439 | + assert docs.status_code == 200 | ||
| 440 | + assert openapi_response.status_code == 200 | ||
| 441 | + for path, msg_type in expected.items(): | ||
| 442 | + operation = schema["paths"][path] | ||
| 443 | + assert set(operation) == {"post"} | ||
| 444 | + request_ref = operation["post"]["requestBody"]["content"]["application/json"][ | ||
| 445 | + "schema" | ||
| 446 | + ]["$ref"] | ||
| 447 | + request_schema = schema["components"]["schemas"][request_ref.rsplit("/", 1)[1]] | ||
| 448 | + assert request_schema["required"] == ["metadata", "rawdata"] | ||
| 449 | + assert request_schema["properties"]["type"]["const"] == msg_type | ||
| 450 | + | ||
| 451 | + invalid = application_client.post( | ||
| 452 | + "/api/db/write", | ||
| 453 | + json=_envelope( | ||
| 454 | + "db/write", | ||
| 455 | + {"id": "record-without-value"}, | ||
| 456 | + request_id="invalid-1", | ||
| 457 | + ), | ||
| 458 | + ) | ||
| 459 | + wrong_type = application_client.post( | ||
| 460 | + "/api/db/write", | ||
| 461 | + json=_envelope( | ||
| 462 | + "db/read", | ||
| 463 | + {"id": "record-1"}, | ||
| 464 | + request_id="invalid-2", | ||
| 465 | + ), | ||
| 466 | + ) | ||
| 467 | + unknown = application_client.post( | ||
| 468 | + "/api/unknown", | ||
| 469 | + json=_envelope( | ||
| 470 | + "unknown", | ||
| 471 | + {}, | ||
| 472 | + request_id="unknown-1", | ||
| 473 | + ), | ||
| 474 | + ) | ||
| 475 | + | ||
| 476 | + assert invalid.status_code == 400 | ||
| 477 | + assert invalid.json()["error_code"] == "validation" | ||
| 478 | + assert wrong_type.status_code == 422 | ||
| 479 | + assert unknown.status_code == 404 | ||
| @@ -0,0 +1,67 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Tests for the application command-line launcher.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import os | ||
| 9 | +from pathlib import Path | ||
| 10 | +from unittest.mock import patch | ||
| 11 | + | ||
| 12 | +import pytest | ||
| 13 | + | ||
| 14 | +from runtime_capabilities.cli import main | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +@pytest.mark.unit | ||
| 18 | +def test_cli_loads_environment_and_starts_uvicorn(tmp_path: Path): | ||
| 19 | + env_file = tmp_path / "runtime-capabilities.env" | ||
| 20 | + env_file.write_text( | ||
| 21 | + "\n".join( | ||
| 22 | + [ | ||
| 23 | + "RUNTIME_CAPABILITIES_SERVICE_LABEL=cli-test", | ||
| 24 | + "RUNTIME_CAPABILITIES_KUBERNETES_NAMESPACE=cli-test", | ||
| 25 | + "RUNTIME_CAPABILITIES_KUBERNETES_POD_IMAGE=example:test", | ||
| 26 | + f"RUNTIME_CAPABILITIES_SQLITE_PATH={tmp_path / 'cli.db'}", | ||
| 27 | + ] | ||
| 28 | + ), | ||
| 29 | + encoding="utf-8", | ||
| 30 | + ) | ||
| 31 | + | ||
| 32 | + with ( | ||
| 33 | + patch.dict(os.environ, {}, clear=True), | ||
| 34 | + patch("uvicorn.run") as run, | ||
| 35 | + ): | ||
| 36 | + main( | ||
| 37 | + [ | ||
| 38 | + "--mode", | ||
| 39 | + "local", | ||
| 40 | + "--env-file", | ||
| 41 | + str(env_file), | ||
| 42 | + "--host", | ||
| 43 | + "127.0.0.2", | ||
| 44 | + "--port", | ||
| 45 | + "18090", | ||
| 46 | + ] | ||
| 47 | + ) | ||
| 48 | + | ||
| 49 | + run.assert_called_once() | ||
| 50 | + assert run.call_args.kwargs == { | ||
| 51 | + "host": "127.0.0.2", | ||
| 52 | + "port": 18090, | ||
| 53 | + "workers": 1, | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +@pytest.mark.unit | ||
| 58 | +def test_cli_rejects_missing_environment_file(tmp_path: Path): | ||
| 59 | + with pytest.raises(FileNotFoundError, match="environment file does not exist"): | ||
| 60 | + main( | ||
| 61 | + [ | ||
| 62 | + "--mode", | ||
| 63 | + "local", | ||
| 64 | + "--env-file", | ||
| 65 | + str(tmp_path / "missing.env"), | ||
| 66 | + ] | ||
| 67 | + ) | ||
| @@ -128,6 +128,8 @@ class KubernetesAsyncioOperations: | |||
| 128 | allow_privilege_escalation=False, | 128 | allow_privilege_escalation=False, |
| 129 | read_only_root_filesystem=False, | 129 | read_only_root_filesystem=False, |
| 130 | run_as_non_root=True, | 130 | run_as_non_root=True, |
| 131 | + run_as_user=spec.run_as_user, | ||
| 132 | + run_as_group=spec.run_as_group, | ||
| 131 | seccomp_profile=client.V1SeccompProfile(type="RuntimeDefault"), | 133 | seccomp_profile=client.V1SeccompProfile(type="RuntimeDefault"), |
| 132 | capabilities=client.V1Capabilities(drop=["ALL"]), | 134 | capabilities=client.V1Capabilities(drop=["ALL"]), |
| 133 | ), | 135 | ), |
| @@ -15,6 +15,8 @@ class PodCreateSpec: | |||
| 15 | 15 | ||
| 16 | name: str | 16 | name: str |
| 17 | image: str | 17 | image: str |
| 18 | + run_as_user: int | None = None | ||
| 19 | + run_as_group: int | None = None | ||
| 18 | 20 | ||
| 19 | 21 | ||
| 20 | @dataclass(frozen=True) | 22 | @dataclass(frozen=True) |
| @@ -202,6 +202,31 @@ async def test_real_adapter_create_uses_restricted_pod_template(): | |||
| 202 | assert container.resources.limits == {"cpu": "100m", "memory": "64Mi"} | 202 | assert container.resources.limits == {"cpu": "100m", "memory": "64Mi"} |
| 203 | 203 | ||
| 204 | 204 | ||
| 205 | +@pytest.mark.unit | ||
| 206 | +async def test_real_adapter_applies_optional_non_root_uid_and_gid(): | ||
| 207 | + client = pytest.importorskip("kubernetes_asyncio.client") | ||
| 208 | + operations = KubernetesAsyncioOperations("demo") | ||
| 209 | + operations._client_module = client | ||
| 210 | + operations._core_api = SimpleNamespace( | ||
| 211 | + create_namespaced_pod=AsyncMock(return_value=_pod()), | ||
| 212 | + ) | ||
| 213 | + | ||
| 214 | + await operations.create_pod( | ||
| 215 | + PodCreateSpec( | ||
| 216 | + name="capability-pod-1", | ||
| 217 | + image="demo:1", | ||
| 218 | + run_as_user=999, | ||
| 219 | + run_as_group=1000, | ||
| 220 | + ) | ||
| 221 | + ) | ||
| 222 | + | ||
| 223 | + call = operations._core_api.create_namespaced_pod.await_args.kwargs | ||
| 224 | + security = call["body"].spec.containers[0].security_context | ||
| 225 | + assert security.run_as_non_root is True | ||
| 226 | + assert security.run_as_user == 999 | ||
| 227 | + assert security.run_as_group == 1000 | ||
| 228 | + | ||
| 229 | + | ||
| 205 | @pytest.mark.unit | 230 | @pytest.mark.unit |
| 206 | async def test_real_adapter_start_falls_back_to_kubeconfig_once(): | 231 | async def test_real_adapter_start_falls_back_to_kubeconfig_once(): |
| 207 | pytest.importorskip("kubernetes_asyncio") | 232 | pytest.importorskip("kubernetes_asyncio") |