Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Base images only need what `pip install .` builds plus the pinned requirements (allowlist)
*
!pyproject.toml
!README.md
!LICENSE
!docker-requirements.txt
!whiteneuron/
whiteneuron/**/__pycache__
whiteneuron/**/*.pyc
whiteneuron/db.sqlite3
whiteneuron/media
94 changes: 94 additions & 0 deletions .github/workflows/docker-base.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Docker base images

# Publishes ${namespace}/django-whiteneuron-{alpine,debian}:v<version>, the base images
# WN-Sense (and other projects) build FROM. The build itself is docker/build-base.sh, so a
# local `bash docker/push-base.sh` produces the same images.
#
# GitHub Release published -> build + push the release tag (must equal "v" + pyproject version)
# Run workflow (manual) -> build any ref; "push" only works for a release tag v<version>
# Pull request (docker/**) -> build only, to catch a broken Dockerfile before release
#
# Needs repository secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (a Docker Hub access
# token with write access). Optional repository variable DOCKERHUB_NAMESPACE (default anhneuron).

on:
release:
types: [published]
workflow_dispatch:
inputs:
ref:
description: "Git ref to build (tag, branch or SHA); empty = the selected branch. Pushing needs a release tag."
required: false
default: ""
push:
description: "Push the images to Docker Hub"
type: boolean
default: false
pull_request:
paths:
- "docker/**"
- ".dockerignore"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/docker-base.yml"

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
IMAGE_NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE || 'anhneuron' }}

permissions:
contents: read

concurrency:
group: docker-base-${{ github.event.release.tag_name || inputs.ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
build:
name: django-whiteneuron-${{ matrix.variant }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
variant: [alpine, debian]
steps:
# Full history + tags: the script archives GIT_REF and checks it against tag v<version>.
# The checked-out commit only provides the Dockerfiles and the script.
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Resolve ref and push mode
id: mode
env:
EVENT: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
INPUT_REF: ${{ inputs.ref }}
PUSH_INPUT: ${{ inputs.push }}
run: |
case "$EVENT" in
release) ref="refs/tags/$RELEASE_TAG"; push=1 ;;
workflow_dispatch) ref="${INPUT_REF:-$GITHUB_SHA}"; push=$([ "$PUSH_INPUT" = "true" ] && echo 1 || echo 0) ;;
*) ref="$GITHUB_SHA"; push=0 ;;
esac
echo "ref=$ref" >> "$GITHUB_OUTPUT"
echo "push=$push" >> "$GITHUB_OUTPUT"
echo "### django-whiteneuron-${{ matrix.variant }} from \`$ref\` (push: $push)" >> "$GITHUB_STEP_SUMMARY"

- uses: astral-sh/setup-uv@v5

- uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
if: steps.mode.outputs.push == '1'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build${{ steps.mode.outputs.push == '1' && ' and push' || '' }}
env:
GIT_REF: ${{ steps.mode.outputs.ref }}
PUSH: ${{ steps.mode.outputs.push }}
VARIANTS: ${{ matrix.variant }}
run: bash docker/build-base.sh
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
**Feature release: daisyUI multi-theme admin, server monitoring dashboard, django-unfold 0.109, security hardening and performance work**
- **Added**: 35 daisyUI themes selectable from the user menu (**Themes**); each theme also re-colors Unfold and drives its dark mode. `WN_ADMIN_THEMES` limits the picker (`[]` hides it); `WN_THEMES="nord,dracula" bash scripts/tailwind.sh` builds a smaller CSS bundle.
- **Added**: `whiteneuron.monitoring` — Grafana-style **Server monitoring** dashboard (CPU, memory, disk, load, network, disk I/O, request throughput, p50/p95/p99 latency, error rate, Apdex, slowest endpoints, DB/cache latency) with time ranges, auto-refresh, host selector and shared crosshair. Collect with `manage.py monitoring_collector` or the Celery task `whiteneuron.monitoring.tasks.collect_metrics`; configure with `WN_MONITORING`. New dependency: `psutil`.
- **Added**: Docker base images `django-whiteneuron-{alpine,debian}:v<version>` built from this repository (`docker/`, moved from WN-Sense); `.github/workflows/docker-base.yml` builds and pushes them on every GitHub Release (secrets `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN`).
- **Changed**: `django-unfold>=0.109.0,<0.111` (the cap is raised after each Unfold release is re-tested against the template overrides); template overrides ported; uses the avatar badge (unread notifications), sidebar sub-navigation (Celery "Schedules"), `SITE_VERSION`, theme-aware dashboard charts.
- **Security**: escaped notification/grid HTML (stored XSS), whitelisted `?display_fields` (a GET could call `obj.delete()`) and limited the column picker to fields shown on the change form (never `password`; extra exclusions via `ModelAdmin.field_selection_exclude`), staff-only per-user notification WebSocket with origin check, sandboxed uploaded-HTML preview, private exports scoped to the selected rows, `X-Forwarded-For` read from the right (`TRUSTED_PROXY_COUNT`, `IGNORE_X_FORWARDED_FOR`), open-redirect fix in guest login, new-user signal no longer overwrites passwords, `secrets`-based passwords redacted after sending (never passed to the task broker), POST-only feedback resolve, notification IDOR fix.
- **Performance**: activity logging 1 INSERT per request (was 3–4 queries), dashboard 4 queries (was ~16) + 60s cache, one `bulk_create` + one broadcast per notification, new indexes, atomic rate-limit counters, request-scoped admin state (grid view / page size no longer shared between users), thumbnails built at upload (regenerated if the file is missing), N+1 fixes, 14KB logo instead of 187KB.
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ v0.4.0.0
**Feature release: daisyUI multi-theme admin, server monitoring dashboard, django-unfold 0.109, security hardening and performance work**
- **Added**: 35 daisyUI themes selectable from the user menu (**Themes**); each theme also re-colors Unfold and drives its dark mode. `WN_ADMIN_THEMES` limits the picker (`[]` hides it); `WN_THEMES="nord,dracula" bash scripts/tailwind.sh` builds a smaller CSS bundle.
- **Added**: `whiteneuron.monitoring` — Grafana-style **Server monitoring** dashboard (CPU, memory, disk, load, network, disk I/O, request throughput, p50/p95/p99 latency, error rate, Apdex, slowest endpoints, DB/cache latency) with time ranges, auto-refresh, host selector and shared crosshair. Collect with `manage.py monitoring_collector` or the Celery task `whiteneuron.monitoring.tasks.collect_metrics`; configure with `WN_MONITORING`. New dependency: `psutil`.
- **Added**: Docker base images `django-whiteneuron-{alpine,debian}:v<version>` built from this repository (`docker/`, moved from WN-Sense); `.github/workflows/docker-base.yml` builds and pushes them on every GitHub Release (secrets `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN`).
- **Changed**: `django-unfold>=0.109.0,<0.111` (the cap is raised after each Unfold release is re-tested against the template overrides); template overrides ported; uses the avatar badge (unread notifications), sidebar sub-navigation (Celery "Schedules"), `SITE_VERSION`, theme-aware dashboard charts.
- **Security**: escaped notification/grid HTML (stored XSS), whitelisted `?display_fields` (a GET could call `obj.delete()`) and limited the column picker to fields shown on the change form (never `password`; extra exclusions via `ModelAdmin.field_selection_exclude`), staff-only per-user notification WebSocket with origin check, sandboxed uploaded-HTML preview, private exports scoped to the selected rows, `X-Forwarded-For` read from the right (`TRUSTED_PROXY_COUNT`, `IGNORE_X_FORWARDED_FOR`), open-redirect fix in guest login, new-user signal no longer overwrites passwords, `secrets`-based passwords redacted after sending (never passed to the task broker), POST-only feedback resolve, notification IDOR fix.
- **Performance**: activity logging 1 INSERT per request (was 3–4 queries), dashboard 4 queries (was ~16) + 60s cache, one `bulk_create` + one broadcast per notification, new indexes, atomic rate-limit counters, request-scoped admin state (grid view / page size no longer shared between users), thumbnails built at upload (regenerated if the file is missing), N+1 fixes, 14KB logo instead of 187KB.
Expand Down Expand Up @@ -408,6 +409,43 @@ bash scripts/build.sh

This builds Tailwind CSS assets, runs migrations, and creates the Python package via `uv build`.

