Serve bbs from a container image with SQLite
This commit is contained in:
9 files changed
+220
-8
No files matched your search
@@ -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
@@ -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"]
|
||||
@@ -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:<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
|
||||
|
||||
- We use JS code generation to work with queries/mutations in our graphql API: <https://github.com/dotansimha/graphql-typed-document-node>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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 = [
|
||||
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<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT}),
|
||||
re_path(r"^(?!media/|static/).*$", spa_index),
|
||||
]
|
||||
@@ -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]
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user