diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd3e41c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.venv +**/__pycache__ +**/node_modules +static/assets +static/index.html +staticfiles +media +db.sqlite3 +.env +bbs/local_settings.py +bbs/management/commands/generate_testdata.py +.coverage +coverage.xml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4826e1b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM node:26-trixie-slim AS frontend +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +# Django serves its routes under /api/ in the image (BBS_API_PREFIX in settings_prod.py) +ENV VITE_API_URL=/api +RUN npm run build + +FROM python:3.14-slim-trixie AS build +COPY --from=ghcr.io/astral-sh/uv:0.13.4 /uv /bin/uv +# mysqlclient ships no wheels; it stays until no database is copied from MySQL +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential pkg-config default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* +# The venv lives outside /app so the final stage copies it as a layer of its own, which +# only a dependency change replaces. +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 --no-install-project +COPY . . +# Vite writes into static/, beside the files already tracked there +COPY --from=frontend /src/static/ static/ +RUN DJANGO_SETTINGS_MODULE=bbs.settings_prod SECRET_KEY=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 932 --home-dir /app --no-create-home bbs \ + && mkdir /data && chown bbs:bbs /data +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=bbs.settings_prod \ + PYTHONUNBUFFERED=1 \ + SENTRY_RELEASE=$SENTRY_RELEASE +USER bbs +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "--access-logfile", "-", "bbs.wsgi"] diff --git a/README.md b/README.md index 06a6edf..baae36a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ To create the admin during data seeding instead, set `BBS_SEED_ADMIN_PASSWORD` f mise run test ``` +## Deployment + +Every push to main that passes the tests is built into `git.neuf.no/edb/bbs:` and deployed to amiga by `.gitea/workflows/deploy.yml`, while the repository variable `DEPLOY_AMIGA` is `true`. The image runs with `bbs.settings_prod`: SQLite and uploads in `/data`, Django under `/api/` and the frontend at the root. The stacks on amiga, one per tenant, are the `bbs` role in [edb-infra](https://git.neuf.no/edb/edb-infra). + ## References - We use JS code generation to work with queries/mutations in our graphql API: diff --git a/bbs/settings.py b/bbs/settings.py index 1434ad3..10c0dd2 100755 --- a/bbs/settings.py +++ b/bbs/settings.py @@ -94,6 +94,10 @@ INSTALLED_APPS += LOCAL_APPS LOGIN_REDIRECT_URL = "/" +# Where Django's own routes (graphql, admin, accounts) are mounted. Production serves +# them under api/ beside the frontend; in development Vite proxies them at the root. +BBS_API_PREFIX = "" + SESSION_COOKIE_AGE = 1209600 # 15 minutes SESSION_EXPIRE_AT_BROWSER_CLOSE = True diff --git a/bbs/settings_prod.py b/bbs/settings_prod.py new file mode 100644 index 0000000..0733696 --- /dev/null +++ b/bbs/settings_prod.py @@ -0,0 +1,68 @@ +"""Settings for the container image, from environment variables.""" + +import os + +from bbs.settings import * # noqa: F403 +from bbs.settings import BASE_DIR, MIDDLEWARE + +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] + +# Django's routes live under /api/, the frontend at the root +BBS_API_PREFIX = "api/" +LOGIN_URL = "django.contrib.auth.views.login" + +DATA_DIR = os.getenv("DATA_DIR", "/data") +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(DATA_DIR, "db.sqlite3"), # noqa: PTH118 + # WAL lets gunicorn's workers read while one writes; IMMEDIATE makes a writer + # wait for the lock up to `timeout` instead of failing mid-transaction. + "OPTIONS": { + "init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;", + "transaction_mode": "IMMEDIATE", + "timeout": 20, + }, + }, +} +MEDIA_ROOT = os.path.join(DATA_DIR, "media") # noqa: PTH118 +STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # noqa: PTH118 + +# whitenoise serves Django's static files at STATIC_URL, and the built frontend +# (index.html, assets/) at the root +# SecurityMiddleware applies the TLS settings below +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + *MIDDLEWARE, +] +WHITENOISE_ROOT = os.path.join(BASE_DIR, "static") # noqa: PTH118 +WHITENOISE_INDEX_FILE = True +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, +} + +# 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=True, + traces_sample_rate=0, + send_client_reports=False, + auto_session_tracking=False, + ) diff --git a/bbs/tests/test_urls.py b/bbs/tests/test_urls.py new file mode 100644 index 0000000..72e37ea --- /dev/null +++ b/bbs/tests/test_urls.py @@ -0,0 +1,53 @@ +import importlib + +import pytest +from django.test import Client +from django.urls import clear_url_caches + +import bbs.urls + + +@pytest.fixture +def prod_urls(settings, tmp_path): + """bbs.urls as the image loads it: routes under api/, uploads and frontend routes served.""" + settings.DEBUG = False + settings.BBS_API_PREFIX = "api/" + settings.MEDIA_ROOT = str(tmp_path) + (tmp_path / "products").mkdir() + (tmp_path / "products" / "beer.png").write_bytes(b"png") + clear_url_caches() + importlib.reload(bbs.urls) + yield + settings.DEBUG = True + settings.BBS_API_PREFIX = "" + clear_url_caches() + importlib.reload(bbs.urls) + + +def test_graphql_under_api_prefix(prod_urls, client: Client, db): # noqa: ARG001 + response = client.post("/api/graphql/", {"query": "{ allUsers { id } }"}, content_type="application/json") + + assert response.status_code == 200 + assert response.json() == {"data": {"allUsers": []}} + + +def test_login_page_renders(prod_urls, client: Client, db): # noqa: ARG001 + response = client.get("/api/accounts/login/") + + assert response.status_code == 200 + assert b"csrfmiddlewaretoken" in response.content + + +@pytest.mark.parametrize("path", ["/deposit", "/stats/users", "/user/1"]) +def test_frontend_routes_serve_the_spa_entry(prod_urls, client: Client, path: str): # noqa: ARG001 + response = client.get(path) + + assert response.status_code == 200 + assert b'
' in b"".join(response.streaming_content) + + +def test_uploads_are_served(prod_urls, client: Client): # noqa: ARG001 + response = client.get("/media/products/beer.png") + + assert response.status_code == 200 + assert b"".join(response.streaming_content) == b"png" diff --git a/bbs/urls.py b/bbs/urls.py index ed781fc..400b596 100644 --- a/bbs/urls.py +++ b/bbs/urls.py @@ -2,8 +2,9 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.contrib.auth.views import LoginView, LogoutView -from django.urls import path +from django.urls import include, path, re_path from django.views.decorators.csrf import csrf_exempt +from django.views.static import serve from strawberry.django.views import GraphQLView from bbs.api.schema import schema @@ -11,13 +12,7 @@ from bbs.views.spa import spa_index admin.autodiscover() -urlpatterns = [ - path("inventory", spa_index, name="inventory"), - path("inventory/", spa_index), - path("inventory/add", spa_index, name="inventory-add"), - path("inventory/add/", spa_index), - path("report", spa_index, name="report"), - path("report/", spa_index), +api_patterns = [ # bbs has no login template of its own; the admin's works for any user path( "accounts/login/", @@ -33,5 +28,23 @@ urlpatterns = [ path("graphql/", csrf_exempt(GraphQLView.as_view(schema=schema)), name="graphql"), ] -urlpatterns += static("/media", document_root=settings.MEDIA_ROOT) -urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +urlpatterns = [ + path("inventory", spa_index, name="inventory"), + path("inventory/", spa_index), + path("inventory/add", spa_index, name="inventory-add"), + path("inventory/add/", spa_index), + path("report", spa_index, name="report"), + path("report/", spa_index), + path(settings.BBS_API_PREFIX, include(api_patterns)), +] + +if settings.DEBUG: + urlpatterns += static("/media", document_root=settings.MEDIA_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +else: + # The container has no web server of its own for uploads, and there are few of them. + # Any other path is a frontend route, which index.html renders. + urlpatterns += [ + re_path(r"^media/(?P.*)$", serve, {"document_root": settings.MEDIA_ROOT}), + re_path(r"^(?!media/|static/).*$", spa_index), + ] diff --git a/pyproject.toml b/pyproject.toml index e10ddee..093b553 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "sentry-sdk>=2.61,<3", "strawberry-graphql-django>=0.93,<0.94", "strawberry-graphql>=0.330.3,<0.337", + "whitenoise>=6.12,<7", ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index e2187a6..c4030ee 100644 --- a/uv.lock +++ b/uv.lock @@ -26,6 +26,7 @@ dependencies = [ { name = "sentry-sdk" }, { name = "strawberry-graphql" }, { name = "strawberry-graphql-django" }, + { name = "whitenoise" }, ] [package.dev-dependencies] @@ -48,6 +49,7 @@ requires-dist = [ { name = "sentry-sdk", specifier = ">=2.61,<3" }, { name = "strawberry-graphql", specifier = ">=0.330.3,<0.337" }, { name = "strawberry-graphql-django", specifier = ">=0.93,<0.94" }, + { name = "whitenoise", specifier = ">=6.12,<7" }, ] [package.metadata.requires-dev] @@ -504,3 +506,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909 wheels = [ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, +]