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>
230 lines
8.4 KiB
YAML
230 lines
8.4 KiB
YAML
services:
|
|
migrate:
|
|
build:
|
|
context: .
|
|
dockerfile: backend/Dockerfile
|
|
image: model-development-backend:latest
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: "200m"
|
|
max-file: "10"
|
|
restart: "no"
|
|
command:
|
|
- uv
|
|
- run
|
|
- --frozen
|
|
- --no-dev
|
|
- --package
|
|
- backend
|
|
- alembic
|
|
- upgrade
|
|
- head
|
|
environment:
|
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin12345}
|
|
UV_OFFLINE: "1"
|
|
UV_NO_SYNC: "1"
|
|
|
|
web:
|
|
build:
|
|
context: .
|
|
dockerfile: frontend/Dockerfile
|
|
image: model-development-web:latest
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: "200m"
|
|
max-file: "10"
|
|
restart: unless-stopped
|
|
# Architecture §2.2: this is the only service exposed to the host. The
|
|
# default.conf file is mounted as a template; scripts/nginx-entrypoint.sh
|
|
# parses ${S3_ENDPOINT} and writes the rendered config to
|
|
# /etc/nginx/conf.d/default.conf before exec'ing nginx.
|
|
ports:
|
|
- "${GATEWAY_PORT:-8888}:80"
|
|
environment:
|
|
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT is required}
|
|
depends_on:
|
|
backend:
|
|
condition: service_healthy
|
|
runtime:
|
|
condition: service_healthy
|
|
volumes:
|
|
- ${PWD}:/app
|
|
- ./default.conf:/etc/nginx/conf.d/default.conf.template:ro
|
|
healthcheck:
|
|
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null" ]
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 12
|
|
start_period: 10s
|
|
|
|
backend:
|
|
build:
|
|
context: .
|
|
dockerfile: backend/Dockerfile
|
|
image: model-development-backend:latest
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: "200m"
|
|
max-file: "10"
|
|
restart: unless-stopped
|
|
# No host port: architecture §2.2 — only Nginx is externally reachable.
|
|
# The previous ``8891:8000`` mapping (P0-1) was removed: the
|
|
# ``/internal/v1/*`` storage control plane is now guarded by a
|
|
# shared ``INTERNAL_SERVICE_TOKEN`` instead of network isolation.
|
|
# No local-FS volume: backend stores everything in S3 (S3_*).
|
|
environment:
|
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
|
SERVICE_NAME: model-platform-backend
|
|
SCHEDULE_EVENT_NAMESPACE: ${SCHEDULE_EVENT_NAMESPACE:-model-platform-local}
|
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
|
DEMO_AUTH_ENABLED: ${DEMO_AUTH_ENABLED:-false}
|
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin12345}
|
|
RUNTIME_API_URL: http://runtime:8000
|
|
# P0-1 fix: shared secret required by /internal/v1/* routes.
|
|
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required}
|
|
STORAGE_BACKEND: ${STORAGE_BACKEND:-s3}
|
|
LOCAL_STORAGE_BASE_DIR: ${LOCAL_STORAGE_BASE_DIR:-/data}
|
|
# S3_* only matter when STORAGE_BACKEND=s3. Defaults are kept so local
|
|
# mode boots without them; override in .env when switching to s3.
|
|
S3_ENDPOINT: ${S3_ENDPOINT:-http://s3:9000}
|
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
|
|
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
|
|
S3_WORKSPACE_BUCKET: ${S3_WORKSPACE_BUCKET:-workspace}
|
|
S3_VERSION_BUCKET: ${S3_VERSION_BUCKET:-version}
|
|
S3_RUN_LOG_BUCKET: ${S3_RUN_LOG_BUCKET:-run-log}
|
|
S3_TRASH_BUCKET: ${S3_TRASH_BUCKET:-trash}
|
|
S3_TRASH_RETENTION_DAYS: ${S3_TRASH_RETENTION_DAYS:-30}
|
|
READINESS_TARGETS: ${MYSQL_HOST:?MYSQL_HOST is required}:${MYSQL_PORT:-3306},runtime:8000
|
|
UV_OFFLINE: "1"
|
|
UV_NO_SYNC: "1"
|
|
depends_on:
|
|
migrate:
|
|
condition: service_completed_successfully
|
|
runtime:
|
|
condition: service_healthy
|
|
volumes:
|
|
- ${PWD}:/app
|
|
- ./data:/data
|
|
- /app/.venv
|
|
healthcheck:
|
|
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:8000/health/ready >/dev/null" ]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 18
|
|
start_period: 20s
|
|
|
|
runtime:
|
|
build:
|
|
context: .
|
|
dockerfile: runtime/Dockerfile
|
|
image: model-development-runtime:latest
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: "200m"
|
|
max-file: "10"
|
|
restart: unless-stopped
|
|
# No host port: architecture §2.2 — only Nginx is externally reachable.
|
|
# The previous ``8892:8000`` mapping (P0-1) was removed: the runtime
|
|
# container is reachable only from the Docker internal network and
|
|
# Nginx-authenticated Jupyter paths.
|
|
cap_add:
|
|
- SYS_ADMIN
|
|
devices:
|
|
- /dev/fuse:/dev/fuse
|
|
security_opt:
|
|
- apparmor:unconfined
|
|
# No host port: architecture §2.2 — only Nginx is externally reachable.
|
|
environment:
|
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
|
SERVICE_NAME: runtime-manager
|
|
# P0-1 fix: runtime's /api/v1/jupyter is token-guarded. The same
|
|
# ``INTERNAL_SERVICE_TOKEN`` value backend uses for /internal/v1/*
|
|
# auth — see ``require_internal_service`` in runtime/main.py.
|
|
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required}
|
|
STORAGE_BACKEND: ${STORAGE_BACKEND:-s3}
|
|
LOCAL_STORAGE_BASE_DIR: ${LOCAL_STORAGE_BASE_DIR:-/data}
|
|
# WORKSPACES_ROOT defaults to /data/workspace (settings.workspaces_root);
|
|
# in local mode runtime skips the rclone mount and reads directly from
|
|
# ${LOCAL_STORAGE_BASE_DIR}/workspace instead.
|
|
PUBLIC_BASE_URL: http://runtime
|
|
# rclone config only used when STORAGE_BACKEND=s3 (mount skipped in local mode).
|
|
# The remote spec ("s3:<workspace_bucket>") is derived in
|
|
# common.storage.rclone_remote_spec(); no REMOTE_BUCKET env needed.
|
|
RCLONE_CONFIG_S3_TYPE: s3
|
|
RCLONE_CONFIG_S3_PROVIDER: Other
|
|
RCLONE_CONFIG_S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-}
|
|
RCLONE_CONFIG_S3_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:-}
|
|
RCLONE_CONFIG_S3_ENDPOINT: ${S3_ENDPOINT:-http://s3:9000}
|
|
RCLONE_CONFIG_S3_ENV_AUTH: "false"
|
|
RCLONE_CONFIG_S3_FORCE_PATH_STYLE: "true"
|
|
RCLONE_CONFIG_S3_REGION: other
|
|
UV_OFFLINE: "1"
|
|
UV_NO_SYNC: "1"
|
|
volumes:
|
|
- ${PWD}:/app
|
|
- ./data:/data
|
|
- /app/.venv
|
|
healthcheck:
|
|
# Two-mode: s3 needs rclone FUSE mount; local skips rclone and uses a
|
|
# bind mount instead. In s3 mode the grep succeeds (mount must be up or
|
|
# startup would have crashed). In local mode the grep fails (no fuse.rclone)
|
|
# so the || branch runs: just verify /data/workspace is a directory.
|
|
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:8000/api/v1/health >/dev/null" ]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 18
|
|
start_period: 30s
|
|
|
|
schedule:
|
|
build:
|
|
context: .
|
|
dockerfile: schedule/Dockerfile
|
|
image: model-development-schedule:latest
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: "200m"
|
|
max-file: "10"
|
|
restart: unless-stopped
|
|
# No host port: architecture §2.2 — only Nginx is externally reachable.
|
|
# No local-FS volume: schedule executes nodes via tempfile.TemporaryDirectory
|
|
# under Python's default temp dir (cleaned per-run); artifacts live in S3.
|
|
environment:
|
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
|
SERVICE_NAME: schedule-executor
|
|
SCHEDULE_EVENT_NAMESPACE: ${SCHEDULE_EVENT_NAMESPACE:-model-platform-local}
|
|
BACKEND_API_URL: http://backend:8000
|
|
# P0-1 fix: must match the backend's INTERNAL_SERVICE_TOKEN exactly.
|
|
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required}
|
|
STORAGE_BACKEND: ${STORAGE_BACKEND:-s3}
|
|
LOCAL_STORAGE_BASE_DIR: ${LOCAL_STORAGE_BASE_DIR:-/data}
|
|
S3_ENDPOINT: ${S3_ENDPOINT:-http://s3:9000}
|
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
|
|
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
|
|
S3_WORKSPACE_BUCKET: ${S3_WORKSPACE_BUCKET:-workspace}
|
|
S3_VERSION_BUCKET: ${S3_VERSION_BUCKET:-version}
|
|
S3_RUN_LOG_BUCKET: ${S3_RUN_LOG_BUCKET:-run-log}
|
|
# Backend's health endpoint already verifies MySQL; the scheduler only
|
|
# needs MySQL and Backend to be ready in either local or S3 mode.
|
|
READINESS_TARGETS: ${MYSQL_HOST:?MYSQL_HOST is required}:${MYSQL_PORT:-3306},backend:8000
|
|
UV_OFFLINE: "1"
|
|
UV_NO_SYNC: "1"
|
|
depends_on:
|
|
backend:
|
|
condition: service_healthy
|
|
volumes:
|
|
- ${PWD}:/app
|
|
- ./data:/data
|
|
- /app/.venv
|
|
healthcheck:
|
|
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:8000/health/ready >/dev/null" ]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 18
|
|
start_period: 20s
|