Files
model-platform/README.md
T
tao.chenandClaude Fable 5 dfe3f0b118 fix(security): P0-1 — port exposure + service-token auth on /internal/* + jupyter RPC
The fix lands in three concentric layers, all backed by a single
INTERNAL_SERVICE_TOKEN shared secret so we have one mechanism
instead of three:

1. docker-compose: drop the backend.ports: 8891:8000 and
   runtime.ports: 8892:8000 mappings. Nginx is the only host
   ingress again (architecture §2.2).
2. /internal/v1/*: the storage control plane had six endpoints, five
   of which were dead code (frontend already migrated to
   /api/v1/data-resources/* with JWT; schedule only ever called
   POST /internal/v1/objects). Delete the dead routes, mount the
   one survivor with Depends(require_internal_service) that
   compares the X-Internal-Service-Token header against
   settings.internal_service_token with secrets.compare_digest.
3. POST /api/v1/jupyter on the runtime container: previously open
   inside the Docker network. Same token mechanism — backend's
   runtime_http_client now carries the header, runtime's
   handle_jupyter_action requires the same header. /api/v1/health
   stays open for the Nginx and compose healthchecks.

The schedule worker was already configured to call
POST /internal/v1/objects; build_storage_http_client now
sets the token header so its existing call site keeps working
without changes.

Files touched:
  backend/src/backend/storage_api.py   # 5 dead routes deleted + token guard
  backend/src/backend/main.py          # runtime_http_client header
  runtime/src/runtime/main.py          # require_internal_service Depends
  common/src/common/config.py          # internal_service_token setting
  schedule/src/schedule/service.py     # httpx client header
  docker-compose.yml                   # ports dropped, INTERNAL_SERVICE_TOKEN env
  .env.example                         # INTERNAL_SERVICE_TOKEN placeholder
  API.md / README.md / DEVELOP.md      # §9 trimmed to 1 endpoint

Verified:
  compileall -> 0 errors
  pytest backend/tests -> 37 passed
  in-process ASGI smoke:
    POST /internal/v1/objects no/wrong/correct token -> 401/401/200
    POST /api/v1/jupyter   no/wrong/correct token -> 401/401/200
    5 deleted internal routes -> 404
  docker compose config (with env) -> OK

P0-1 still has one open sub-item (rclone RC --rc-no-auth) that
the user has explicitly deferred; not touched here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:48:46 +08:00

208 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Model Platform
自托管的 **Jupyter 模型开发平台**:交互式 workspace、DAG 调度、对象存储工件、
按 workspace 隔离的运行时 — 全部经一个 Nginx 网关对外。
> 技术栈:React Router SPA · FastAPI · APScheduler · MySQL · S3 兼容存储
> (或本地文件系统,`STORAGE_BACKEND=local`)· 共享 Jupyter · rclone FUSE 挂载(仅 s3 模式)
> 单一入口(Nginx :80);其他服务只在 Docker 内网互通。
## 它做什么
| 能力 | 位置 |
|---|---|
| workspace 内 notebook 编辑,行级锁 | `backend/jupyter.py` + `scripts.is_locked` |
| Jupyter 鉴权路由(浏览器永远拿不到 runtime token | `nginx/default.conf` + `auth_request` + `backend/jupyter.py` |
| notebook / script / version / run_log 的对象存储(s3 / local 二选一) | `common/storage/` + `backend/scripts.py` |
| DAG 调度:节点、边、cron、手动触发、重试、快照 | `backend/schedules.py` + `backend/schedule_runs.py` + `schedule/`5 个模块) |
| DAG 执行走 MySQL Outbox(无 Redis,无进程内队列) | `schedule/orchestrator.py` + `schedule/worker.py` |
| 每个 workspace 一个 Jupyter 子进程池,配 asyncio 锁 | `runtime/process.py` |
| runtime 内 rclone FUSE 把 workspace 桶挂上来(s3 模式) | `runtime/mount.py` |
| 仅 MySQL 持久化(26 张表,软删除,无外键) | `common/db/models/` |
## 架构一览
```
┌────────────────────┐
│ Browser (SPA) │
└─────────┬──────────┘
│ HTTPS / WS
┌─────────▼──────────┐
│ Nginx (only :80) │ ← templates/default.conf
│ /api/ /jupyter/ /storage/
└────┬───────┬──────┘
│ │
┌──────────────┘ └─────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ FastAPI Backend │ │ Runtime (Jupyter) │
│ + /internal/v1 │ control │ - rclone FUSE mount │ (P0-1)
│ /objects │ token-Auth │ │
│ (storage) ├──────────────►│ - subprocess pool │
│ - DAG CRUD │ │ (per workspace) │
│ - script CRUD │ └──────────┬───────────┘
│ - auth_request │ │ FUSE / shared vol
│ - /api/v1/... │ ▼
└────┬──────┬──────┘ ┌──────────────────────┐
│ │ │ Object storage │
│ └──────── HTTP ───────►│ (s3: S3 service / │
▼ │ local: shared vol) │
┌────────────┐ │ 4 buckets per usage │
│ MySQL │◄───────── poll ─────│ │
│ - 26 tbls │ └──────────────────────┘
│ - outbox │
│ - jobstore │
└────┬───────┘
│ outbox poll
┌────┴──────────────────────────┐
│ Schedule Executor │
│ - CronScheduler (APScheduler) │
│ - DispatchOrchestrator │
│ - NodeExecutor (worker) │
│ - SchedulerService (facade) │
└───────────────────────────────┘
```
对象存储通过 `STORAGE_BACKEND`s3 | local)二选一。s3 模式下 4 个 purpose 命名桶
`workspace` / `version` / `run-log` / `trash`)是独立的 S3 bucketlocal 模式下
`LOCAL_STORAGE_BASE_DIR` 的子目录,通过 Docker volume `local-storage` 共享。
详见 `DEVELOP.md` §存储。
详细设计见 `ARCHITECTURE.md`。实现的偏离和近期重构记录在 `HANDOVER.md`
## 目录结构
```text
frontend/ React Router SPA
backend/ FastAPI:公开 API + 内部存储 API
runtime/ Jupyter 子进程管理 + rclone FUSE
schedule/ DAG 调度器(5 模块:context/scheduler/
orchestrator/worker/service
common/ 配置、SQLAlchemy 模型、存储 SDK、
outbox 事件、jobstore
migrations/ Alembic 基线 + 各特性 migration
nginx/ (仅概念 — 见下方「容器」一节)
scripts/ nginx-entrypoint.sh(模板渲染)
docker-compose.yml 4 服务 — web / backend / runtime / schedule
default.conf Nginx 模板(挂载,启动时渲染)
.env.example common.config.Settings 消费的所有环境变量
```
## 容器
| 服务 | 镜像 | 暴露 | 用途 |
|---|---|---|---|
| `web` | `nginx:alpine` | 宿主机 `:8888``:80` | SPA、`/api/` 反向代理、`/jupyter/{ws}/` auth_request 代理、`/storage/` S3 直通(仅 s3 模式) |
| `backend` | `Dockerfile` | 仅内网 | DAG CRUD、script CRUD、schedule 触发、`/api/v1/auth/jupyter``/internal/v1/objects` 服务间 RPC(共享 `INTERNAL_SERVICE_TOKEN` 鉴权,P0-1)|
| `runtime` | `Dockerfile` | 仅内网 | 每个 workspace 一个 Jupyter 子进程池、rclone FUSE 挂载 `workspace` 桶(s3 模式) |
| `schedule` | `Dockerfile` | 仅内网 | cron tick + DAG 执行(轮询 MySQL Outbox |
架构**故意只暴露一个宿主机端口**(网关);其他服务都在 Docker 内网。
这一点在 `docker-compose.yml` 里强制执行 — backend / runtime / schedule 都没有 `ports:`。在 P0-1 之前,backend 与 runtime 曾短暂地把 `8891` / `8892` 映射到宿主机;此映射已被删除,改用 `INTERNAL_SERVICE_TOKEN` 头对 `/internal/v1/*` 做服务间鉴权,见 `API.md §9`
## 快速启动
```bash
cp .env.example .env
# 编辑 .env — 至少改 MYSQL 密码,以及(s3 模式下)S3 凭据。
# 静态检查
uv sync --all-packages
uv run --frozen --package backend python -m compileall -q backend/src common/src
uv run --frozen --package schedule python -m compileall -q schedule/src
uv run --frozen --package runtime python -m compileall -q runtime/src
# 应用 schema
uv run --frozen --package backend alembic upgrade head
# 启动整套服务
docker compose config # 校验
docker compose up -d --build
docker compose ps
```
访问 `http://localhost:8888`
### 日志
```bash
docker compose logs -f backend
docker compose logs -f schedule
docker compose logs -f runtime
```
### 停服(保留 MySQL + S3 / local-storage 数据卷)
```bash
docker compose down
```
### 抹数据
```bash
docker compose down -v
```
## 配置
所有环境变量在 `common/src/common/config.py` 里用 pydantic-settings 的 `Settings`
类一次性声明,外面套一层 `@lru_cache` 单例。新增环境变量:
1.`common/src/common/config.py``Settings` 里加字段(带合理 default,使 dev 启动不需要设)
2.`.env.example` 加一行带注释
3. 调用点用 `settings.<name>`,永远不要用 `os.environ["..."]`
完整环境变量列表和含义见 `DEVELOP.md`
## 存储布局
4 个 purpose 命名桶。从 `StorageObjects.usage_type` 到桶的映射由
**单一入口**`backend/storage_api.py:resolve_bucket`)决定:
| `usage_type` | 桶(环境变量) | 默认名 |
|---|---|---|
| `working_copy``public_script``data_resource``snapshot` | `S3_WORKSPACE_BUCKET` | `workspace` |
| `version_artifact` | `S3_VERSION_BUCKET` | `version` |
| `run_log``run_result` | `S3_RUN_LOG_BUCKET` | `run-log` |
| (软删除目标) | `S3_TRASH_BUCKET` | `trash` |
`STORAGE_BACKEND=s3` 模式下是 4 个独立 S3 桶。`STORAGE_BACKEND=local` 模式下
`LOCAL_STORAGE_BASE_DIR`(默认 `/data`)下的 4 个子目录:
```
/data/
├── workspace/ # S3_WORKSPACE_BUCKET
├── version/ # S3_VERSION_BUCKET
├── run_log/ # S3_RUN_LOG_BUCKET
└── trash/ # S3_TRASH_BUCKET
```
某个 workspace 的 `artifact_bucket` 列(非 NULL 时)覆盖该 workspace 的默认桶,
无视 `usage_type` — 适合把付费客户隔离到专属桶。
对象 key 是两层扁平路径 — `workspace_id` 加服务端签发的 `ulid`
```
<workspace_bucket>/<workspace_id>/<ulid>{.<ext>}
```
文件名、扩展名、MIME、逻辑路径都放在 `StorageObjects``Scripts` 行里,不进
object key — 重新组织存储不需要重写数据库。
Backend 代码从不写容器本地文件系统(`STORAGE_BACKEND=local` 模式除外,那里共享
`local-storage` volume 就是规范存储)。Schedule Executor 在 `tempfile.TemporaryDirectory()`
里暂存节点工件(自动清理)。只有 `runtime` 容器保留宿主 volume — s3 模式下 rclone FUSE
挂载需要;local 模式下是 no-op 透传。
## 文档
- `README.md`(本文)— 快速导读
- `ARCHITECTURE.md` — 设计图 + 简化历史
- `HANDOVER.md` — 实现偏离、近期重构、待办事项
- `DEVELOP.md` — 开发指南(环境变量、代码规约、常用操作)
- `CLAUDE.md` — agent 面向的本仓库规约
## 许可
内部。