feat: build an image and deploy it to lynx from ci
This commit is contained in:
9 files changed
+141
-20
No files matched your search
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.venv
|
||||
venv
|
||||
**/__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.coverage
|
||||
db.sqlite3
|
||||
static
|
||||
dpdapisite/local_settings.py
|
||||
@@ -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 }}"
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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"] = ""
|
||||
Reference in new issue
Block a user