diff --git a/CLAUDE.md b/CLAUDE.md
index 101df8a9..b3d4e5eb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -132,6 +132,8 @@ the existing viewset/serializer pattern and keep `v1` fields additive-only
- `TIME_ZONE = 'America/Los_Angeles'`. `ML_WEBSITE_VERSION` in settings is shown in the admin header and used in release tagging.
- **Logging (#1283):** `debug.log` lives at `LOG_DIR/debug.log`, where `LOG_DIR` is `$ML_LOG_DIR` or `/media` (`/code/media` in the container). Keep it inside `MEDIA_ROOT` — that's the tree bind-mounted to the shared CSE filesystem, so it's what makes the log readable over SSH at all. `ML_LOG_DIR` is unset everywhere today; it exists for non-`/code` hosts. `MEDIA_ROOT` is web-served, so never log anything sensitive. If the dir isn't writable the file handler degrades to a `NullHandler` rather than crashing `django.setup()`, and since there's no console on the servers that state surfaces via `/version.json` (`log_to_file`) and a superuser-only callout on the admin dashboard. Rotation uses `concurrent-log-handler` (#1439) because Gunicorn's 3 workers share one file — the stdlib `RotatingFileHandler` races on rollover across processes. Its lock file goes in a per-uid temp dir (`/tmp/makelab-log-locks-`), never the web-served media root and never shared across users. If the package isn't importable (the bind-mounted checkout can be ahead of the image's site-packages) or no lock dir is usable, the handler degrades to the stdlib `RotatingFileHandler` instead of crashing `django.setup()`; `/version.json` reports which one is live as `log_rotation`. `django.db.backends` is pinned to INFO so per-query SQL doesn't dominate the log (or the lock).
+- **Database backups (#1443):** a `db-backup` sidecar service (in *both* compose files) runs `scripts/pg_backup.sh` hourly; the script is a single pass that writes one dated `pg_dump | gzip` per UTC day to `pg_backups/` **inside the postgres data volume**, prunes past `BACKUP_RETENTION_DAYS` (14, never the newest dump), and writes `status.json` to a small volume the website container mounts read-only. Dumps go inside the data volume on purpose — that's the volume CSE IT snapshots, so every snapshot carries a consistent restore point. Two things are load-bearing: the sidecar's `entrypoint` **must** stay overridden (the postgres image's own entrypoint would start a second server on that `PGDATA`), and the scheduling loop lives in the compose file rather than in the script (`docker compose up -d` only recreates containers whose *config* changed, so a loop inside the bind-mounted script would run stale code forever after a deploy). Django only ever *reads* the status: `website/utils/backup_status.py` → `/version.json` (`backup_ok`), a superuser callout on the admin dashboard shown only when stale/failed, and a panel on Data Health. Dumps contain `Person.email` — never move one under a web-served path. Restore procedure, the `initdb`-refuses-a-non-empty-directory gotcha, and the two Docker-based restore harnesses (`scripts/test_backup_restore*.sh`): `docs/BACKUPS.md`.
+
### Container startup side effects (`docker-entrypoint.sh`)
Every container start runs, in order: `collectstatic` → `makemigrations` → `migrate` → `makemigrations website` → `migrate website` → `delete_unused_files` → `thumbnail_cleanup` → `generate_slugs_for_old_news_items` → `auto_close_project_roles` → `remove_year_from_forum_name` → `fix_sortedm2m_columns` → `seed_sidewalk_participants` → `warm_api_thumbnails` → `runserver 0.0.0.0:8000`. The repeated `makemigrations website` step is intentional (fixes first-run issues). If you add a one-shot data migration command under `website/management/commands/`, decide whether it belongs in this startup sequence.
diff --git a/docker-compose-local-dev.yml b/docker-compose-local-dev.yml
index 2e99894d..548d5d04 100644
--- a/docker-compose-local-dev.yml
+++ b/docker-compose-local-dev.yml
@@ -72,6 +72,66 @@ services:
timeout: 5s
retries: 5
+ # ===========================================================================
+ # DATABASE BACKUP SERVICE (#1443)
+ # ===========================================================================
+ # Writes a dated, gzipped pg_dump into the postgres data volume once a day.
+ #
+ # On the servers this exists because volume-level snapshots of a *live*
+ # postgres data directory are only probably restorable; the dump is the
+ # guaranteed-consistent restore point. It runs locally too so that the same
+ # code path is exercised in development rather than only in production.
+ #
+ # Two things here are load-bearing and should not be "simplified":
+ # 1. `entrypoint` is overridden. Left alone, the postgres image's own
+ # entrypoint would try to start a second database server on this PGDATA.
+ # 2. The scheduling loop lives here, not inside pg_backup.sh, because
+ # `docker compose up -d` only recreates containers whose config changed.
+ # A loop inside the bind-mounted script would keep running stale code
+ # after a deploy.
+ #
+ # To force a backup immediately instead of waiting for the next pass:
+ # docker compose -f docker-compose-local-dev.yml exec db-backup \
+ # bash /backup-scripts/pg_backup.sh
+ #
+ # To verify and restore, see docs/BACKUPS.md.
+ db-backup:
+ # Same image as `db` so the pg_dump binary version matches the server.
+ image: 'postgres:16'
+ restart: always
+
+ environment:
+ # libpq connection settings; must match the `db` service above.
+ - PGHOST=db
+ - PGUSER=admin
+ - PGPASSWORD=password
+ - PGDATABASE=makeability
+ # Delete dumps older than this, but never the most recent one.
+ - BACKUP_RETENTION_DAYS=14
+ # How long to wait between passes, and after a failed pass.
+ - BACKUP_POLL_SECONDS=3600
+ - BACKUP_RETRY_SECONDS=300
+
+ volumes:
+ # The database volume, so dumps land inside the thing that gets snapshotted.
+ - postgres-data:/var/lib/postgresql/data
+ # Small shared volume for the status file Django reads.
+ - backup-status:/var/backup-status
+ # The backup script, mounted as a directory so edits survive git checkouts.
+ - ./scripts:/backup-scripts:ro
+
+ # `sleep ... & wait $!` plus the leading `trap`, instead of a plain
+ # foreground `sleep`, so `docker compose stop`/`down` return promptly:
+ # bash only acts on a trapped signal between foreground commands, so a
+ # SIGTERM arriving during a plain `sleep 3600` would sit unhandled for up
+ # to an hour and this container would always eat the full stop grace
+ # period before Docker escalates to SIGKILL.
+ entrypoint: ["/bin/bash", "-c", "trap 'exit 0' TERM INT; while true; do if bash /backup-scripts/pg_backup.sh; then sleep \"$${BACKUP_POLL_SECONDS}\" & wait $!; else sleep \"$${BACKUP_RETRY_SECONDS}\" & wait $!; fi; done"]
+
+ depends_on:
+ db:
+ condition: service_healthy
+
# ===========================================================================
# WEBSITE SERVICE (Django Application)
# ===========================================================================
@@ -94,6 +154,9 @@ services:
# This enables "live reloading"—when you edit local files, the changes
# are immediately visible inside the container without rebuilding.
- .:/code
+ # Read-only view of the backup status file so Django can report backup
+ # health on the admin dashboard and /version.json (#1443).
+ - backup-status:/var/backup-status:ro
healthcheck:
# Check if Django is responding
@@ -158,4 +221,6 @@ services:
# Run 'docker volume ls' to see all volumes.
# Run 'docker volume rm postgres-data' to delete (WARNING: destroys all data).
volumes:
- postgres-data:
\ No newline at end of file
+ postgres-data:
+ # Disposable: holds only the backup status.json, regenerated every pass.
+ backup-status:
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 17fde606..e3d0200a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,45 @@ services:
- POSTGRES_PASSWORD=password
volumes:
- db-data:/var/lib/postgresql/data
+ # Consistent daily pg_dump into the postgres data volume (#1443). CSE IT's
+ # ZFS snapshots capture the raw volume, which is only *probably* restorable
+ # for a live database; this writes a guaranteed-consistent restore point
+ # inside that same volume so every snapshot carries one. See docs/BACKUPS.md.
+ #
+ # Uses the same image as `db` so pg_dump's version always matches the server.
+ # `entrypoint` MUST stay overridden: the postgres image's own entrypoint would
+ # otherwise try to bring up a second server on this PGDATA.
+ #
+ # The scheduling loop lives here rather than inside pg_backup.sh on purpose —
+ # `docker compose up -d` only recreates a container whose config changed, so a
+ # loop inside the bind-mounted script would keep running the old code forever
+ # after a deploy. Re-invoking the script each pass makes logic changes deploy.
+ #
+ # The `sleep ... & wait $!` (rather than a plain foreground `sleep`) and the
+ # leading `trap` are so `docker compose stop`/`down` return promptly: bash
+ # only acts on a trapped signal between foreground commands, so with a plain
+ # `sleep 3600` a SIGTERM sent while idle would sit unhandled for up to an
+ # hour and this container would always eat the full stop grace period before
+ # Docker escalates to SIGKILL. Backgrounding the sleep and `wait`-ing on it
+ # makes the trap run immediately instead.
+ db-backup:
+ image: "${POSTGRES_IMAGE:-postgres}"
+ restart: always
+ environment:
+ - PGHOST=db
+ - PGUSER=admin
+ - PGPASSWORD=password
+ - PGDATABASE=makeability
+ - BACKUP_RETENTION_DAYS=${BACKUP_RETENTION_DAYS:-14}
+ - BACKUP_POLL_SECONDS=${BACKUP_POLL_SECONDS:-3600}
+ - BACKUP_RETRY_SECONDS=${BACKUP_RETRY_SECONDS:-300}
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ - backup-status:/var/backup-status
+ - ./scripts:/backup-scripts:ro
+ entrypoint: ["/bin/bash", "-c", "trap 'exit 0' TERM INT; while true; do if bash /backup-scripts/pg_backup.sh; then sleep \"$${BACKUP_POLL_SECONDS}\" & wait $!; else sleep \"$${BACKUP_RETRY_SECONDS}\" & wait $!; fi; done"]
+ depends_on:
+ - db
website:
environment:
- DJANGO_ENV=${DJANGO_ENV:-TEST}
@@ -23,6 +62,7 @@ services:
- .:/code
- ${MEDIA_PATH:-./media}:/code/media
- ${CONFIG_PATH:-./config-dev.ini}:/code/config.ini # for loading vars into ConfigParser in Django
+ - backup-status:/var/backup-status:ro # read-only: Django reports backup health (#1443)
depends_on:
- db
command: ["./docker-entrypoint.sh", "db", "python", "manage.py"]
@@ -32,3 +72,8 @@ volumes:
db-data:
external: true
name: "makeabilitylabcswashingtonedu_${POSTGRES_VOLUME:-postgres-data}"
+ # Holds only the backup status.json that Django reads to report backup health.
+ # Intentionally NOT external and NOT something CSE IT needs to back up: it is
+ # disposable state, regenerated on the next backup pass. The dumps themselves
+ # live in db-data above, which is the volume that gets snapshotted.
+ backup-status:
diff --git a/docs/BACKUPS.md b/docs/BACKUPS.md
new file mode 100644
index 00000000..fe9b96a6
--- /dev/null
+++ b/docs/BACKUPS.md
@@ -0,0 +1,196 @@
+# Database backups and restore
+
+How the Makeability Lab website's data is backed up, how to check that it's
+actually happening, and how to restore it. See issue
+[#1443](https://github.com/makeabilitylab/makeabilitylabwebsite/issues/1443).
+
+**Read the [Restoring](#restoring) section before you need it.** The one step
+people get wrong under pressure is documented there: Postgres will not
+initialize into a non-empty data directory, and the dumps live *inside* that
+directory.
+
+## What is backed up, and by whom
+
+| Data | Where it lives | How it's protected |
+| --- | --- | --- |
+| Uploaded media (PDFs, images) | `/cse/web/research/makelab/www[-test]/` on the shared CSE filesystem | CSE IT's standard snapshot schedule — hourly, weekly, monthly, plus off-site to UW's lolo service. Retained 1 year. Plain files, so a snapshot is always consistent. |
+| Code and schema | git | GitHub |
+| **Database contents** | the `db` container's named volume (`db-data` → `/var/lib/postgresql/data`) | Two tiers, below |
+
+The database is the only piece that needs special handling, because a
+filesystem-level snapshot of a **live** Postgres data directory is not
+guaranteed to be transaction-consistent. Restoring one behaves like recovering
+from a hard power cut — usually fine, occasionally not.
+
+So there are two tiers:
+
+| Tier | Cadence | Guarantee |
+| --- | --- | --- |
+| CSE IT's ZFS snapshot of the raw volume | hourly | *Probably* restorable. Postgres crash recovery is designed for exactly this case. |
+| Our `pg_dump`, written **into** that volume | daily | Guaranteed consistent restore point. |
+
+Because the dump lives inside the volume that gets snapshotted, every snapshot
+automatically carries a known-good dump. A snapshot from six months ago contains
+that day's dump, which is why in-volume retention only needs to be 14 days.
+
+> **The dump cadence is the guaranteed worst-case RPO, and more snapshots don't
+> improve it.** Every hourly snapshot taken between two dumps contains the *same*
+> dump. Hourly snapshots give more copies of one restore point, not more restore
+> points.
+
+## How it works
+
+A `db-backup` sidecar service in `docker-compose.yml` runs
+[`scripts/pg_backup.sh`](../scripts/pg_backup.sh) once an hour. The script is a
+single pass: if today's dump doesn't exist yet it makes one, prunes anything past
+retention, and writes a status file.
+
+- **Dumps:** `/var/lib/postgresql/data/pg_backups/makeability-YYYY-MM-DD.sql.gz`
+ (UTC date, mode 0600).
+- **Retention:** 14 days, but the newest dump is *never* pruned regardless of
+ age — otherwise a backup that had been failing for longer than the retention
+ window would end with pruning deleting the last good dump too.
+- **Status:** `status.json` on a small shared volume that the website container
+ mounts read-only.
+
+Two things in the compose config are load-bearing and shouldn't be "simplified":
+
+1. `entrypoint` is overridden. Left alone, the postgres image's own entrypoint
+ would try to start a second database server on that `PGDATA`.
+2. The scheduling loop lives in `docker-compose.yml`, not inside
+ `pg_backup.sh`. `docker compose up -d` only recreates a container whose
+ *config* changed, so a loop inside the bind-mounted script would keep running
+ stale code after a deploy — and there's no shell access to restart it by hand.
+
+## Checking that backups are actually running
+
+Because the dumps sit in a Docker volume on a host nobody has a shell on — and
+inside a `PGDATA` the website container can't even traverse — the status file is
+the only way to observe this. Three places surface it:
+
+- **`/version.json`** — `backup_ok`, `last_backup_at`, `backup_age_hours`,
+ `backup_count`. No auth needed; use this for any external check.
+- **Admin dashboard** — a superuser-only warning callout, shown *only* when
+ backups are stale or failing.
+- **Admin → Data Health** — a panel with last success, age, size, how many
+ dumps are retained, and the last error.
+
+"Stale" means the newest dump is more than 36 hours old (`BACKUP_STALE_AFTER_HOURS`).
+That's 1.5× the daily cadence, so one missed run doesn't cry wolf but a second
+consecutive one does.
+
+## Restoring
+
+### The gotcha, first
+
+The dumps live at `pg_backups/` **inside** the Postgres data directory. `initdb`
+refuses to initialize into a non-empty directory, so you cannot wipe the database
+and leave the backups sitting there. **Copy the dump out of the volume first.**
+This is pinned by a test in `scripts/test_backup_restore.sh` so the warning can't
+silently go stale.
+
+### Restoring locally (development, or verifying a dump)
+
+```bash
+# 1. Get the dump out of the volume and onto your machine.
+docker compose -f docker-compose-local-dev.yml cp \
+ db-backup:/var/lib/postgresql/data/pg_backups/makeability-2026-08-07.sql.gz .
+
+# 2. Stop the stack and destroy the database volume.
+docker compose -f docker-compose-local-dev.yml down
+docker volume rm makeabilitylabwebsite_postgres-data
+
+# 3. Bring just the database back up on a fresh, empty volume.
+docker compose -f docker-compose-local-dev.yml up -d db
+
+# 4. Restore.
+gunzip -c makeability-2026-08-07.sql.gz | \
+ docker compose -f docker-compose-local-dev.yml exec -T db \
+ psql -v ON_ERROR_STOP=1 -U admin -d makeability
+
+# 5. Start the site and confirm Django agrees the database is complete.
+docker compose -f docker-compose-local-dev.yml up -d
+docker compose -f docker-compose-local-dev.yml exec website python manage.py migrate --check
+```
+
+Step 5 is the real test. `migrate --check` exits non-zero if Django thinks
+migrations are pending, which is how you'd catch a restore that brought back
+tables but not the `django_migrations` table.
+
+### Restoring production or test
+
+**This requires someone with Docker access on the host** — `grabthar` for
+production, `docker-test2` for test. The maintainer does not have that (see the
+server access model in `CLAUDE.md`), so a production restore means opening a
+ticket with UW CSE IT. Send them this section.
+
+The steps are the same as above, against `docker-compose.yml` and the external
+volume `makeabilitylabcswashingtonedu_postgres16-data`. Before destroying
+anything:
+
+1. **Copy the chosen dump somewhere off the volume first.** If the volume itself
+ is the problem, ask CSE IT to recover the dump from a ZFS snapshot or from
+ lolo instead — the dump inside a snapshot is exactly what this whole scheme
+ exists to provide.
+2. **Take a copy of the current broken volume before overwriting it.** A
+ corrupt database still contains data; a hasty restore over the top of it
+ destroys any chance of salvaging rows the dump predates.
+3. Restore, then confirm via `/version.json` and by loading the site.
+
+There's no ad-hoc "back up right now" button, deliberately — see the follow-up
+note in #1443. If you need a fresh dump before something risky and you can't
+reach the host, the practical options are to wait for the next pass or ask CSE
+IT to run one.
+
+## Testing the backups
+
+Two harnesses, both self-contained and namespaced so they never touch your real
+stack, database, or volumes. **An untested backup is not a backup** — run these
+after any change to `pg_backup.sh` or the compose wiring.
+
+```bash
+# Mechanics: dump → destroy → restore, on a synthetic schema built to break a
+# naive dump (unicode, embedded quotes and newlines, NULLs, binary columns,
+# a 540 KB row, views, foreign keys, sequences). ~1 minute.
+bash scripts/test_backup_restore.sh
+
+# The real thing: builds the actual Makeability Lab schema with `migrate`, seeds
+# through the ORM (sortedm2m through-tables, rich text), backs up, destroys the
+# volume, restores, and asserts Django accepts the result — `migrate --check`
+# and `manage.py check` both pass. Needs a built website image. ~3 minutes.
+bash scripts/test_backup_restore_django.sh
+```
+
+The Django-side unit tests (status file parsing, staleness, failure reporting)
+run in the normal suite:
+
+```bash
+python manage.py test website.tests.test_backup_status --settings=makeabilitylab.settings_test
+```
+
+## Security
+
+The dumps contain personal data — `Person.email`, which is deliberately withheld
+from the public API. They are written mode 0600 into a Docker volume.
+
+**Never move a dump under `media/`, `static/`, or any other web-served path.**
+Everything under those is publicly downloadable. The status file is safe to
+surface in the admin because it carries no row data.
+
+## Configuration
+
+Set on the `db-backup` service in `docker-compose.yml`:
+
+| Variable | Default | Meaning |
+| --- | --- | --- |
+| `BACKUP_RETENTION_DAYS` | 14 | Delete dumps older than this (never the newest). |
+| `BACKUP_MIN_KEEP` | 1 | Dumps always kept regardless of age. |
+| `BACKUP_POLL_SECONDS` | 3600 | Time between passes. |
+| `BACKUP_RETRY_SECONDS` | 300 | Time between passes after a failure. |
+
+Django-side, in `settings.py`:
+
+| Setting | Default | Meaning |
+| --- | --- | --- |
+| `BACKUP_STATUS_FILE` | `/var/backup-status/status.json` | Where to read status from (`ML_BACKUP_STATUS_FILE`). |
+| `BACKUP_STALE_AFTER_HOURS` | 36 | Age at which the dashboard warns (`ML_BACKUP_STALE_AFTER_HOURS`). |
diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py
index 7ecb35b6..bf5c2d82 100644
--- a/makeabilitylab/settings.py
+++ b/makeabilitylab/settings.py
@@ -87,8 +87,8 @@
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Makeability Lab Global Variables, including Makeability Lab version
-ML_WEBSITE_VERSION = "2.32.1" # Keep this updated with each release and also change the short description below
-ML_WEBSITE_VERSION_DESCRIPTION = "debug.log rotation is now multiprocess-safe (concurrent-log-handler): Gunicorn's three workers previously raced on rollover and silently lost log records (#1439)."
+ML_WEBSITE_VERSION = "2.33.0" # Keep this updated with each release and also change the short description below
+ML_WEBSITE_VERSION_DESCRIPTION = "The database now writes a nightly pg_dump into its own volume, so the infrastructure team's snapshots always contain a consistent restore point. Backup health shows on this dashboard and /version.json (#1443)."
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
MAX_BANNERS = 7 # Maximum number of banners on a page
@@ -365,6 +365,24 @@ def _file_log_handler(log_file, level, enabled):
f"multiprocess-safe (concurrent-log-handler importable: "
f"{_HAS_CONCURRENT_LOG_HANDLER}). Check /version.json 'log_rotation'.")
+# ---------------------------------------------------------------------------
+# Database backup health (#1443)
+# ---------------------------------------------------------------------------
+# The db-backup sidecar writes a small JSON status file to a volume shared
+# read-only with this container. Django reads it purely to *report* backup
+# health; it never writes here and never touches the dumps themselves.
+#
+# The reporting is the whole point: the dumps live in a Docker named volume on
+# a host nobody has a shell on, and PGDATA is mode 700/uid 999 while this
+# container runs as uid 48 — so without this file there is no way to observe
+# whether backups are running at all. Same reasoning as LOG_TO_FILE above.
+BACKUP_STATUS_FILE = os.environ.get('ML_BACKUP_STATUS_FILE', '/var/backup-status/status.json')
+
+# How old the newest dump may get before the admin dashboard complains. 36h,
+# not 24h: backups run daily, so one missed pass or a little clock skew should
+# not raise an alarm — but a second consecutive miss should.
+BACKUP_STALE_AFTER_HOURS = int(os.environ.get('ML_BACKUP_STALE_AFTER_HOURS', '36'))
+
# Application definition
INSTALLED_APPS = [
'website.apps.WebsiteConfig',
diff --git a/scripts/pg_backup.sh b/scripts/pg_backup.sh
new file mode 100755
index 00000000..e55a869b
--- /dev/null
+++ b/scripts/pg_backup.sh
@@ -0,0 +1,192 @@
+#!/bin/bash
+#
+# One backup pass for the Makeability Lab database (#1443).
+#
+# Writes a dated, gzipped pg_dump into a subdirectory of the postgres data
+# volume so that CSE IT's volume-level ZFS snapshots always contain a
+# transaction-consistent restore point. A filesystem snapshot of a *live*
+# PGDATA is only probably restorable (it looks like a power cut to postgres);
+# this dump is the guaranteed tier. See docs/BACKUPS.md.
+#
+# This script deliberately performs a SINGLE pass and exits. The scheduling
+# loop lives in docker-compose.yml instead, because `docker compose up -d`
+# only recreates a container whose *config* changed: if the loop lived here,
+# in this bind-mounted file, edits to the backup logic would never take effect
+# on a running server (and there is no shell access to restart it by hand).
+# Re-invoking the script each pass means logic changes deploy normally.
+#
+# Runs as root, which is needed to write the shared status volume: Docker
+# creates named-volume roots as root:root, so an unprivileged uid could not
+# create files there. The container's only job is to run pg_dump over the
+# stack's private network.
+#
+# Environment:
+# PGHOST/PGUSER/PGPASSWORD/PGDATABASE standard libpq connection settings
+# BACKUP_DIR where dumps are written (default: inside PGDATA)
+# STATUS_DIR where status.json is written for Django to read
+# BACKUP_RETENTION_DAYS delete dumps older than this (default 14)
+# BACKUP_MIN_KEEP never prune below this many dumps (default 1)
+#
+# Usage (one pass, as run by the db-backup service):
+# bash /backup-scripts/pg_backup.sh
+
+# No `set -e`: a failed dump must still fall through to write a status file
+# recording the failure, otherwise a broken backup is indistinguishable from
+# one that has simply not run yet. pipefail is required so that a pg_dump
+# failure is not masked by gzip's success.
+set -uo pipefail
+
+BACKUP_DIR="${BACKUP_DIR:-/var/lib/postgresql/data/pg_backups}"
+STATUS_DIR="${STATUS_DIR:-/var/backup-status}"
+STATUS_FILE="$STATUS_DIR/status.json"
+RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}"
+MIN_KEEP="${BACKUP_MIN_KEEP:-1}"
+DB_NAME="${PGDATABASE:-makeability}"
+
+# Dumps contain personal data (Person.email, which is deliberately withheld
+# from the public API), so they are created mode 0600. They live in a Docker
+# volume, never under a web-served path.
+umask 077
+
+now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
+
+# Escape a string for embedding in a JSON string literal, and collapse it to a
+# single line. Only used for error text, which is untrusted-ish (stderr from
+# pg_dump) and must not be able to corrupt the status file's JSON.
+json_escape() {
+ printf '%s' "$1" | tr '\n\r\t' ' ' \
+ | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | cut -c1-500
+}
+
+file_mtime_iso() { date -u -d "@$(stat -c %Y "$1")" +%Y-%m-%dT%H:%M:%SZ; }
+
+# ---------------------------------------------------------------------------
+# Write status.json atomically (temp file + rename) so Django never reads a
+# half-written file. Mode 0644 because the website container reads it as a
+# different uid (48/apache) than this one.
+# ---------------------------------------------------------------------------
+write_status() {
+ local ok="$1" error="$2"
+ local last_at='null' last_file='null' last_bytes='null'
+ local oldest_at='null' count=0
+ local newest oldest
+
+ newest="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name '*.sql.gz' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n 1 | cut -d' ' -f2-)"
+ oldest="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name '*.sql.gz' -printf '%T@ %p\n' 2>/dev/null | sort -n | head -n 1 | cut -d' ' -f2-)"
+ count="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name '*.sql.gz' 2>/dev/null | wc -l)"
+
+ if [ -n "$newest" ]; then
+ last_at="\"$(file_mtime_iso "$newest")\""
+ last_file="\"$(json_escape "$(basename "$newest")")\""
+ last_bytes="$(stat -c %s "$newest")"
+ fi
+ if [ -n "$oldest" ]; then
+ oldest_at="\"$(file_mtime_iso "$oldest")\""
+ fi
+
+ local error_json='null'
+ if [ -n "$error" ]; then
+ error_json="\"$(json_escape "$error")\""
+ fi
+
+ mkdir -p "$STATUS_DIR"
+ local tmp="$STATUS_FILE.tmp"
+ cat > "$tmp" </dev/null \
+ | sort -rn | head -n "$MIN_KEEP" | cut -d' ' -f2-)
+
+ find "$BACKUP_DIR" -maxdepth 1 -type f -name '*.sql.gz' \
+ -mtime "+$RETENTION_DAYS" "${keep_args[@]}" -delete 2>/dev/null
+}
+
+# ---------------------------------------------------------------------------
+# Main pass
+# ---------------------------------------------------------------------------
+if ! mkdir -p "$BACKUP_DIR"; then
+ # Can't even reach the volume; still try to report it.
+ write_status false "Could not create backup directory $BACKUP_DIR"
+ exit 1
+fi
+chmod 0700 "$BACKUP_DIR"
+
+# UTC date, so the rollover point does not shift with daylight saving. Note
+# this means the file named for a given day rolls over at 5pm/4pm Pacific.
+target="$BACKUP_DIR/${DB_NAME}-$(date -u +%F).sql.gz"
+
+if [ -f "$target" ]; then
+ # Today's dump already exists. Still prune and refresh status so a running
+ # container keeps its status file current between dumps.
+ prune_old_backups
+ write_status true ""
+ exit 0
+fi
+
+tmp_target="$target.partial"
+err_file="$(mktemp)"
+
+# --no-owner/--no-privileges keep the dump restorable into a database owned by
+# a differently-named role. There is no separate pg_dumpall for globals: the
+# only role is the one POSTGRES_USER recreates on any fresh container.
+pg_dump --no-owner --no-privileges "$DB_NAME" 2>"$err_file" | gzip -c > "$tmp_target"
+dump_status=$?
+
+if [ "$dump_status" -ne 0 ]; then
+ rm -f "$tmp_target"
+ write_status false "pg_dump failed (exit $dump_status): $(cat "$err_file")"
+ rm -f "$err_file"
+ exit 1
+fi
+
+# Catch a truncated or corrupt archive before it is promoted to today's dump —
+# otherwise a bad file would satisfy the "today already done" check above and
+# suppress every retry until tomorrow.
+if ! gzip -t "$tmp_target" 2>>"$err_file"; then
+ rm -f "$tmp_target"
+ write_status false "gzip integrity check failed: $(cat "$err_file")"
+ rm -f "$err_file"
+ exit 1
+fi
+
+# Promote atomically: a container killed mid-dump leaves only a .partial file,
+# never something that looks like a finished backup.
+mv -f "$tmp_target" "$target"
+rm -f "$err_file"
+
+prune_old_backups
+write_status true ""
+echo "Wrote $(basename "$target") ($(stat -c %s "$target") bytes)"
diff --git a/scripts/test_backup_restore.sh b/scripts/test_backup_restore.sh
new file mode 100755
index 00000000..d09fb4d5
--- /dev/null
+++ b/scripts/test_backup_restore.sh
@@ -0,0 +1,374 @@
+#!/bin/bash
+#
+# End-to-end proof that scripts/pg_backup.sh produces a dump that actually
+# restores (#1443). An untested backup is not a backup.
+#
+# This exercises the real disaster path, not a simulation of it:
+# 1. stand up a throwaway postgres and seed it with awkward data
+# 2. run the REAL scripts/pg_backup.sh against it
+# 3. assert the dump and status file look right
+# 4. copy the dump out, then DESTROY the container and its volume
+# 5. bring up postgres on a genuinely fresh volume and restore
+# 6. assert the restored database is byte-identical in content
+#
+# It also pins the initdb-refuses-a-non-empty-directory behavior that
+# docs/BACKUPS.md warns about, so that caveat can't silently go stale.
+#
+# Everything is namespaced under mlbackuptest-* and torn down at the end, so
+# this never touches the developer's real stack, database, or volumes.
+#
+# Usage:
+# bash scripts/test_backup_restore.sh
+#
+# Requires: docker, the postgres image used by the stack, and python3 on the
+# host (used to parse status.json in the assertions below — everything
+# postgres-specific runs inside a container, but that parsing does not).
+
+set -uo pipefail
+
+PROJECT=mlbackuptest
+IMAGE="${POSTGRES_IMAGE:-postgres:16}"
+NET="$PROJECT-net"
+DATA_VOL="$PROJECT-data"
+STATUS_VOL="$PROJECT-status"
+DB_CONTAINER="$PROJECT-db"
+DB_NAME=makeability
+DB_USER=admin
+DB_PASS=password
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# Under /tmp specifically: Docker Desktop on macOS shares /tmp by default, and
+# the rescued dump has to be bind-mountable back into a container.
+WORK_DIR="$(mktemp -d /tmp/mlbackuptest.XXXXXX)"
+
+PASS=0
+FAIL=0
+
+green() { printf '\033[32m%s\033[0m\n' "$1"; }
+red() { printf '\033[31m%s\033[0m\n' "$1"; }
+step() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
+
+ok() { PASS=$((PASS+1)); green " PASS $1"; }
+bad() { FAIL=$((FAIL+1)); red " FAIL $1"; }
+
+assert_eq() {
+ # assert_eq
+ {% comment %}
+ Database-backup health (#1443). Unlike the admin dashboard callout, which
+ appears only when something is wrong, this panel is always rendered: this
+ page is where you come to look, and "when did it last succeed?" is a
+ reasonable question even when nothing is broken. Values come from the
+ db-backup sidecar's status.json, read via each_context.
+ {% endcomment %}
+
Database backups
+
+
+ Nightly pg_dump written into the postgres data volume so the
+ infrastructure team's volume snapshots always contain a consistent restore
+ point. Restore instructions live in docs/BACKUPS.md.
+
@@ -55,9 +128,24 @@
.dh-count { font-variant-numeric: tabular-nums; font-weight: 600; }
.dh-count-ok { color: #2e7d32; }
.dh-count-warn { color: #b54708; }
+ /* Backup status panel (#1443) */
+ .dh-section-heading { font-size: 15px; margin: 24px 0 8px; }
+ .dh-backup { border-collapse: collapse; margin-bottom: 8px; max-width: 60em; }
+ .dh-backup caption {
+ caption-side: bottom;
+ text-align: left;
+ font-size: 12px;
+ color: #777;
+ padding-top: 6px;
+ max-width: 60em;
+ }
+ .dh-backup th, .dh-backup td { padding: 6px 12px 6px 0; text-align: left; vertical-align: top; }
+ .dh-backup th { white-space: nowrap; font-weight: 600; }
+ .dh-backup-error { white-space: pre-wrap; word-break: break-word; }
@media (prefers-color-scheme: dark) {
.dh-intro { color: #bbb; }
.dh-desc { color: #999; }
+ .dh-backup caption { color: #999; }
.dh-count-ok { color: #7bc47f; }
.dh-count-warn { color: #e8a14b; }
}
diff --git a/website/templates/admin/index.html b/website/templates/admin/index.html
index 9341d98e..d8ed7836 100644
--- a/website/templates/admin/index.html
+++ b/website/templates/admin/index.html
@@ -28,6 +28,29 @@
{% endif %}
+{% comment %}
+ Unhealthy-backup warning (#1443). Shown ONLY when something is actually
+ wrong — a permanently-green banner turns into invisible chrome, and this one
+ needs to be read. BACKUP_STATUS comes from MakeabilityLabAdminSite.each_context
+ and is only populated for superusers, so this is inert for everyone else.
+ 'should_warn' rather than 'not healthy' so that a local dev machine with no
+ db-backup service running doesn't nag; the servers still warn. The scriptable
+ equivalent is /version.json's 'backup_ok'.
+{% endcomment %}
+{% if BACKUP_STATUS.should_warn %}
+
+ ⚠️
+ Warning: database backups are not healthy.
+ {{ BACKUP_STATUS.problem }}
+ {% if BACKUP_STATUS.last_backup_file %}
+ Most recent dump: {{ BACKUP_STATUS.last_backup_file }}
+ ({{ BACKUP_STATUS.size_display }}).
+ {% endif %}
+ See the Data Health dashboard
+ for detail, and docs/BACKUPS.md for how to investigate.
+
+{% endif %}
+
{% if user.is_superuser %}
🩺
diff --git a/website/tests/test_backup_status.py b/website/tests/test_backup_status.py
new file mode 100644
index 00000000..c6651133
--- /dev/null
+++ b/website/tests/test_backup_status.py
@@ -0,0 +1,299 @@
+"""
+Tests for database-backup health reporting (#1443).
+
+The end-to-end proof that a dump actually *restores* lives in
+``scripts/test_backup_restore.sh`` and ``scripts/test_backup_restore_django.sh``
+(they need Docker, so they can't run in this suite). These tests cover the
+Django half: reading the sidecar's status file and reporting it correctly.
+
+The most important behavior pinned here is that a broken or missing status file
+degrades to "unknown" instead of raising. This code runs inside
+``each_context``, so an exception would take the entire admin down — the exact
+opposite of what a backup-health feature should do.
+
+``BackupStatusFileTests``/``BackupWarningSuppressionTests``/``FormatBytesTests``
+exercise ``get_backup_status()`` directly. ``AdminBackupWarningTests`` below
+goes one layer further and proves the wiring itself -- that
+``MakeabilityLabAdminSite.each_context`` actually reaches the rendered
+``/admin/`` and Data Health pages -- mirroring ``AdminLoggingWarningTests`` in
+``test_logging_config.py`` for the sibling ``LOG_TO_FILE`` feature.
+"""
+
+import json
+import os
+import tempfile
+from datetime import datetime, timedelta, timezone
+
+from django.contrib.auth import get_user_model
+from django.test import SimpleTestCase, override_settings
+from django.urls import reverse
+
+from website.tests.base import DatabaseTestCase
+from website.utils.backup_status import format_bytes, get_backup_status
+
+NOW = datetime(2026, 8, 7, 12, 0, 0, tzinfo=timezone.utc)
+
+
+def _status_payload(**overrides):
+ """A well-formed status.json body, matching what pg_backup.sh writes."""
+ payload = {
+ 'schema_version': 1,
+ 'database': 'makeability',
+ 'last_attempt_at': '2026-08-07T03:00:00Z',
+ 'last_attempt_ok': True,
+ 'error': None,
+ 'last_backup_at': '2026-08-07T03:00:00Z',
+ 'last_backup_file': 'makeability-2026-08-07.sql.gz',
+ 'last_backup_bytes': 12684,
+ 'oldest_backup_at': '2026-07-25T03:00:00Z',
+ 'backup_count': 14,
+ 'retention_days': 14,
+ }
+ payload.update(overrides)
+ return payload
+
+
+class BackupStatusFileTests(SimpleTestCase):
+ """Reading and interpreting the sidecar's status file."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.path = os.path.join(self.tmp.name, 'status.json')
+
+ def _write(self, payload):
+ with open(self.path, 'w', encoding='utf-8') as handle:
+ if isinstance(payload, str):
+ handle.write(payload)
+ else:
+ json.dump(payload, handle)
+ return self.path
+
+ def test_healthy_backup_is_reported_healthy(self):
+ self._write(_status_payload())
+ status = get_backup_status(status_file=self.path, now=NOW)
+
+ self.assertTrue(status['available'])
+ self.assertTrue(status['ok'])
+ self.assertTrue(status['healthy'])
+ self.assertFalse(status['stale'])
+ self.assertFalse(status['should_warn'])
+ self.assertIsNone(status['problem'])
+ self.assertEqual(status['age_hours'], 9.0)
+ self.assertEqual(status['backup_count'], 14)
+ self.assertEqual(status['last_backup_file'], 'makeability-2026-08-07.sql.gz')
+
+ @override_settings(BACKUP_STALE_AFTER_HOURS=36)
+ def test_backup_older_than_threshold_is_stale(self):
+ old = (NOW - timedelta(hours=50)).strftime('%Y-%m-%dT%H:%M:%SZ')
+ self._write(_status_payload(last_backup_at=old))
+ status = get_backup_status(status_file=self.path, now=NOW)
+
+ self.assertTrue(status['stale'])
+ self.assertFalse(status['healthy'])
+ self.assertTrue(status['should_warn'])
+ self.assertIn('50 hours old', status['problem'])
+
+ @override_settings(BACKUP_STALE_AFTER_HOURS=36)
+ def test_backup_inside_threshold_is_not_stale(self):
+ # 30h is over a day old but under the threshold: one missed nightly run
+ # (or clock skew) must not raise an alarm, or the warning gets ignored.
+ recent = (NOW - timedelta(hours=30)).strftime('%Y-%m-%dT%H:%M:%SZ')
+ self._write(_status_payload(last_backup_at=recent))
+ status = get_backup_status(status_file=self.path, now=NOW)
+
+ self.assertFalse(status['stale'])
+ self.assertTrue(status['healthy'])
+
+ def test_failed_attempt_is_reported_with_its_error(self):
+ # The case that would otherwise look identical to "hasn't run yet".
+ self._write(_status_payload(
+ last_attempt_ok=False,
+ error='pg_dump failed (exit 1): connection refused',
+ ))
+ status = get_backup_status(status_file=self.path, now=NOW)
+
+ self.assertFalse(status['ok'])
+ self.assertFalse(status['healthy'])
+ self.assertTrue(status['should_warn'])
+ self.assertIn('connection refused', status['problem'])
+
+ def test_status_with_no_successful_backup_is_not_healthy(self):
+ # Sidecar ran, reported success, but no dump file exists yet. That is
+ # not a healthy state even though last_attempt_ok is true.
+ self._write(_status_payload(last_backup_at=None, last_backup_file=None,
+ backup_count=0))
+ status = get_backup_status(status_file=self.path, now=NOW)
+
+ self.assertFalse(status['healthy'])
+ self.assertTrue(status['stale'])
+ self.assertIn('No successful backup', status['problem'])
+
+ def test_missing_file_does_not_raise(self):
+ status = get_backup_status(status_file=os.path.join(self.tmp.name, 'nope.json'),
+ now=NOW)
+ self.assertFalse(status['available'])
+ self.assertFalse(status['healthy'])
+ self.assertIsNone(status['age_hours'])
+ self.assertIn('No backup status file', status['problem'])
+
+ def test_malformed_json_does_not_raise(self):
+ # A status file caught mid-write, or truncated. This runs inside
+ # each_context; raising here would break every admin page.
+ self._write('{"last_attempt_ok": true, "last_backu')
+ status = get_backup_status(status_file=self.path, now=NOW)
+
+ self.assertFalse(status['available'])
+ self.assertFalse(status['healthy'])
+ self.assertTrue(status['should_warn'])
+ self.assertIn('unreadable', status['problem'])
+
+ def test_json_that_is_not_an_object_does_not_raise(self):
+ self._write('["not", "a", "dict"]')
+ status = get_backup_status(status_file=self.path, now=NOW)
+ self.assertFalse(status['available'])
+ self.assertIn('unreadable', status['problem'])
+
+ def test_unparseable_timestamp_does_not_raise(self):
+ self._write(_status_payload(last_backup_at='not-a-timestamp'))
+ status = get_backup_status(status_file=self.path, now=NOW)
+
+ self.assertTrue(status['available'])
+ self.assertIsNone(status['age_hours'])
+ self.assertFalse(status['healthy'])
+
+ def test_missing_keys_do_not_raise(self):
+ self._write({'last_attempt_ok': True})
+ status = get_backup_status(status_file=self.path, now=NOW)
+
+ self.assertTrue(status['available'])
+ self.assertIsNone(status['last_backup_file'])
+ self.assertFalse(status['healthy'])
+
+ def test_every_documented_key_is_always_present(self):
+ # Templates render these unconditionally, so the shape must not depend
+ # on which failure path produced it.
+ expected = {
+ 'available', 'healthy', 'ok', 'stale', 'age_hours', 'last_backup_at',
+ 'last_backup_file', 'last_backup_bytes', 'last_attempt_at',
+ 'oldest_backup_at', 'backup_count', 'retention_days', 'database',
+ 'error', 'problem', 'should_warn', 'size_display', 'status_file',
+ }
+ self._write(_status_payload())
+ self.assertTrue(expected.issubset(get_backup_status(self.path, NOW).keys()))
+ os.remove(self.path)
+ self.assertTrue(expected.issubset(get_backup_status(self.path, NOW).keys()))
+
+
+class BackupWarningSuppressionTests(SimpleTestCase):
+ """
+ A missing status file means different things in different environments.
+
+ Keyed off DJANGO_ENV, not DEBUG: the test server runs DEBUG=True and still
+ needs to be told its backups aren't running.
+ """
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.missing = os.path.join(self.tmp.name, 'absent.json')
+
+ @override_settings(DJANGO_ENV='PROD')
+ def test_missing_file_warns_on_prod(self):
+ self.assertTrue(get_backup_status(self.missing, NOW)['should_warn'])
+
+ @override_settings(DJANGO_ENV='TEST')
+ def test_missing_file_warns_on_test_server_despite_debug(self):
+ self.assertTrue(get_backup_status(self.missing, NOW)['should_warn'])
+
+ @override_settings(DJANGO_ENV=None)
+ def test_missing_file_is_quiet_in_local_dev(self):
+ # A developer not running the db-backup service shouldn't be nagged.
+ self.assertFalse(get_backup_status(self.missing, NOW)['should_warn'])
+
+
+class AdminBackupWarningTests(DatabaseTestCase):
+ """
+ The admin dashboard callout and Data Health panel that surface backup
+ health (#1443), rendered through a real request rather than calling
+ ``get_backup_status()`` in isolation.
+
+ ``get_backup_status()`` being correct doesn't prove
+ ``MakeabilityLabAdminSite.each_context`` actually wires ``BACKUP_STATUS``
+ into the templates, or that the superuser gate in ``each_context`` is
+ doing its job at the HTTP layer -- that's what these tests are for.
+ """
+
+ def setUp(self):
+ super().setUp()
+ User = get_user_model()
+ self.superuser = User.objects.create_superuser(
+ username="backupadmin", email="backupadmin@example.com", password="pw-for-test"
+ )
+ self.editor = User.objects.create_user(
+ username="backupeditor",
+ email="backupeditor@example.com",
+ password="pw-for-test",
+ is_staff=True,
+ )
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.status_file = os.path.join(self.tmp.name, 'status.json')
+
+ def _write_status(self, **overrides):
+ with open(self.status_file, 'w', encoding='utf-8') as handle:
+ json.dump(_status_payload(**overrides), handle)
+ return self.status_file
+
+ def test_warning_shown_to_superuser_when_backup_unhealthy(self):
+ self._write_status(last_attempt_ok=False,
+ error='pg_dump failed (exit 1): connection refused')
+ with override_settings(BACKUP_STATUS_FILE=self.status_file):
+ self.client.force_login(self.superuser)
+ response = self.client.get("/admin/")
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "database backups are not healthy")
+ self.assertContains(response, "connection refused")
+
+ def test_no_warning_when_backup_healthy(self):
+ self._write_status()
+ with override_settings(BACKUP_STATUS_FILE=self.status_file):
+ self.client.force_login(self.superuser)
+ response = self.client.get("/admin/")
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "database backups are not healthy")
+
+ def test_warning_hidden_from_non_superusers(self):
+ """Only the maintainer can act on a backup failure, so don't alarm editors."""
+ self._write_status(last_attempt_ok=False, error='connection refused')
+ with override_settings(BACKUP_STATUS_FILE=self.status_file):
+ self.client.force_login(self.editor)
+ response = self.client.get("/admin/")
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "database backups are not healthy")
+
+ def test_data_health_panel_always_shown_even_when_healthy(self):
+ """
+ Unlike the ``/admin/`` callout, the Data Health panel is rendered
+ unconditionally -- "when did it last succeed?" is worth showing even
+ when nothing is wrong.
+ """
+ self._write_status()
+ with override_settings(BACKUP_STATUS_FILE=self.status_file):
+ self.client.force_login(self.superuser)
+ response = self.client.get(reverse("admin:data_health_dashboard"))
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Database backups")
+ self.assertContains(response, "Healthy")
+ self.assertContains(response, "makeability-2026-08-07.sql.gz")
+
+
+class FormatBytesTests(SimpleTestCase):
+ def test_formats_common_sizes(self):
+ self.assertEqual(format_bytes(512), '512 B')
+ self.assertEqual(format_bytes(12684), '12.4 KB')
+ self.assertEqual(format_bytes(5 * 1024 * 1024), '5.0 MB')
+
+ def test_handles_missing_and_garbage(self):
+ self.assertEqual(format_bytes(None), '—')
+ self.assertEqual(format_bytes('nonsense'), '—')
diff --git a/website/tests/test_version_endpoint.py b/website/tests/test_version_endpoint.py
index 86682451..255c81cc 100644
--- a/website/tests/test_version_endpoint.py
+++ b/website/tests/test_version_endpoint.py
@@ -15,6 +15,8 @@
import json
import os
+import tempfile
+from datetime import datetime, timedelta, timezone
from django.test import SimpleTestCase, override_settings
from django.urls import resolve, reverse
@@ -68,6 +70,60 @@ def test_payload_from_settings_and_no_store_header(self):
# Which rotation handler is live (#1439) -- the only remote way to see
# that the multiprocess-safe handler degraded.
self.assertIn("log_rotation", data)
+ # Backup health (#1443) -- the dumps live in a Docker volume on a host
+ # with no shell, so these fields are the only way to check backups
+ # without logging into /admin.
+ self.assertIn("backup_ok", data)
+ self.assertIn("last_backup_at", data)
+ self.assertIn("backup_age_hours", data)
+ self.assertIn("backup_count", data)
+
+ def test_reports_backup_health_from_status_file(self):
+ """A healthy status file surfaces as ``backup_ok: true`` plus its age."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "status.json")
+ now = datetime.now(timezone.utc)
+ with open(path, "w", encoding="utf-8") as handle:
+ json.dump({
+ "last_attempt_ok": True,
+ "error": None,
+ "last_backup_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "last_backup_file": "makeability-2026-08-07.sql.gz",
+ "last_backup_bytes": 12684,
+ "backup_count": 14,
+ "retention_days": 14,
+ }, handle)
+ with override_settings(BACKUP_STATUS_FILE=path):
+ data = json.loads(self.client.get("/version/").content)
+
+ self.assertTrue(data["backup_ok"])
+ self.assertEqual(data["backup_count"], 14)
+ self.assertIsNotNone(data["last_backup_at"])
+ self.assertLess(data["backup_age_hours"], 1)
+
+ def test_reports_unhealthy_backup_without_leaking_internals(self):
+ """A stale backup reports ``backup_ok: false``.
+
+ The endpoint is public, so it deliberately carries no error text and no
+ filesystem paths -- just enough for an external check to go red.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "status.json")
+ stale = datetime.now(timezone.utc) - timedelta(hours=100)
+ with open(path, "w", encoding="utf-8") as handle:
+ json.dump({
+ "last_attempt_ok": False,
+ "error": "pg_dump failed (exit 1): connection refused",
+ "last_backup_at": stale.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "backup_count": 3,
+ }, handle)
+ with override_settings(BACKUP_STATUS_FILE=path):
+ data = json.loads(self.client.get("/version/").content)
+
+ self.assertFalse(data["backup_ok"])
+ self.assertNotIn("backup_problem", data)
+ self.assertNotIn("connection refused", json.dumps(data))
+ self.assertNotIn(path, json.dumps(data))
def test_server_reflects_wsgi_server_software(self):
# The view reports request.META["SERVER_SOFTWARE"] verbatim; on the real
diff --git a/website/utils/backup_status.py b/website/utils/backup_status.py
new file mode 100644
index 00000000..91d322ee
--- /dev/null
+++ b/website/utils/backup_status.py
@@ -0,0 +1,179 @@
+"""
+Read the database-backup status file written by the ``db-backup`` sidecar (#1443).
+
+The sidecar (``scripts/pg_backup.sh``, wired up in ``docker-compose.yml``) writes
+a small JSON file after every pass — successful or not — to a volume this
+container mounts read-only. This module turns that file into a dict the admin
+dashboard, the Data Health page, and ``/version.json`` all render.
+
+Why this exists at all: the dumps land in a Docker named volume on a host with
+no shell access, inside a ``PGDATA`` this container cannot even traverse. The
+status file is the only channel through which backup health is observable. That
+makes *this* module's failure modes important — it must degrade to "unknown"
+rather than raise, or a missing backup would take the admin down with it.
+
+Usage::
+
+ from website.utils.backup_status import get_backup_status
+ status = get_backup_status()
+ if not status['healthy']:
+ print(status['problem']) # e.g. "Last backup is 51 hours old"
+"""
+
+import json
+import logging
+import os
+from datetime import datetime, timezone
+
+from django.conf import settings
+
+logger = logging.getLogger(__name__)
+
+#: Shape returned when the status file can't be read or understood at all.
+#: Deliberately not an exception: a broken status file must not break /admin.
+_UNKNOWN = {
+ 'available': False,
+ 'healthy': False,
+ 'ok': None,
+ 'stale': False,
+ 'age_hours': None,
+ 'last_backup_at': None,
+ 'last_backup_file': None,
+ 'last_backup_bytes': None,
+ 'last_attempt_at': None,
+ 'oldest_backup_at': None,
+ 'backup_count': None,
+ 'retention_days': None,
+ 'database': None,
+ 'error': None,
+ # Kept in the unknown shape too so templates can render every key
+ # unconditionally instead of guarding each one.
+ 'problem': None,
+ 'should_warn': False,
+ 'size_display': '—',
+ 'status_file': None,
+}
+
+
+def _parse_iso(value):
+ """Parse an ISO-8601 UTC timestamp from the status file, or return None.
+
+ The sidecar writes ``...Z``; ``fromisoformat`` only learned to accept the
+ ``Z`` suffix in Python 3.11, so normalize it rather than relying on that.
+ """
+ if not value or not isinstance(value, str):
+ return None
+ try:
+ return datetime.fromisoformat(value.replace('Z', '+00:00'))
+ except (ValueError, TypeError):
+ return None
+
+
+def format_bytes(num):
+ """Render a byte count as a short human string ('12.4 MB'), or '—'."""
+ if num is None:
+ return '—'
+ try:
+ num = float(num)
+ except (TypeError, ValueError):
+ return '—'
+ for unit in ('B', 'KB', 'MB', 'GB'):
+ if abs(num) < 1024.0 or unit == 'GB':
+ return f"{num:.0f} {unit}" if unit == 'B' else f"{num:.1f} {unit}"
+ num /= 1024.0
+ return f"{num:.1f} GB"
+
+
+def get_backup_status(status_file=None, now=None):
+ """
+ Return a dict describing database-backup health. Never raises.
+
+ Args:
+ status_file: path to status.json. Defaults to ``settings.BACKUP_STATUS_FILE``.
+ now: aware ``datetime`` used as "now" (for tests). Defaults to real UTC now.
+
+ Returns:
+ dict with at least ``available`` (was the file readable), ``healthy``
+ (readable AND last attempt succeeded AND not stale), ``problem`` (a
+ one-line human explanation when not healthy, else None), plus the
+ parsed fields and a few display-formatted extras.
+
+ A note on the three unhealthy states, which mean different things:
+ * ``available=False`` — no status file. Either backups have never run on
+ this host, or the volume isn't mounted. Expected in local dev.
+ * ``ok=False`` — the sidecar ran and *failed*; ``error`` says why.
+ * ``stale=True`` — the last pass may have succeeded, but the newest dump
+ is older than ``BACKUP_STALE_AFTER_HOURS``, so backups have stopped.
+ """
+ if status_file is None:
+ status_file = getattr(settings, 'BACKUP_STATUS_FILE', None)
+ if now is None:
+ now = datetime.now(timezone.utc)
+
+ status = dict(_UNKNOWN)
+ status['status_file'] = status_file
+
+ if not status_file or not os.path.exists(status_file):
+ status['problem'] = 'No backup status file found.'
+ # On a server this is a real problem — the sidecar never ran, or the
+ # status volume isn't mounted. In local dev, where a developer may
+ # simply not be running the db-backup service, it is expected and must
+ # not nag. Keyed off DJANGO_ENV rather than DEBUG on purpose: the test
+ # server runs DEBUG=True and still needs to be warned.
+ status['should_warn'] = getattr(settings, 'DJANGO_ENV', None) in ('PROD', 'TEST')
+ return status
+
+ try:
+ with open(status_file, 'r', encoding='utf-8') as handle:
+ data = json.load(handle)
+ if not isinstance(data, dict):
+ raise ValueError('status file is not a JSON object')
+ except (OSError, ValueError) as exc:
+ # Truncated file, bad JSON, permissions — all reported the same way.
+ # A file that exists but can't be parsed is always worth warning about.
+ logger.warning("Could not read backup status file %s: %s", status_file, exc)
+ status['problem'] = f'Backup status file is unreadable ({exc}).'
+ status['should_warn'] = True
+ return status
+
+ status['available'] = True
+ status['ok'] = bool(data.get('last_attempt_ok'))
+ status['error'] = data.get('error') or None
+ status['database'] = data.get('database')
+ status['last_backup_file'] = data.get('last_backup_file')
+ status['last_backup_bytes'] = data.get('last_backup_bytes')
+ status['backup_count'] = data.get('backup_count')
+ status['retention_days'] = data.get('retention_days')
+ status['last_backup_at'] = _parse_iso(data.get('last_backup_at'))
+ status['last_attempt_at'] = _parse_iso(data.get('last_attempt_at'))
+ status['oldest_backup_at'] = _parse_iso(data.get('oldest_backup_at'))
+ status['size_display'] = format_bytes(status['last_backup_bytes'])
+
+ stale_after = getattr(settings, 'BACKUP_STALE_AFTER_HOURS', 36)
+ if status['last_backup_at'] is not None:
+ age = (now - status['last_backup_at']).total_seconds() / 3600.0
+ status['age_hours'] = round(age, 1)
+ status['stale'] = age > stale_after
+ else:
+ # A readable status file that names no dump means nothing has ever been
+ # backed up successfully here — treat that as stale, not as healthy.
+ status['stale'] = True
+
+ status['healthy'] = bool(status['ok']) and not status['stale']
+
+ # A status file exists, so whatever it says is worth showing everywhere,
+ # local dev included: a failing sidecar is a bug wherever it happens.
+ status['should_warn'] = not status['healthy']
+
+ if status['healthy']:
+ status['problem'] = None
+ elif not status['ok']:
+ status['problem'] = f"Last backup attempt failed: {status['error'] or 'unknown error'}"
+ elif status['age_hours'] is None:
+ status['problem'] = 'No successful backup has been recorded yet.'
+ else:
+ status['problem'] = (
+ f"Last backup is {status['age_hours']:.0f} hours old "
+ f"(expected at most {stale_after})."
+ )
+ return status
diff --git a/website/views/version.py b/website/views/version.py
index e790e7b0..562ee0f5 100644
--- a/website/views/version.py
+++ b/website/views/version.py
@@ -63,6 +63,8 @@
from django.conf import settings
from django.http import JsonResponse
+from website.utils.backup_status import get_backup_status
+
# Module logger (configured in settings.LOGGING).
_logger = logging.getLogger(__name__)
@@ -104,6 +106,7 @@ def version(request, format=None):
wrapper applied in ``website/urls.py`` doesn't choke on the suffixed route.
"""
build_info = _read_build_info()
+ backup = get_backup_status()
payload = {
"version": settings.ML_WEBSITE_VERSION,
"description": settings.ML_WEBSITE_VERSION_DESCRIPTION,
@@ -122,6 +125,18 @@ def version(request, format=None):
# "ConcurrentRotatingFileHandler" means the multiprocess-safe handler
# degraded and Gunicorn's workers can race on rollover again.
"log_rotation": settings.LOG_ROTATION,
+ # Database backup health (#1443). The nightly dump lands in a Docker
+ # volume on a host with no shell, so this is the only way to check it
+ # without logging into /admin. backup_ok false means either the last
+ # pass failed or the newest dump has gone stale -- backup_problem says
+ # which. No paths or error internals beyond that: this endpoint is
+ # public.
+ "backup_ok": backup["healthy"],
+ "last_backup_at": (
+ backup["last_backup_at"].isoformat() if backup["last_backup_at"] else None
+ ),
+ "backup_age_hours": backup["age_hours"],
+ "backup_count": backup["backup_count"],
}
response = JsonResponse(payload)
response["Cache-Control"] = "no-store"