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:
@@ -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"]
|
||||
|
||||
@@ -74,7 +74,7 @@ include = ["src", "tests"]
|
||||
extraPaths = ["src"]
|
||||
pythonVersion = "3.12"
|
||||
typeCheckingMode = "standard"
|
||||
reportMissingImports = "warning"
|
||||
reportMissingImports = "error"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user