Serve bbs from a container image with SQLite

This commit is contained in:
ponas committed 2026-10-23 02:58:15 +02:00
1 parent edacf81401
commit b2684d537e
9 files changed
+220 -8

No files matched your search

+14
View File
@@ -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
+44
View File
@@ -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"]
+4
View File
@@ -37,6 +37,10 @@ To create the admin during data seeding instead, set `BBS_SEED_ADMIN_PASSWORD` f
mise run test mise run test
``` ```
## Deployment
Every push to main that passes the tests is built into `git.neuf.no/edb/bbs:<sha>` 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 ## References
- We use JS code generation to work with queries/mutations in our graphql API: <https://github.com/dotansimha/graphql-typed-document-node> - We use JS code generation to work with queries/mutations in our graphql API: <https://github.com/dotansimha/graphql-typed-document-node>
+4
View File
@@ -94,6 +94,10 @@ INSTALLED_APPS += LOCAL_APPS
LOGIN_REDIRECT_URL = "/" 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_COOKIE_AGE = 1209600 # 15 minutes
SESSION_EXPIRE_AT_BROWSER_CLOSE = True SESSION_EXPIRE_AT_BROWSER_CLOSE = True
+68
View File
@@ -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,
)
+53
View File
@@ -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'<div id="root"></div>' 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"
+21 -8
View File
@@ -2,8 +2,9 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView 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.decorators.csrf import csrf_exempt
from django.views.static import serve
from strawberry.django.views import GraphQLView from strawberry.django.views import GraphQLView
from bbs.api.schema import schema from bbs.api.schema import schema
@@ -11,13 +12,7 @@ from bbs.views.spa import spa_index
admin.autodiscover() admin.autodiscover()
urlpatterns = [ api_patterns = [
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),
# bbs has no login template of its own; the admin's works for any user # bbs has no login template of its own; the admin's works for any user
path( path(
"accounts/login/", "accounts/login/",
@@ -33,5 +28,23 @@ urlpatterns = [
path("graphql/", csrf_exempt(GraphQLView.as_view(schema=schema)), name="graphql"), path("graphql/", csrf_exempt(GraphQLView.as_view(schema=schema)), name="graphql"),
] ]
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("/media", document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_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<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT}),
re_path(r"^(?!media/|static/).*$", spa_index),
]
+1
View File
@@ -16,6 +16,7 @@ dependencies = [
"sentry-sdk>=2.61,<3", "sentry-sdk>=2.61,<3",
"strawberry-graphql-django>=0.93,<0.94", "strawberry-graphql-django>=0.93,<0.94",
"strawberry-graphql>=0.330.3,<0.337", "strawberry-graphql>=0.330.3,<0.337",
"whitenoise>=6.12,<7",
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]
Generated
+11
View File
@@ -26,6 +26,7 @@ dependencies = [
{ name = "sentry-sdk" }, { name = "sentry-sdk" },
{ name = "strawberry-graphql" }, { name = "strawberry-graphql" },
{ name = "strawberry-graphql-django" }, { name = "strawberry-graphql-django" },
{ name = "whitenoise" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -48,6 +49,7 @@ requires-dist = [
{ name = "sentry-sdk", specifier = ">=2.61,<3" }, { name = "sentry-sdk", specifier = ">=2.61,<3" },
{ name = "strawberry-graphql", specifier = ">=0.330.3,<0.337" }, { name = "strawberry-graphql", specifier = ">=0.330.3,<0.337" },
{ name = "strawberry-graphql-django", specifier = ">=0.93,<0.94" }, { name = "strawberry-graphql-django", specifier = ">=0.93,<0.94" },
{ name = "whitenoise", specifier = ">=6.12,<7" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -504,3 +506,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909
wheels = [ 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" }, { 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" },
]