diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e8b6d8d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.venv +venv +**/__pycache__ +.pytest_cache +.ruff_cache +.coverage +db.sqlite3 +static +dpdapisite/local_settings.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5032caa..f152a6b 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,6 +1,6 @@ name: CI -# Tests and prek on every push and pull request. +# Tests and prek on every push and pull request. Pushes to main deploy to lynx while DEPLOY_LYNX is true. on: push: pull_request: @@ -32,3 +32,49 @@ jobs: steps: - uses: actions/checkout@v4 - uses: j178/prek-action@v1 + + # kaniko: the runner has no docker socket + image: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_LYNX == 'true' + runs-on: ubuntu-latest + needs: + - tests + - prek + container: ghcr.io/osscontainertools/kaniko:v1.28.6-debug + defaults: + run: + shell: sh + steps: + - name: Build and push + env: + GIT_USERNAME: ${{ github.actor }} + GIT_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + auth=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 | tr -d '\n') + printf '{"auths":{"git.neuf.no":{"auth":"%s"}}}' "$auth" > /kaniko/.docker/config.json + /kaniko/executor \ + --context "git://git.neuf.no/${{ github.repository }}.git#${{ github.ref }}#${{ github.sha }}" \ + --destination "git.neuf.no/edb/django-postfix-dovecot-api:${{ github.sha }}" \ + --build-arg "SENTRY_RELEASE=${{ github.sha }}" \ + --cache=true --cache-repo git.neuf.no/edb/django-postfix-dovecot-api-cache \ + --snapshot-mode=redo --use-new-run \ + --reproducible + + # The key's forced command deploys the sha it is given + deploy-lynx: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_LYNX == 'true' + runs-on: ubuntu-latest + needs: + - image + steps: + - name: Deploy + run: | + command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y ) + eval $(ssh-agent -s) + echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add - + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts + ssh mxapi-deploy@lynx.neuf.no "${{ github.sha }}" diff --git a/AGENTS.md b/AGENTS.md index 57fd113..fed0678 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,4 +27,5 @@ prek run --all-files # ruff check, ruff format and file hygiene hooks `tests/test_mailadmin_contract.py` replays its calls. - Test the API through HTTP in `tests/`, using pytest and the fixtures in `tests/conftest.py`. - Keep coverage at 100%; `pytest` fails below it. -- Production-only deps (`gunicorn`, `mysqlclient`) live in the `prod` dependency group. +- Production-only deps (`gunicorn`, `mysqlclient`) live in the `prod` dependency group. Production settings come from + the environment (`dpdapisite/settings_prod.py`). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..270c617 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.14-slim-trixie AS build +COPY --from=ghcr.io/astral-sh/uv:0.13.6 /uv /bin/uv +# mysqlclient ships no wheels +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential pkg-config default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* +# Outside /app, so a code change leaves the venv layer alone +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never UV_PROJECT_ENVIRONMENT=/venv +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN uv sync --locked --no-dev --group prod --no-install-project +COPY . . +RUN DJANGO_SETTINGS_MODULE=dpdapisite.settings_prod SECRET_KEY=collectstatic DB_PASSWORD=collectstatic \ + /venv/bin/python manage.py collectstatic --noinput + +FROM python:3.14-slim-trixie +RUN apt-get update \ + && apt-get install -y --no-install-recommends libmariadb3 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 934 --home-dir /app --no-create-home mxapi +COPY --from=build /venv /venv +COPY --from=build /app /app +WORKDIR /app +ARG SENTRY_RELEASE=N/A +ENV PATH=/venv/bin:$PATH \ + DJANGO_SETTINGS_MODULE=dpdapisite.settings_prod \ + PYTHONUNBUFFERED=1 \ + SENTRY_RELEASE=$SENTRY_RELEASE +USER mxapi +EXPOSE 8000 +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "--access-logfile", "-", "dpdapisite.wsgi"] diff --git a/README.md b/README.md index 13bb1e9..070e430 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,8 @@ uv run pytest API docs are served at `/api/docs`. Override settings (database, secret key, Sentry) in `dpdapisite/local_settings.py`. + +## Deployment + +CI builds an image for each push to `main` and deploys it to lynx while the repository variable `DEPLOY_LYNX` is +`true`. The image reads its settings from the environment (`dpdapisite/settings_prod.py`). diff --git a/dpdapisite/settings.py b/dpdapisite/settings.py index c4d1ff4..dd1145b 100644 --- a/dpdapisite/settings.py +++ b/dpdapisite/settings.py @@ -6,13 +6,9 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/stable/ref/settings/ """ -import os from contextlib import suppress from pathlib import Path -import sentry_sdk -from sentry_sdk.integrations.django import DjangoIntegration - BASE_DIR = Path(__file__).resolve().parent.parent @@ -127,11 +123,5 @@ LOGGING = { }, } -SENTRY_DSN = os.getenv("SENTRY_DSN", "https://c7e6a47844484c0a81a98e10e48c1b71@sentry.neuf.no/8") -if SENTRY_DSN: - SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "production") - sentry_sdk.init(dsn=SENTRY_DSN, environment=SENTRY_ENVIRONMENT, integrations=[DjangoIntegration()]) - - with suppress(ImportError): from .local_settings import * # noqa: F403 diff --git a/dpdapisite/settings_prod.py b/dpdapisite/settings_prod.py new file mode 100644 index 0000000..6451be2 --- /dev/null +++ b/dpdapisite/settings_prod.py @@ -0,0 +1,44 @@ +"""Settings for the container image, from environment variables.""" + +import os + +from dpdapisite.settings import * # noqa: F403 + +DEBUG = False +SECRET_KEY = os.environ["SECRET_KEY"] +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",") +CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS if host] + +# A DB_HOST starting with / is a unix socket +DATABASES = { + "default": { + "ENGINE": "django.db.backends.mysql", + "NAME": os.getenv("DB_NAME", "prod_mail"), + "USER": os.getenv("DB_USER", "mailserver"), + "PASSWORD": os.environ["DB_PASSWORD"], + "HOST": os.getenv("DB_HOST", "/run/mysqld/mysqld.sock"), + "CONN_MAX_AGE": 60, + "CONN_HEALTH_CHECKS": True, + }, +} + +# TLS ends at the host's nginx +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +if dsn := os.getenv("SENTRY_DSN"): + import sentry_sdk + from sentry_sdk.integrations.django import DjangoIntegration + + sentry_sdk.init( + dsn=dsn, + environment=os.getenv("SENTRY_ENVIRONMENT", "production"), + release=os.getenv("SENTRY_RELEASE", "N/A"), + integrations=[DjangoIntegration()], + send_default_pii=False, + traces_sample_rate=0, + send_client_reports=False, + auto_session_tracking=False, + ) diff --git a/pyproject.toml b/pyproject.toml index 323f60a..218a661 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,11 +33,11 @@ DJANGO_SETTINGS_MODULE = "dpdapisite.settings" testpaths = ["tests"] pythonpath = ["."] filterwarnings = ["error"] -addopts = "-p tests.no_sentry --cov --cov-report=term-missing" +addopts = "--cov --cov-report=term-missing" [tool.coverage.run] source = ["dpdapi", "dpdapisite"] -omit = ["*/migrations/*", "dpdapisite/settings.py"] +omit = ["*/migrations/*", "dpdapisite/settings.py", "dpdapisite/settings_prod.py"] branch = true [tool.coverage.report] diff --git a/tests/no_sentry.py b/tests/no_sentry.py deleted file mode 100644 index a2fb73a..0000000 --- a/tests/no_sentry.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Loaded with `-p` so it runs before pytest-django imports the settings.""" - -import os - -# Never report test runs to the production Sentry project. -os.environ["SENTRY_DSN"] = ""