Publishing a **GitHub Release** (tag `v<version>`, equal to `version` in `pyproject.toml`) runs two
workflows: `python-publish.yml` uploads the package to PyPI, and `docker-base.yml` builds and pushes
the Docker base images.

### Docker base images

`anhneuron/django-whiteneuron-alpine:v<version>` and `anhneuron/django-whiteneuron-debian:v<version>`
contain Python 3.13 with this package installed. Its dependencies are pinned from `uv.lock`, the
same set CI tests. The images run as `appuser` (uid 100, gid 101, home `/app`). Projects build
`FROM` them, for example WN-Sense's web (Alpine) and core (Debian) images.

Every build goes through `docker/build-base.sh`:
- The source is a `git archive` of the chosen ref, never local files.
- Dependencies come from that ref's `uv.lock` (`uv export`).
- The Dockerfiles come from the current checkout, so older tags can be built too.
- Pushing is only allowed for the release tag `v<version>`, which must match `pyproject.toml`.
A published tag is never rebuilt from other code.
- Each image fails to build if the installed version differs from its tag.

**CI/CD**: `.github/workflows/docker-base.yml` builds and pushes both images when a GitHub
Release is published.
- Required repository secrets: `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` (a Docker Hub access
token with write access).
- Optional repository variable: `DOCKERHUB_NAMESPACE` (default `anhneuron`).
- *Actions → Docker base images → Run workflow* builds any ref. Pushing from there also needs a
release tag.
- Pull requests that touch `docker/`, `pyproject.toml` or `uv.lock` only build the images.

**By hand** (needs git, uv and docker buildx):

```bash
bash docker/build-base.sh # HEAD, load into local docker
GIT_REF=v0.3.8.0 bash docker/build-base.sh # a tag / branch / commit
GIT_REF=v0.4.0.0 bash docker/push-base.sh # push a release, with provenance and SBOM
VARIANTS=alpine IMAGE_NAMESPACE=myorg WN_PYTHON_VERSION=3.13 bash docker/build-base.sh
```

## Error Pages

Templates in `whiteneuron/templates/` are used automatically when `DEBUG=False`:
Expand Down
41 changes: 41 additions & 0 deletions docker/Dockerfile.base-alpine
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1
# ============================================================
# django-whiteneuron base image — Alpine (used by Sense "web" targets)
# Built by docker/build-base.sh (locally and in .github/workflows/docker-base.yml)
# Published as: ${IMAGE_NAMESPACE}/django-whiteneuron-alpine:v<version>
# ============================================================

ARG PYTHON_VERSION=3.13

FROM python:${PYTHON_VERSION}-alpine3.23 AS base

# v<version> of the source being installed (checked below)
ARG WHITENEURON_VERSION

LABEL org.opencontainers.image.title="django-whiteneuron-alpine" \
org.opencontainers.image.source="https://github.com/White-Neuron/django-whiteneuron" \
org.opencontainers.image.version="${WHITENEURON_VERSION}" \
org.opencontainers.image.licenses="MIT"

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TZ=Asia/Ho_Chi_Minh

# Dependencies pinned from uv.lock (docker-requirements.txt, written by build-base.sh), then
# the package itself from the build context (bind mount: no source layer in the image).
# A C compiler is only needed for the few packages without musl wheels; it is removed in
# the same layer. AUTOBAHN_USE_NVX=0 only applies if autobahn has to be built from source.
RUN --mount=type=bind,target=/tmp/src,rw \
apk upgrade --no-cache && \
apk add --no-cache --virtual .build-deps gcc musl-dev && \
AUTOBAHN_USE_NVX=0 pip install --no-cache-dir -r /tmp/src/docker-requirements.txt && \
pip install --no-cache-dir --no-deps /tmp/src && \
python -c 'import importlib.metadata as m, os, sys; from pip._vendor.packaging.version import Version as V; w = os.environ.get("WHITENEURON_VERSION", ""); i = m.version("django-whiteneuron"); sys.exit(f"WHITENEURON_VERSION={w} but the source is {i}" if w and V(w.lstrip("v")) != V(i) else 0)' && \
apk del .build-deps && \
addgroup -S -g 101 appuser && \
adduser -S -u 100 -G appuser -h /app appuser

