diff --git a/.env.example b/.env.example index ab0c8055..b6d8b038 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,5 @@ APP_NAME=Urban Tree 5.0 -ENV=development - #* Database DB_NAME=urbantree @@ -12,3 +10,7 @@ DB_ROOT_PASS="J0¿H.MXA;'J6!XE" # If you are using docker-compose, you can ignore the following variables DB_HOST=database DB_PORT=3306 + +#* Sentry +SENTRY_DSN_API=https://{PUBLIC_KEY}@{HOST}.ingest.de.sentry.io/{PROJECT_ID} +SENTRY_DSN_APP=https://{PUBLIC_KEY}@{HOST}.ingest.de.sentry.io/{PROJECT_ID} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4fe15796..0afb2246 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -153,6 +153,17 @@ jobs: subject-digest: ${{ steps.build-and-push.outputs.digest }} push-to-registry: true + # https://github.com/getsentry/action-release/tree/e769183448303de84c5a06aaaddf9da7be26d6c7 + - name: 📦 Release to Sentry + uses: getsentry/action-release@e769183448303de84c5a06aaaddf9da7be26d6c7 + if: ${{ github.event_name != 'pull_request' }} + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + with: + projects: api + environment: production + deploy: name: 🚀 Deploy to production runs-on: ubuntu-latest diff --git a/api/Dockerfile b/api/Dockerfile index b7126d6f..e4201ce9 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -21,6 +21,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \ python -m pip install -r requirements-dev.txt FROM dev-deps AS development +ENV APP_ENV=development ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 WORKDIR /app @@ -29,10 +30,12 @@ EXPOSE 8000 CMD ["python3", "-m", "uvicorn", "src.app:app", "--host=0.0.0.0", "--port=8000"] FROM development AS test +ENV APP_ENV=test WORKDIR /app CMD ["pytest", "tests"] FROM prod-deps AS final +ENV APP_ENV=production # Prevents Python from writing pyc files. ENV PYTHONDONTWRITEBYTECODE=1 # Keeps Python from buffering stdout and stderr to avoid situations where diff --git a/api/requirements.txt b/api/requirements.txt index 62185c1b..7e8d1cd8 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -5,3 +5,4 @@ pydantic-settings==2.3.4 uvicorn==0.30.1 sqlmodel==0.0.19 databases[mysql] +sentry-sdk[fastapi]==2.19.0 diff --git a/api/src/app.py b/api/src/app.py index 7d78bc63..6d92c10a 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,9 +1,24 @@ from fastapi import FastAPI +import sentry_sdk from sqlmodel import Session, SQLModel, create_engine from src.config import settings from src.services.sensor_service import insert_data from src.utils.file_loader import load_sensor_data +sentry_sdk.init( + dsn=settings.SENTRY_DSN, + environment=settings.APP_ENV, + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for tracing. + traces_sample_rate=1.0, + _experiments={ + # Set continuous_profiling_auto_start to True + # to automatically start the profiler on when + # possible. + "continuous_profiling_auto_start": True, + }, +) + engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) def create_db_and_tables(): diff --git a/api/src/config.py b/api/src/config.py index 9ffb4b28..e5d62eef 100644 --- a/api/src/config.py +++ b/api/src/config.py @@ -1,7 +1,5 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict import os - -from typing import Any - from pydantic import ( MariaDBDsn, computed_field, @@ -9,13 +7,14 @@ model_validator, ) from pydantic_core import MultiHostUrl -from pydantic_settings import BaseSettings, SettingsConfigDict - +from typing import Any class Settings(BaseSettings): if os.path.exists('/run/secrets'): model_config = SettingsConfigDict(secrets_dir='/run/secrets') + APP_ENV: str = "development" + MARIADB_SERVER: str MARIADB_PORT: int = 3306 MARIADB_USER: str @@ -23,6 +22,8 @@ class Settings(BaseSettings): MARIADB_PASSWORD_FILE: str | None = None MARIADB_DB: str + SENTRY_DSN: str + @model_validator(mode="before") @classmethod def check_mariadb_password(cls, data: Any) -> Any: @@ -54,4 +55,6 @@ def SQLALCHEMY_DATABASE_URI(self) -> MariaDBDsn: path=self.MARIADB_DB, ) -settings = Settings() +# instantiate the settings object if APP_ENV is production +if os.environ.get("APP_ENV") == "production": + settings = Settings() diff --git a/api/tests/test_config.py b/api/tests/test_config.py new file mode 100644 index 00000000..b25156e8 --- /dev/null +++ b/api/tests/test_config.py @@ -0,0 +1,47 @@ +import pytest +from pydantic import ValidationError +from src.config import Settings + +def test_settings_with_password(): + settings = Settings( + MARIADB_SERVER="localhost", + MARIADB_USER="user", + MARIADB_PASSWORD="password", + MARIADB_DB="test_db", + SENTRY_DSN="http://example.com" + ) + assert settings.MARIADB_PASSWORD == "password" + assert str(settings.SQLALCHEMY_DATABASE_URI) == "mysql+pymysql://user:password@localhost:3306/test_db" + +def test_settings_with_password_file(tmp_path): + password_file = tmp_path / "password.txt" + password_file.write_text("file_password") + + settings = Settings( + MARIADB_SERVER="localhost", + MARIADB_USER="user", + MARIADB_PASSWORD_FILE=str(password_file), + MARIADB_DB="test_db", + SENTRY_DSN="http://example.com" + ) + assert settings.MARIADB_PASSWORD_FILE == "file_password" + assert str(settings.SQLALCHEMY_DATABASE_URI) == "mysql+pymysql://user:file_password@localhost:3306/test_db" + +def test_settings_missing_password(): + with pytest.raises(ValidationError): + Settings( + MARIADB_SERVER="localhost", + MARIADB_USER="user", + MARIADB_DB="test_db", + SENTRY_DSN="http://example.com" + ) + +def test_password_file_does_not_exist(): + with pytest.raises(ValueError, match="Password file /non/existent/path does not exist."): + Settings( + MARIADB_SERVER="localhost", + MARIADB_USER="user", + MARIADB_PASSWORD_FILE="/non/existent/path", + MARIADB_DB="test_db", + SENTRY_DSN="http://example.com" + ) diff --git a/compose.yml b/compose.yml index 71fb991c..11992e56 100644 --- a/compose.yml +++ b/compose.yml @@ -79,12 +79,12 @@ services: - db_pass environment: - APP_NAME=${APP_NAME} - - APP_ENV=${APP_ENV} - MARIADB_SERVER=database - MARIADB_PORT=3306 - MARIADB_DB=${DB_NAME} - MARIADB_USER=${DB_USER} - MARIADB_PASSWORD_FILE=/run/secrets/db_pass + - SENTRY_DSN=${SENTRY_DSN_API} develop: watch: - action: rebuild