Перейти к содержимому

F-08 FastAPI skeleton — middleware + manifest auto-reg + production deploy

Research prereqs (mandatory per F-06 lesson)

Прочитано перед implementation:

  • AB §1: FastAPI 0.115+, Pydantic v2, SQLAlchemy 2.0 async, modular monolith
  • AG §1-2: manifest-driven module system, 5 single sources of truth
  • N-security §I1+I2: RLS + FORCE + OrgScopedSession, token redactor
  • N-security §S3: JWT alg allowlist (PyJWT >= 2.10, NO none/HS256 confusion)
  • Y RBAC §1.2: 4 system roles (owner/admin/operator/viewer) + permission level matrix
  • W pentest §P01+P03+P04: alg=none, cross-tenant SELECT, cross-tenant INSERT

Никаких research skips — все основные patterns взяты прямо из docs.

Что было сделано

  1. apps/api/src/razmakh_api/ модулей:
    • settings.py — pydantic-settings
    • logging_config.py — structlog JSON для Loki + token redaction processor
    • auth/jwt.py — PyJWT decode/encode, explicit algorithms=[HS256] allowlist
    • auth/permissions.py — Permission registry + require_permission decorator
    • middleware/rls.py — JWT decode → request.state.jwt_claims + correlation IDs
    • health/router.py/health, /health/db, /health/etl, /health/infisical, /health/aggregate
    • metrics/router.py — Prometheus custom REGISTRY + HTTP middleware
    • modules/registry.py — manifest auto-discovery → permissions + routes
    • main.py — FastAPI app factory + lifespan (startup/shutdown)
  2. Tests: 32 unit + integration (test_jwt_auth, test_rls_middleware, test_metrics, test_manifest_registration, test_health, test_cross_tenant_probe)
  3. Dockerfile multi-stage (uv 0.8 + python:3.13-slim, 384MB final)
  4. VPS deploy: docker-compose api service (root user → cat secrets → su razmakh → uvicorn)
  5. Caddy: razmakh.ru/api/*api:8000
  6. Prometheus razmakh-api job uncommented + reloaded
  7. k6 pool leak test: 100 RPS × 5min, 30001 reqs, 0 failures, p95=3.52ms

Главные wins

  • Mypy strict + ruff clean — 32 файла, no ignores сверх pre-existing
  • JWT alg=none blocked: test_alg_none_rejected pass (PyJWT 2.12)
  • Cross-tenant probe pass: 3 tests (SELECT isolation + INSERT WITH CHECK + no-context default-deny)
  • Pool leak test pass: 30001 reqs, p95=3.52ms, 8 active connections под cap (pool=20+overflow=10)
  • Prometheus scrape works: up{job="razmakh-api"}=1 через docker network

Issues encountered

Issue 1: uv 0.5.11 workspace install — Audited 0ms (empty venv)

Symptom: первый Docker build — venv с только _virtualenv.pth, uvicorn missing. uv sync --frozen --no-dev repeated → “Audited in 0.00ms” (cached “ничего не сделано” — но deps не installed).

Cause: старая uv 0.5.11 не правильно обрабатывает workspace без явного --all-packages flag. Root pyproject.toml у нас “пустой aggregator” с [tool.uv.workspace], без dependencies. uv 0.5 решает “ничего не надо install” → пустой venv.

Fix:

  • Upgrade ghcr.io/astral-sh/uv:0.5.11:0.8.4
  • Add --all-packages flag к uv sync --frozen --no-dev

После fix — Built razmakh-core / razmakh-api → Prepared 51 packages → Installed 51 packages в 65ms.

Issue 2: Docker secrets permission denied for non-root user

Symptom: container exit с cat: /run/secrets/postgres_password: Permission denied когда USER=razmakh (UID 1001).

Cause: Docker secrets mounted с permissions 0400 root:root через tmpfs — immutable, не подлежат chmod в running container. Same pattern уже у postgres-exporter container (см. docker-compose.yml: user: "0:0").

Fix:

  • В docker-compose api service: user: "0:0"
  • Entrypoint wrapper: cat secrets → export env → su razmakh → exec uvicorn

Issue 3: asyncpg ищет ~/.postgresql/postgresql.key при connect

Symptom: /health/db returns 500 Internal Server Error. Logs: PermissionError: [Errno 13] Permission denied: '/root/.postgresql/postgresql.key'.

Cause: asyncpg default SSL key lookup идёт в $HOME/.postgresql/postgresql.key. После su razmakh -p (preserve env) HOME оставался /root (export’нут root shell). Файл /root/.postgresql отсутствует, но stat падает с EACCES потому что /root 700 root:root → razmakh user не может даже check existence.

Fix:

  • В entrypoint: export HOME=/tmp перед su
  • В su command: su -p -s /bin/sh razmakh -c "HOME=/tmp python -m uvicorn ..." (защита от inherited HOME)

После — /health/db → 200, latency 1.6ms через TCP (postgres container).

Issue 4: Dockerfile CMD using sh -c "exec uvicorn ..." — uvicorn not in PATH

Symptom: container exit с sh: 1: exec: uvicorn: not found.

Cause: ENV PATH с /opt/venv/bin set в Dockerfile, но sh -c запускается из base shell environment где PATH inheritance работает не как ожидалось — некоторые ENV vars не propagate в child shell argv.

Fix: явно вызывать /opt/venv/bin/python -m uvicorn (absolute path) вместо bare uvicorn.

Что меняется в process

  • Перед добавлением dependency: uv lock + git diff uv.lock — убедиться, что lockfile содержит новые deps. Иначе uv sync --frozen в Docker → старые deps.
  • При первой Docker сборке: всегда smoke test container локально (docker run --rm -p 18000:8000) перед push на VPS. Это caught 4 issues выше за 15 минут вместо часа debugging на VPS.
  • Asyncpg + non-root containers: всегда HOME=/tmp или HOME=/var/lib/<app> (writable). Default $HOME lookup в asyncpg SSL detection — gotcha.

Tracking — speedup metric

  • Plan estimate: 1-2 дня (8-16h)
  • Actual wall-clock: ~3.5h (включая 4 Docker issues + Caddy/Prometheus reload)
  • Speedup: ~3-5x (consistent с F-06 estimate-recalibration baseline 5-8x; на нижней границе из-за infra debugging time)

Production verification (post-deploy)

Окно терминала
curl https://razmakh.ru/api/health
# {"status":"ok","service":"razmakh-api","version":"0.1.0","timestamp":"..."}
curl https://razmakh.ru/api/health/aggregate
# {"status":"ok","components":{"db":{"status":"ok","latency_ms":1.6},"infisical":{"status":"unknown"},"etl":{"status":"ok"}}}
# Prometheus scrape verified
docker exec razmakh-prometheus wget -O- "http://localhost:9090/api/v1/query?query=up{job=\"razmakh-api\"}"
# {"status":"success","data":{"resultType":"vector","result":[{"metric":{...job:"razmakh-api"},"value":[...,"1"]}]}}

Deferred to next iterations

  • /health/etl real freshness query: требует SECURITY DEFINER function или dedicated health-check DB role (NOT razmakh_app — он NOBYPASSRLS, не видит cross-org data). Phase 1.5.
  • JWT KMS via Infisical: Phase 1.5 — Phase 1 simple HS256 shared secret.
  • CORS_ORIGINS=https://razmakh.ru — пока web placeholder, но настройка уже работает (set в env).
  • Permission templates table (Y RBAC §3): Phase 1.5 — Phase 1 = 4 system roles hardcoded.
  • Module routes auto-include: hello_world в Phase 0 пока без routes (.routes.py не existed). Когда первый real module появится — register_module_routes сам подцепит.
  • Image size optimization: 384MB (target <250MB). Future: distroless base, slim asyncpg, exclude .pyc.