# numeric, so runAsNonRoot can verify it (same ids as earlier releases: volumes stay writable)
USER 100:101
40 changes: 40 additions & 0 deletions docker/Dockerfile.base-debian
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# syntax=docker/dockerfile:1
# ============================================================
# django-whiteneuron base image — Debian (glibc; used by Sense "core" targets)
# Built by docker/build-base.sh (locally and in .github/workflows/docker-base.yml)
# Published as: ${IMAGE_NAMESPACE}/django-whiteneuron-debian:v<version>
# ============================================================

ARG PYTHON_VERSION=3.13

FROM python:${PYTHON_VERSION}-slim-bookworm AS base

# v<version> of the source being installed (checked below)
ARG WHITENEURON_VERSION
ARG DEBIAN_FRONTEND=noninteractive

LABEL org.opencontainers.image.title="django-whiteneuron-debian" \
org.opencontainers.image.source="https://github.com/White-Neuron/django-whiteneuron" \
org.opencontainers.image.version="${WHITENEURON_VERSION}" \
org.opencontainers.image.licenses="MIT"

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TZ=Asia/Ho_Chi_Minh

# Dependencies pinned from uv.lock (docker-requirements.txt, written by build-base.sh), then
# the package itself from the build context (bind mount: no source layer in the image)
RUN --mount=type=bind,target=/tmp/src,rw \
apt-get update && \
apt-get upgrade -y && \
pip install --no-cache-dir -r /tmp/src/docker-requirements.txt && \
pip install --no-cache-dir --no-deps /tmp/src && \
python -c 'import importlib.metadata as m, os, sys; from pip._vendor.packaging.version import Version as V; w = os.environ.get("WHITENEURON_VERSION", ""); i = m.version("django-whiteneuron"); sys.exit(f"WHITENEURON_VERSION={w} but the source is {i}" if w and V(w.lstrip("v")) != V(i) else 0)' && \
rm -rf /var/lib/apt/lists/* && \
addgroup --system --gid 101 appuser && \
adduser --system --uid 100 --ingroup appuser --home /app appuser

# numeric, so runAsNonRoot can verify it (same ids as earlier releases: volumes stay writable)
USER 100:101
101 changes: 101 additions & 0 deletions docker/build-base.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Build the django-whiteneuron base images (Alpine + Debian) from a git ref of this repo.
# Used as-is by .github/workflows/docker-base.yml, so local and CI builds are the same.
#
# bash docker/build-base.sh # HEAD, load into the local docker
# GIT_REF=v0.4.0.0 bash docker/build-base.sh # a tag / branch / commit
# GIT_REF=v0.4.0.0 bash docker/push-base.sh # push with provenance + SBOM
# VARIANTS=alpine bash docker/build-base.sh # one variant (alpine | debian)
#
# Images: ${IMAGE_NAMESPACE}/django-whiteneuron-{alpine,debian}:v<pyproject version at GIT_REF>
#
# - Source: `git archive` of GIT_REF (never local files such as db.sqlite3, dist/, node_modules).
# - Dependencies: pinned from that ref's uv.lock (`uv export`), the set CI tests.
# - Dockerfiles / .dockerignore: this working copy, so older tags build too.
# - Pushing requires GIT_REF to be the release tag v<version>, so a published tag is
# never overwritten with other code, and a clean docker/ directory.
#
# Requires: git, uv, docker buildx.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"

IMAGE_NAMESPACE="${IMAGE_NAMESPACE:-anhneuron}"
# WN_-prefixed: python images (devcontainers, CI runners) export their own PYTHON_VERSION
WN_PYTHON_VERSION="${WN_PYTHON_VERSION:-3.13}"
WN_PLATFORM="${WN_PLATFORM:-linux/amd64}"
GIT_REF="${GIT_REF:-HEAD}"
PUSH="${PUSH:-0}"
VARIANTS="${VARIANTS:-alpine debian}"

die() { printf 'error: %b\n' "$*" >&2; exit 1; }

for variant in $VARIANTS; do
case "$variant" in alpine|debian) ;; *) die "unknown variant '$variant' (alpine | debian)" ;; esac
done
command -v uv >/dev/null || die "uv is required (https://docs.astral.sh/uv/)"

COMMIT="$(git -C "$ROOT_DIR" rev-parse --verify --quiet "${GIT_REF}^{commit}")" || die "unknown GIT_REF: ${GIT_REF}"
REF_VERSION="$(git -C "$ROOT_DIR" show "${COMMIT}:pyproject.toml" | sed -n 's/^version *= *"\(.*\)"/\1/p' | head -1)"
[ -n "$REF_VERSION" ] || die "no version in pyproject.toml at ${GIT_REF}"
TAG="v${REF_VERSION}"

dirty_docker="$(git -C "$ROOT_DIR" status --porcelain -- docker .dockerignore)"
if [ "$PUSH" = "1" ]; then
# A published image must be exactly the released code
ref_tag="${GIT_REF#refs/tags/}"
if [ "$ref_tag" != "$TAG" ] && git -C "$ROOT_DIR" rev-parse --verify --quiet "refs/tags/${ref_tag}" >/dev/null; then
die "tag ${ref_tag} has version ${REF_VERSION} in pyproject.toml: the release tag must be ${TAG}"
fi
TAG_COMMIT="$(git -C "$ROOT_DIR" rev-parse --verify --quiet "refs/tags/${TAG}^{commit}")" \
|| die "tag ${TAG} does not exist: tag the release before pushing its images"
[ "$TAG_COMMIT" = "$COMMIT" ] \
|| die "${GIT_REF} (${COMMIT:0:9}) is not the release tag ${TAG} (${TAG_COMMIT:0:9}); push with GIT_REF=${TAG}"
[ -z "$dirty_docker" ] || die "uncommitted changes in docker/ or .dockerignore:\n${dirty_docker}"
else
[ -z "$dirty_docker" ] || echo "warning: building with uncommitted changes in docker/ or .dockerignore" >&2
if [ "$GIT_REF" = "HEAD" ] && [ -n "$(git -C "$ROOT_DIR" status --porcelain -- whiteneuron pyproject.toml uv.lock)" ]; then
echo "warning: uncommitted changes under whiteneuron/, pyproject.toml or uv.lock are NOT included (building HEAD)" >&2
fi
fi

CONTEXT_DIR="$(mktemp -d)"
trap 'rm -rf "$CONTEXT_DIR"' EXIT
git -C "$ROOT_DIR" archive --format=tar "$COMMIT" | tar -x -C "$CONTEXT_DIR"
cp "${ROOT_DIR}/.dockerignore" "$CONTEXT_DIR/.dockerignore"
[ -f "$CONTEXT_DIR/uv.lock" ] || die "no uv.lock at ${GIT_REF}"
(cd "$CONTEXT_DIR" && uv export --frozen --no-dev --no-emit-project --no-hashes \
--format requirements-txt --quiet --output-file docker-requirements.txt)

BUILDER_ARGS=()
if [ "$PUSH" = "1" ]; then
# Attestations need a BuildKit container builder (the plain "docker" driver can't on
# the classic image store). CI (setup-buildx-action) already runs one.
if docker buildx inspect 2>/dev/null | grep -qE '^Driver: +docker$'; then
docker buildx inspect wn-base >/dev/null 2>&1 || docker buildx create --name wn-base --driver docker-container >/dev/null
BUILDER_ARGS=(--builder wn-base)
fi
OUTPUT=(--provenance=mode=max --sbom=true --push)
echo "==> Building and pushing base images ${TAG} (${COMMIT:0:9})..."
else
OUTPUT=(--load)
echo "==> Building base images ${TAG} from ${GIT_REF} (${COMMIT:0:9}, local only)..."
fi

for variant in $VARIANTS; do
image="${IMAGE_NAMESPACE}/django-whiteneuron-${variant}:${TAG}"
docker buildx build "${BUILDER_ARGS[@]}" \
--platform "${WN_PLATFORM}" \
--file "${SCRIPT_DIR}/Dockerfile.base-${variant}" \
--build-arg "PYTHON_VERSION=${WN_PYTHON_VERSION}" \
--build-arg "WHITENEURON_VERSION=${TAG}" \
--label "org.opencontainers.image.revision=${COMMIT}" \
--tag "$image" \
--pull \
"${OUTPUT[@]}" \
"$CONTEXT_DIR"
echo "==> ${image}"
done

echo "==> Base images ready."
Loading
Loading