已合并
feat: 部署工具+applications迁移:数据库读写、Redis读写、Kubernetes Pod操作接口demo #421
feat: 部署工具+applications迁移:数据库读写、Redis读写、Kubernetes Pod操作接口demo #421
已合并
Wal1et创建于 8月14日
共 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: str16 name: str
17 image: str17 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.unit230@pytest.mark.unit
206async def test_real_adapter_start_falls_back_to_kubeconfig_once():231async def test_real_adapter_start_falls_back_to_kubeconfig_once():
207 pytest.importorskip("kubernetes_asyncio")232 pytest.importorskip("kubernetes_asyncio")