feat(deploy): healthcheck api и worker, отдельный migrate-сервис, лимит загрузки отчёта

Миграции вынесены из команды api в one-shot сервис migrate, api и worker стартуют после него. /health отвечает 503 при недоступной БД. Отчёт больше MAX_UPLOAD_BYTES (25 МиБ) получает 413, Caddy режет на 30 МБ раньше. Том uploads убран, секреты env_file передаются сервисам явно. В CI добавлена сборка образа бэкенда без push, test_migrations сверяет модели с историей Alembic.
This commit is contained in:
Dmitry
2026-09-19 21:55:33 +03:00
parent 600496048f
commit 4a0b4e0532
11 changed files with 139 additions and 21 deletions
-1
View File
@@ -21,7 +21,6 @@ COPY --from=builder /app/src /app/src
COPY --from=builder /app/alembic.ini /app/alembic.ini
COPY --from=builder /app/alembic /app/alembic
COPY certs /app/certs
RUN mkdir -p /data/uploads && chown -R app /data
USER app
EXPOSE 8000
CMD ["fintracker", "serve", "--host", "0.0.0.0", "--port", "8000"]
+1 -1
View File
@@ -74,7 +74,7 @@ include = ["src", "tests"]
extraPaths = ["src"]
pythonVersion = "3.12"
typeCheckingMode = "standard"
reportMissingImports = "warning"
reportMissingImports = "error"
[tool.pytest.ini_options]
pythonpath = ["src"]
+4 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Response, status
from pydantic import BaseModel
from sqlalchemy import text
@@ -17,10 +17,12 @@ class Health(BaseModel):
@router.get("/health", name="check")
async def check(session: SessionDep) -> Health:
async def check(session: SessionDep, response: Response) -> Health:
"""503 when the database cannot be reached, so a container healthcheck can trust the code."""
try:
await session.execute(text("SELECT 1"))
db = "ok"
except Exception as exc:
db = f"error: {type(exc).__name__}"
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return Health(status="ok" if db == "ok" else "degraded", version=__version__, database=db)
@@ -20,7 +20,7 @@ from typing import Annotated
from fastapi import APIRouter, File, Form, Query, UploadFile
from sqlalchemy import select
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.deps import CurrentUser, SessionDep, SettingsDep
from fintracker.api.errors import Problem
from fintracker.api.schemas.imports import (
AccountSuggestion,
@@ -64,13 +64,19 @@ def _problem(exc: ImportProblem) -> Problem:
@router.post("", name="create")
async def create_import(
session: SessionDep,
settings: SettingsDep,
_: CurrentUser,
file: Annotated[UploadFile, File(description="the report itself")],
account_id: Annotated[int | None, Form(description="target account, if known")] = None,
parser: Annotated[str | None, Form(description="force a parser from registry.names()")] = None,
) -> ImportPreview:
"""Upload a report, parse it and show what committing it would do. Writes no event."""
data = await file.read()
limit = settings.max_upload_bytes
data = await file.read(limit + 1)
if len(data) > limit:
raise Problem(
413, "Payload Too Large", f"Report is larger than {limit // (1024 * 1024)} MiB"
)
try:
outcome = await report_import.upload(
session,
+13
View File
@@ -459,3 +459,16 @@ async def test_pending_rows_are_created_by_the_preview_alone(app, http, auth_hea
async with get_sessionmaker()() as session:
assert await session.scalar(select(func.count()).select_from(PendingInstrument)) == 1
assert await session.scalar(select(func.count()).select_from(Event)) == 0
async def test_oversized_report_is_413(app, http, auth_headers, parser, monkeypatch):
from fintracker.config import get_settings
monkeypatch.setenv("MAX_UPLOAD_BYTES", "1024")
get_settings.cache_clear()
try:
response = await upload(http, auth_headers, b"x" * 2048)
finally:
monkeypatch.undo()
get_settings.cache_clear()
assert response.status_code == 413, response.text
+17
View File
@@ -4,3 +4,20 @@ async def test_health_reports_db(client):
body = r.json()
assert body["status"] == "ok"
assert body["database"] == "ok"
async def test_health_is_503_when_the_database_is_down(app, client):
from fintracker.api.deps import get_session
class _Broken:
async def execute(self, *_a, **_k):
raise ConnectionRefusedError
async def broken():
yield _Broken()
app.dependency_overrides[get_session] = broken
r = await client.get("/api/v1/health")
assert r.status_code == 503
assert r.json()["status"] == "degraded"
assert r.json()["database"].startswith("error")
+20
View File
@@ -0,0 +1,20 @@
"""Models and Alembic history must describe the same schema — the `alembic check` of this
project. `migrated` upgrades a throwaway database to head, so a model change committed
without its revision shows up here as a non-empty diff."""
from __future__ import annotations
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
async def test_migrations_match_the_models(migrated: str):
import fintracker.models # noqa: F401 — registers every table on Base.metadata
from fintracker.db import get_engine
from fintracker.db.base import Base
async with get_engine().connect() as conn:
diff = await conn.run_sync(
lambda c: compare_metadata(MigrationContext.configure(c), Base.metadata)
)
assert diff == [], f"models changed without a migration (run `just revision`): {diff}"