diff --git a/.github/workflows/test-backend.yaml b/.github/workflows/test-backend.yaml index 5eba9e00..59ba3455 100644 --- a/.github/workflows/test-backend.yaml +++ b/.github/workflows/test-backend.yaml @@ -41,10 +41,16 @@ jobs: poetry config virtualenvs.create false poetry install --no-interaction --no-ansi + - name: Start PostgreSQL + run: | + sudo systemctl start postgresql.service + pg_isready + - name: Run tests env: VERSION: test RELEASEID: test + DB_URI: postgresql+asyncpg://postgres@localhost/postgres run: | python -m pytest --cov=app --cov-report=xml diff --git a/backend/README.md b/backend/README.md index 499a6a62..40e54c25 100644 --- a/backend/README.md +++ b/backend/README.md @@ -9,6 +9,7 @@ . ├── app # src for the application │ ├── config.py # reading in settings from env +│ ├── database.py # configuring database connection │ ├── main.py # main entrypoint for the application │ ├── routers # route definitions │ └── schemas # pydantic model definitions diff --git a/backend/app/config.py b/backend/app/config.py index 2d21de3f..2488481a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,6 +6,9 @@ class Settings(pydantic.BaseSettings): version: str releaseId: str + db_uri: pydantic.PostgresDsn + db_echo: bool = False + db_hide_params: bool = True @functools.lru_cache diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 00000000..3583504e --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,45 @@ +from typing import AsyncIterator + +import sqlalchemy as sa +import sqlalchemy.ext.asyncio as saa +from sqlalchemy import orm + +from app import config + +settings = config.get_settings() + + +# echo: if True, the Engine will log all statements as well as a repr() of their +# parameter lists to the default log handler, which defaults to sys.stdout for output. +# +# hide_parameters: Boolean, when set to True, SQL statement parameters will not be +# displayed in INFO logging nor will they be formatted into the string representation +# of StatementError objects. +# +# future=True: Use the 2.0 style Engine and Connection API. +engine = saa.create_async_engine( + settings.db_uri, + echo=settings.db_echo, + hide_parameters=settings.db_hide_params, + future=True, +) + +# https://alembic.sqlalchemy.org/en/latest/naming.html +convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} +mapper_registry = orm.registry(sa.MetaData(naming_convention=convention)) + + +async def get_db() -> AsyncIterator[saa.AsyncSession]: + # https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html#preventing-implicit-io-when-using-asyncsession + # expire_on_commit=False will prevent attributes from being expired after commit + async_session = saa.AsyncSession(engine, expire_on_commit=False) + try: + yield async_session + finally: + await async_session.close() diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py index 5cd19926..8d15d974 100644 --- a/backend/app/routers/health.py +++ b/backend/app/routers/health.py @@ -1,7 +1,11 @@ +import time + import fastapi +import sqlalchemy as sa from fastapi import responses +from sqlalchemy.ext.asyncio import AsyncSession -from app import config, schemas +from app import config, database, schemas router = fastapi.APIRouter() @@ -18,12 +22,37 @@ class HealthResponse(responses.JSONResponse): async def get_health( response: HealthResponse, settings: config.Settings = fastapi.Depends(config.get_settings), + db: AsyncSession = fastapi.Depends(database.get_db), ) -> schemas.Health: response.headers["Cache-Control"] = "max-age=3600" + stmt = sa.text("SELECT now(), pg_postmaster_start_time()") + t0 = time.perf_counter_ns() + result = await db.execute(stmt) + response_time_ns = time.perf_counter_ns() - t0 + now, start = result.one() + content = { "status": schemas.Status.PASS, "version": settings.version, "releaseId": settings.releaseId, + "checks": { + "postgresql:responseTime": [ + { + "componentType": "datastore", + "observedValue": response_time_ns / 1000000, + "observedUnit ": "ms", + "time": now, + } + ], + "postgresql:uptime": [ + { + "componentType": "datastore", + "observedValue": now - start, + "observedUnit ": "s", + "time": now, + } + ], + }, } return schemas.Health(**content) diff --git a/backend/app/schemas/health.py b/backend/app/schemas/health.py index 44e362f0..a75bb7a0 100644 --- a/backend/app/schemas/health.py +++ b/backend/app/schemas/health.py @@ -1,4 +1,5 @@ import enum +from typing import Any import pydantic @@ -13,3 +14,4 @@ class Health(pydantic.BaseModel): status: Status version: str releaseId: str + checks: dict[str, list[dict[str, Any]]] diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index f4e6a337..c88c7abf 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -39,6 +39,8 @@ services: app: container_name: app-aof + depends_on: + - postgres build: context: . target: development @@ -47,6 +49,9 @@ services: environment: - VERSION=dev - RELEASEID=dev + - DB_URI=postgresql+asyncpg://admin:admin@postgres/aof + - DB_ECHO=True + - DB_HIDE_PARAMS=False ports: - 8000:8000 command: uvicorn app.main:app --host 0.0.0.0 --reload diff --git a/backend/tests/routers/test_health.py b/backend/tests/routers/test_health.py index 0daac7fe..b84f59cd 100644 --- a/backend/tests/routers/test_health.py +++ b/backend/tests/routers/test_health.py @@ -15,11 +15,16 @@ def test_get_health() -> None: assert response.headers["content-type"] == "application/health+json" assert response.headers["cache-control"] == "max-age=3600" payload = response.json() - for key in ["status", "version", "releaseId"]: + for key in ["status", "version", "releaseId", "checks"]: assert key in payload assert payload["status"] == "pass" assert payload["version"] == settings.version assert payload["releaseId"] == settings.releaseId + for key in ["postgresql:responseTime", "postgresql:uptime"]: + assert key in payload["checks"] + for item in payload["checks"][key]: + assert "componentType" in item + assert item["componentType"] == "datastore" @pytest.mark.parametrize(