Serve bbs from a container image with SQLite
This commit is contained in:
9 files changed
+222
-10
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
|
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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
+23
-10
@@ -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 += static("/media", document_root=settings.MEDIA_ROOT)
|
urlpatterns = [
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
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",
|
"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]
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in new issue
Block a user