feat: build an image and deploy it to lynx from ci
CI / tests (push) Successful in 1m52s
CI / prek (push) Successful in 17s
CI / image (push) Successful in 1m41s
CI / deploy-lynx (push) Successful in 27s

This commit is contained in:
ponas committed 2026-11-03 04:13:05 +01:00
1 parent 8f58d7fcf9
commit 7efa230deb
9 files changed
+141 -20

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
.git
.venv
venv
**/__pycache__
.pytest_cache
.ruff_cache
.coverage
db.sqlite3
static
dpdapisite/local_settings.py
+47 -1
View File
@@ -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 }}"
+2 -1
View File
@@ -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`).
+31
View File
@@ -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"]
+5
View File
@@ -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`).
-10
View File
@@ -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
+44
View File
@@ -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,
)
+2 -2
View File
@@ -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]
-6
View File
@@ -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"] = ""