Add maintenance mode: lock the platform down from the control panel

Closes every club subdomain with a 503 in that club's own colours, stands the
scheduled jobs down, and keeps open exactly what is needed to end it again.

The exemptions ARE the feature:

- /accounts/ stays open on the base domain. Close it too and you cannot sign in to
  turn maintenance off -- a lock-down with no key, fixable only from a shell.
- /healthz answers on every host. Close it and the load balancer decides the node
  is dead, stops routing to it, and takes the control panel down with everything
  else.
- migrate and collectstatic are NOT blocked. Maintenance is usually declared in
  order to run them; a blanket guard on BaseCommand would mean turning the mode off
  to do the work you turned it on for. Only the domain jobs (archive_overdue_clubs,
  extend_event_series, import_members_csv) refuse, and they exit non-zero so cron
  mails you -- a scheduled job that silently skips itself is how a month of billing
  goes missing.

The state is cached with a 10-second TTL, not for ever. Write-through makes the
flip instant for the shared Redis of a real deployment, and the TTL is the belt to
that braces: on a per-process cache -- a dev box with no Redis, or a misconfigured
deploy -- a lock-down that reached only one gunicorn worker would be worse than
useless. Live-verified: a club subdomain, its login page and the base domain all
503 while the control panel and the sign-in screens stay up.

Also adds the two deployment pieces asked for: compose.behind-proxy.yaml for a
dev/test box that already runs Caddy on :80 (app on the loopback, host Caddy proxies
to it -- and the host's Caddy still needs the DNS plugin, because the wildcard is
still a wildcard), and deploy/backup.sh + restore-check.sh with a cron schedule. The
backup writes to a .part file and only lands it once gzip -t says it is readable: a
truncated dump that looks like a backup is the failure you find on the day you need
it. The weekly restore rehearsal is the only line in that cron that proves the rest
work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:11:15 +02:00
parent c0a44093d9
commit d30b163122
22 changed files with 796 additions and 12 deletions

74
features/middleware.py Normal file
View File

@@ -0,0 +1,74 @@
"""Platform lock-down.
While maintenance is on, every club subdomain is closed and the base domain keeps only what
is needed to *end* the maintenance: the control panel, the auth screens that get you into it,
the static files those pages need, and the health check.
The exemptions are the whole design. Close /accounts/ as well and you cannot sign in to turn
maintenance off — a lock-down with no key, fixable only from a shell. Close /healthz and the
load balancer concludes the node is dead and stops routing to it, which takes the control
panel down with everything else.
"""
from django.http import JsonResponse
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from features.models import Maintenance
#: Reachable on the base domain while the platform is locked down.
OPEN_PREFIXES = (
"/controlpanel/", # the point of the exercise
"/accounts/", # ...which you cannot reach without signing in
"/admin/",
"/static/",
"/media/",
"/__reload__/", # dev only; absent outside DEBUG
)
#: Reachable on every host, always. The health check must answer or the load balancer will
#: take the node out of rotation and the control panel with it.
ALWAYS_OPEN = ("/healthz",)
RETRY_AFTER_SECONDS = 3600
class MaintenanceMiddleware:
"""Runs after ClubTenantMiddleware: whether a request is a club's or the platform's is
decided by ``request.club``, which the tenant middleware has just resolved."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not self.is_closed(request):
return self.get_response(request)
maintenance = Maintenance.current()
response = self.render(request, maintenance)
response["Retry-After"] = RETRY_AFTER_SECONDS
return response
def is_closed(self, request) -> bool:
if request.path.startswith(ALWAYS_OPEN):
return False
if not Maintenance.is_on():
return False
# A club subdomain is closed outright — no login, no shop, nothing.
if getattr(request, "club", None) is not None:
return True
# The base domain keeps the way back in.
return not request.path.startswith(OPEN_PREFIXES)
def render(self, request, maintenance):
message = maintenance.message or _("RosterChief is down for maintenance. It will be back shortly.")
# An API-ish caller gets JSON rather than a page of HTML it cannot read.
if request.headers.get("accept", "").startswith("application/json"):
return JsonResponse({"status": "maintenance", "detail": str(message)}, status=503)
return render(request, "maintenance.html", {"message": message, "maintenance": maintenance}, status=503)