feat(backend): verifie l extension TimescaleDB sur la sonde de disponibilite

/api/v1/health/ready interrogeait la base par un SELECT 1, qui ne distingue
pas un PostgreSQL nu d'un PostgreSQL avec TimescaleDB. La sonde lit desormais
pg_extension et repond 503 si l'extension manque, cas qui survient quand
db/init n'a pas ete joue.

- Premiere revision Alembic : aucune table, une garde qui refuse de
  s'appliquer sans l'extension.
- Tests du chemin nominal et de l'extension absente, plus un test marque
  `integration` contre la vraie base. pytest ecarte ce marqueur par defaut
  pour que make check reste jouable sans Docker.
- conftest recycle l'engine entre les tests : get_engine est lru_cache alors
  que pytest-asyncio ouvre une boucle par test, et les connexions asyncpg
  sont liees a leur boucle.
This commit is contained in:
Johan LEROY
2026-09-14 14:19:25 +02:00
parent 351e928309
commit 20e7374d90
8 changed files with 130 additions and 8 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ APP_DEBUG=true
APP_LOG_LEVEL=INFO APP_LOG_LEVEL=INFO
APP_SECRET_KEY=change_me APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200 APP_CORS_ORIGINS=http://localhost:4200
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5432/enervision DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
+13 -1
View File
@@ -22,6 +22,9 @@ uv sync --all-groups
`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par defaut : l'application refuse `APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par defaut : l'application refuse
de demarrer sans elles. de demarrer sans elles.
`DATABASE_URL` pointe sur `localhost:5433`, le port publie par le service `db` du
`docker-compose.yml` racine. Demarrer la base depuis la racine avec `make db-up`.
## Commandes ## Commandes
Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`, Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`,
@@ -35,8 +38,13 @@ uv run ruff check . # lint
uv run ruff format . # format uv run ruff format . # format
uv run mypy app # typage strict uv run mypy app # typage strict
uv run pytest # tests + couverture uv run pytest # tests + couverture
uv run pytest -m integration # tests exigeant une base joignable
``` ```
`pytest` ecarte par defaut les tests marques `integration`, pour que `make check` reste
jouable sans Docker. Ces tests visent la base `enervision_test`, creee par
`db/init/110-test-database.sql` au premier demarrage du conteneur.
L'application est exposee par une factory (`create_app`) et non par un objet module : L'application est exposee par une factory (`create_app`) et non par un objet module :
aucune configuration n'est lue a l'import, ce qui rend les tests et les migrations aucune configuration n'est lue a l'import, ce qui rend les tests et les migrations
independants de l'environnement. independants de l'environnement.
@@ -73,7 +81,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
| Route | Role | | Route | Role |
|------------------------|-------------------------------------------------| |------------------------|-------------------------------------------------|
| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe | | `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe |
| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base | | `/api/v1/health/ready` | Sonde de disponibilite, verifie la base et TimescaleDB |
| `/metrics` | Metriques au format Prometheus | | `/metrics` | Metriques au format Prometheus |
| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` | | `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` |
@@ -86,6 +94,10 @@ uv run alembic upgrade head
L'URL de connexion vient de `DATABASE_URL`, pas de `alembic.ini`. L'URL de connexion vient de `DATABASE_URL`, pas de `alembic.ini`.
La premiere revision ne cree aucune table : elle refuse de s'appliquer si l'extension
TimescaleDB manque, ce qui arrive quand `db/init` n'a pas ete joue. Le DDL propre a
TimescaleDB qui ne depend pas du schema applicatif vit dans `db/`, pas ici.
## Image Docker ## Image Docker
Build multi-stage, dependances resolues par uv depuis `uv.lock`, execution sous un Build multi-stage, dependances resolues par uv depuis `uv.lock`, execution sous un
@@ -0,0 +1,37 @@
"""socle garde extension timescaledb
Revision ID: 5353c0e4f094
Revises:
Create Date: 2026-09-14 14:17:17.556764
Premiere revision du schema applicatif. Elle ne cree aucune table : elle etablit
alembic_version et refuse de s'appliquer sur une base ou l'extension TimescaleDB
manque, cas qui se produit quand db/init n'a pas ete joue.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "5353c0e4f094"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
GARDE_EXTENSION = """
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
RAISE EXCEPTION 'extension timescaledb absente, voir db/init et db/README.md';
END IF;
END
$$;
"""
def upgrade() -> None:
op.execute(GARDE_EXTENSION)
def downgrade() -> None:
pass
+13 -3
View File
@@ -9,6 +9,8 @@ from app.schemas.health import LivenessStatus, ReadinessStatus
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter(tags=["health"]) router = APIRouter(tags=["health"])
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
@router.get("/live", summary="Sonde de vivacite") @router.get("/live", summary="Sonde de vivacite")
async def liveness(settings: SettingsDep) -> LivenessStatus: async def liveness(settings: SettingsDep) -> LivenessStatus:
@@ -23,11 +25,19 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
@router.get("/ready", summary="Sonde de disponibilite") @router.get("/ready", summary="Sonde de disponibilite")
async def readiness(session: SessionDep) -> ReadinessStatus: async def readiness(session: SessionDep) -> ReadinessStatus:
try: try:
await session.execute(text("SELECT 1")) version: str | None = await session.scalar(TIMESCALEDB_VERSION)
except (SQLAlchemyError, OSError): except SQLAlchemyError, OSError:
logger.exception("Base de donnees injoignable") logger.exception("Base de donnees injoignable")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Base de donnees injoignable", detail="Base de donnees injoignable",
) from None ) from None
return ReadinessStatus(status="ready", database="reachable")
if version is None:
logger.error("Extension TimescaleDB absente de la base")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Extension TimescaleDB absente",
)
return ReadinessStatus(status="ready", database="reachable", timescaledb=version)
+1
View File
@@ -13,3 +13,4 @@ class LivenessStatus(BaseModel):
class ReadinessStatus(BaseModel): class ReadinessStatus(BaseModel):
status: Literal["ready"] status: Literal["ready"]
database: Literal["reachable"] database: Literal["reachable"]
timescaledb: str
+2 -1
View File
@@ -79,7 +79,8 @@ disallow_untyped_defs = false
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
addopts = "-q --strict-markers --cov=app --cov-report=term-missing" addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing"
markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"]
[tool.coverage.run] [tool.coverage.run]
source = ["app"] source = ["app"]
+50 -1
View File
@@ -20,6 +20,44 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None:
} }
async def test_readiness_reports_the_timescaledb_version(app: FastAPI, client: AsyncClient) -> None:
class ReadySession:
async def scalar(self, *_: object, **__: object) -> str:
return "2.22.1"
async def override() -> AsyncIterator[ReadySession]:
yield ReadySession()
app.dependency_overrides[get_session] = override
response = await client.get("/api/v1/health/ready")
assert response.status_code == 200
assert response.json() == {
"status": "ready",
"database": "reachable",
"timescaledb": "2.22.1",
}
async def test_readiness_returns_503_when_the_extension_is_missing(
app: FastAPI, client: AsyncClient
) -> None:
class SessionWithoutExtension:
async def scalar(self, *_: object, **__: object) -> None:
return None
async def override() -> AsyncIterator[SessionWithoutExtension]:
yield SessionWithoutExtension()
app.dependency_overrides[get_session] = override
response = await client.get("/api/v1/health/ready")
assert response.status_code == 503
assert response.json()["detail"] == "Extension TimescaleDB absente"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"failure", "failure",
[ [
@@ -32,7 +70,7 @@ async def test_readiness_returns_503_when_database_is_unreachable(
app: FastAPI, client: AsyncClient, failure: Exception app: FastAPI, client: AsyncClient, failure: Exception
) -> None: ) -> None:
class UnreachableSession: class UnreachableSession:
async def execute(self, *_: object, **__: object) -> None: async def scalar(self, *_: object, **__: object) -> None:
raise failure raise failure
async def override() -> AsyncIterator[UnreachableSession]: async def override() -> AsyncIterator[UnreachableSession]:
@@ -49,3 +87,14 @@ async def test_readiness_returns_503_when_database_is_unreachable(
@pytest.mark.parametrize("path", ["/openapi.json", "/metrics"]) @pytest.mark.parametrize("path", ["/openapi.json", "/metrics"])
async def test_technical_endpoints_are_served(client: AsyncClient, path: str) -> None: async def test_technical_endpoints_are_served(client: AsyncClient, path: str) -> None:
assert (await client.get(path)).status_code == 200 assert (await client.get(path)).status_code == 200
@pytest.mark.integration
async def test_readiness_reaches_the_real_database(client: AsyncClient) -> None:
response = await client.get("/api/v1/health/ready")
assert response.status_code == 200, response.text
body = response.json()
assert body["status"] == "ready"
assert body["database"] == "reachable"
assert body["timescaledb"]
+13 -1
View File
@@ -6,6 +6,7 @@ from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from app.core.config import get_settings from app.core.config import get_settings
from app.db.session import get_engine, get_session_factory
from app.main import create_app from app.main import create_app
@@ -13,13 +14,24 @@ from app.main import create_app
def environment() -> Iterator[None]: def environment() -> Iterator[None]:
os.environ.setdefault("APP_SECRET_KEY", "secret-de-test") os.environ.setdefault("APP_SECRET_KEY", "secret-de-test")
os.environ.setdefault( os.environ.setdefault(
"DATABASE_URL", "postgresql+asyncpg://enervision:enervision@localhost:5432/enervision_test" "DATABASE_URL", "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test"
) )
get_settings.cache_clear() get_settings.cache_clear()
yield yield
get_settings.cache_clear() get_settings.cache_clear()
# Piege : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce
# recyclage, le 2e test touchant vraiment la base heriterait d une boucle morte.
@pytest.fixture(autouse=True)
async def engine_per_test() -> AsyncIterator[None]:
yield
if get_engine.cache_info().currsize:
await get_engine().dispose()
get_engine.cache_clear()
get_session_factory.cache_clear()
@pytest.fixture @pytest.fixture
def app() -> FastAPI: def app() -> FastAPI:
return create_app() return create_app()