diff --git a/.dockerignore b/.dockerignore index be06fc56..ae8d5863 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,7 @@ +# VCS — nothing in the build reads git metadata (every package version is +# static), and .git is the single biggest thing in the context. +.git/ + # Python / uv __pycache__/ *.py[cod] @@ -18,6 +22,10 @@ host/client_app/dist/ packages/*/dist/ .npm/ +# Built frontend — the image runs its own `npm run build`, and a stale local +# bundle would shadow it (same host/static/dist path). +host/static/dist/ + # Editors / OS .vscode/ .idea/ @@ -28,7 +36,22 @@ packages/*/dist/ *.db *.sqlite .memray/ +uploads/ +var/ + +# Agent + tooling scratch. `.claude/worktrees/` holds full checkouts of this +# repo, so leaving it in would multiply the build context by every worktree. +.claude/ +.worktrees/ +.emdash/ +.qa/ +.verify/ +.playwright-mcp/ +.playwright-cli/ +.benchmarks/ +qa-shots/ -# Tests / docs not needed at runtime +# Tests / docs / sample data not needed at runtime tests/e2e/ docs/ +dataset/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..3253b14e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,104 @@ +# syntax=docker/dockerfile:1.7 +# Default image for the SimpleModule reference app — the host plus the bundled +# modules that a single web process needs (auth/users, dashboard, permissions, +# settings, file storage, feature flags, audit log, branding, site lock). +# +# Background tasks are deliberately not part of it: Celery is a second process +# plus a broker, which is the opposite of a standalone image. The worker/beat +# services in docker-compose.yml build docker/worker.Dockerfile for that. +# +# docker build -t simple-module-python . +# docker run --rm -p 8000:8000 simple-module-python +# +# Standalone by design: SQLite under /app/data, no Postgres and no Redis +# needed to boot. `docker-compose.yml`'s `app` service is the same image with +# a named volume; worker/beat (Celery) stay opt-in. +# +# One builder stage carries both uv and Node because the Vite build imports +# `modules.generated.{ts,css}`, which `smpy host gen-pages` emits from the +# *installed Python modules* — a Node-only stage would have nothing to read. + +FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS builder + +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Node 24 — same major as NODE_VERSION in .github/workflows/pr.yml, so the +# image builds the bundle CI validates. +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Dependency layer: every workspace member's manifest, resolved before the +# full source arrives. `uv.lock` is gitignored in this repo, so it's an +# optional glob and the sync deliberately isn't `--frozen`. +COPY pyproject.toml uv.lock* ./ +COPY framework/ framework/ +COPY modules/ modules/ +COPY host/pyproject.toml host/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --all-packages --no-dev --no-install-workspace + +# npm workspaces span host/client_app, packages/* and modules/* — every +# member's package.json must exist before `npm ci` will honour the lockfile. +COPY package.json package-lock.json ./ +COPY packages/ packages/ +COPY host/client_app/package.json host/client_app/ +RUN --mount=type=cache,target=/root/.npm npm ci + +# --no-install-package drops the Celery module from this image: with no entry +# point installed, discovery never sees it, so nothing here needs a broker and +# the bundle carries none of its pages. Drop the flag (and point +# SM_BG_TASKS_BROKER_URL at a real Redis) to run tasks from the web process. +COPY . . +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --all-packages --no-dev --no-install-package simple-module-background-tasks + +# Page manifest + generated module imports first, then the production bundle +# into host/static/dist (with its .vite/manifest.json and precompressed +# .gz/.br siblings, which the host serves from the /static mount). +# The venv binary directly rather than `uv run`, which re-resolves and re-syncs +# the environment on every invocation — the layer above already installed +# exactly what this image should contain. +RUN /app/.venv/bin/smpy host gen-pages --host-dir=host/client_app +RUN npm run build + +# node_modules is a build-time artifact only; the runtime serves static files. +RUN rm -rf node_modules host/client_app/node_modules + +FROM python:3.12-slim-bookworm AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/app/.venv/bin:$PATH" + +# Containers serve the built bundle; development mode would emit asset tags +# pointing at a Vite dev server that isn't in this image. +ENV SM_ENVIRONMENT=production + +# Absolute sqlite path: /app/data is the volume mount point, so the DB is +# cwd-independent and survives restarts whenever a volume is attached. +ENV SM_DATABASE_URL=sqlite+aiosqlite:////app/data/app.db + +# curl backs the HEALTHCHECK below. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app /app + +RUN mkdir -p /app/data \ + && useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin app \ + && chown -R app:app /app +USER app + +WORKDIR /app +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost:8000/health || exit 1 + +ENTRYPOINT ["/app/docker/entrypoint.sh"] +CMD ["uvicorn", "host.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 4293c4d2..40d0bd93 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker +.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker # Install install: @@ -184,6 +184,23 @@ kill: @-lsof -ti:8000,5050,5173 | xargs kill -9 2>/dev/null @echo "Ports 8000, 5050, 5173 freed." +# Docker — the default app image (./Dockerfile). Standalone: SQLite inside the +# container, no Postgres or Redis needed. Both run targets honour SM_APP_PORT +# so they don't collide with a `make dev` already holding 8000. +SM_APP_PORT ?= 8000 +export SM_APP_PORT + +docker-build: ## Build the default app image + docker build -t simple-module-python . + +docker-app: docker-build ## Run the built image standalone on http://localhost:$(SM_APP_PORT) + docker run --rm -p $(SM_APP_PORT):8000 \ + -v simple-module-python-data:/app/data \ + simple-module-python + +docker-compose-app: ## Same image via compose (named volume, .env-overridable) + docker compose up --build app + # Docker — Postgres/Redis now live in the shared ../dev-services stack. # docker-up brings that shared stack up (idempotent, shared with other repos). docker-up: diff --git a/README.md b/README.md index 649c880a..de5a904d 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,72 @@ make dev Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard/` is the authenticated home, and `/admin/doctor/` is the admin-only "smpy doctor" panel (static checks, migrations, dev server, modules). +## Run it in Docker + +The repo ships a default image (`./Dockerfile`) that builds the host plus every +bundled module — Python workspace, `gen-pages`, and the production Vite bundle — +and serves it with uvicorn. It is standalone: SQLite lives inside the container +under `/app/data`, so no Postgres and no Redis are needed to boot. + +```bash +make docker-app # build ./Dockerfile, then run it on http://localhost:8000 +``` + +Both run targets take `SM_APP_PORT=8010` when a `make dev` already holds +8000. + +or without make: + +```bash +docker build -t simple-module-python . +docker run --rm -p 8000:8000 -v simple-module-python-data:/app/data simple-module-python +``` + +**Logging in.** With no `SM_USERS_BOOTSTRAP_*` set, the container seeds +**`admin@example.com` / `changeme`** — the same pair `.env.example` uses for +local dev — and says so on every boot that uses it: + +``` +entrypoint: WARNING - no SM_USERS_BOOTSTRAP_PASSWORD set, so the +entrypoint: first-boot admin is the public default: +entrypoint: admin@example.com / changeme +``` + +Pass `-e SM_USERS_BOOTSTRAP_EMAIL=… -e SM_USERS_BOOTSTRAP_PASSWORD=…` to seed +your own instead, and change it before the container is reachable by anyone but +you. The seed only applies while the users table is empty, so on a reused volume +the existing password still stands — reset it with +`smpy users create-admin --email … --password … --force`. + +`make docker-compose-app` (or `docker compose up --build app`) runs the same +image through compose with a named volume — independent of the shared +dev-services stack that `worker`/`beat` use. + +What the entrypoint does before uvicorn binds: applies `alembic upgrade heads` +(a fresh volume has no tables, and production fails boot on `SM010` when the DB +is behind head), and generates an ephemeral `SM_SECRET_KEY` when none is set — +enough to boot and log in, but sessions die on restart, so set one for anything +real. + +The image runs with `SM_ENVIRONMENT=production` because it serves the built +bundle rather than a Vite dev server. Useful overrides: + +| Variable | Image default | Why you'd change it | +|---|---|---| +| `SM_SECRET_KEY` | generated per start | Persist sessions across restarts | +| `SM_DATABASE_URL` | `sqlite+aiosqlite:////app/data/app.db` | Point at Postgres | +| `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` | `admin@example.com` / `changeme` | Seed a first admin nobody else can guess | +| `SM_TRUSTED_PROXY` | unset | Set to `*` behind a TLS-terminating reverse proxy | + +**No background tasks.** The image skips installing the Celery module +(`uv sync … --no-install-package simple-module-background-tasks`), so nothing in +it wants a broker — a queue means a second process and a Redis, which is the +opposite of a standalone image. `docker-compose.yml`'s `worker` / `beat` +services cover that path: they build `docker/worker.Dockerfile` against the +shared `../dev-services` Postgres + Redis on the external `devnet` network. To +run tasks from the web process instead, drop the `--no-install-package` flag +from the Dockerfile and set `SM_BG_TASKS_BROKER_URL` / `_RESULT_BACKEND`. + ## Create a new module ```bash @@ -105,6 +171,7 @@ docs/ | `make migration msg="..."` | Autogenerate a new migration | | `make new-module name=` | Scaffold a new module | | `make kill` | Stop any running dev servers (ports 8000, 5050, 5173) | +| `make docker-build` / `docker-app` | Build the default app image / run it standalone on port 8000 | | `make docker-up` / `docker-down` | `docker-up` brings up the shared dev-services stack (Postgres/Redis/MinIO); `docker-down` stops only this repo's worker/beat (SQLite needs no Docker) | ## Configuration diff --git a/docker-compose.yml b/docker-compose.yml index 5a98a961..8b0af536 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,28 @@ services: + # The default app, built from ./Dockerfile. Self-contained: SQLite on a + # named volume, no Postgres and no Redis required, so + # `docker compose up --build app` works with nothing else running. The image + # ships no Celery module — worker/beat below are the background-jobs half and + # still expect the shared ../dev-services stack. + app: + build: + context: . + dockerfile: Dockerfile + environment: + # Left unset the entrypoint generates an ephemeral key (fine for a demo, + # logs a warning, invalidates sessions on restart). + - SM_SECRET_KEY + # First-boot admin seed, applied only while the users table is empty. + # Unset, the entrypoint seeds admin@example.com with a generated + # password and prints it — see `docker compose logs app`. + - SM_USERS_BOOTSTRAP_EMAIL + - SM_USERS_BOOTSTRAP_PASSWORD + ports: + # SM_APP_PORT frees you from a host-port clash with `make dev`. + - "${SM_APP_PORT:-8000}:8000" + volumes: + - appdata:/app/data + # Postgres + Redis now come from the shared ../dev-services stack # (one PostGIS + one Redis on the external `devnet` network). Start it first # with `make up` in ~/Repos/dev-services. This project uses: @@ -9,7 +33,9 @@ services: build: context: . dockerfile: docker/worker.Dockerfile - env_file: .env + env_file: + - path: .env + required: false environment: SM_BG_TASKS_BROKER_URL: redis://redis:6379/4 SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/5 @@ -30,7 +56,9 @@ services: build: context: . dockerfile: docker/worker.Dockerfile - env_file: .env + env_file: + - path: .env + required: false environment: SM_BG_TASKS_BROKER_URL: redis://redis:6379/4 SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/5 @@ -52,3 +80,6 @@ services: networks: devnet: external: true + +volumes: + appdata: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..4a3c55ee --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# Container entrypoint for the default app image (see ../Dockerfile). +# +# Three things have to happen before uvicorn binds, all of which the image can +# do for itself so `docker run -p 8000:8000 ` is enough: +# +# 1. Fill in the secrets production refuses to run without. The image ships +# no baked-in keys (an image everyone can pull is the worst possible +# place for one), so any that are still unset get an ephemeral random +# value — enough to boot and log in, gone on the next start. +# 2. Seed an admin, so a bare `docker run` lands on a login you can pass +# (admin@example.com / changeme unless SM_USERS_BOOTSTRAP_* say otherwise). +# 3. Apply migrations. A fresh SQLite volume has no tables at all, and the +# boot-time SM010 check fails the app in production when the DB revision +# is behind head. +set -e + +# SM_SECRET_KEY signs session cookies; the two SM_USERS_* secrets sign +# password-reset and email-verification tokens. All three reject their +# placeholder default when SM_ENVIRONMENT is a production value. +_ephemeral="" +for _var in SM_SECRET_KEY SM_USERS_RESET_PASSWORD_TOKEN_SECRET SM_USERS_VERIFICATION_TOKEN_SECRET; do + eval "_current=\${$_var:-}" + if [ -z "$_current" ]; then + eval "export $_var=\"\$(python -c 'import secrets; print(secrets.token_urlsafe(48))')\"" + _ephemeral="$_ephemeral $_var" + fi +done + +if [ -n "$_ephemeral" ]; then + echo "entrypoint: generated ephemeral secrets for:$_ephemeral" >&2 + echo "entrypoint: sessions and any reset/verification links they sign die on restart — set these to persist them." >&2 +fi + +# The seed the users module applies only while its table is empty, so this +# can't overwrite an account on a persistent volume — and without it a fresh +# container serves a login page nobody holds credentials for. The default is +# deliberately a well-known one: this image is a starting point you are meant +# to log straight into. Anything running where that matters should pass both +# vars, which is why the banner below is loud rather than silent. +: "${SM_USERS_BOOTSTRAP_EMAIL:=admin@example.com}" +: "${SM_USERS_BOOTSTRAP_PASSWORD:=changeme}" +export SM_USERS_BOOTSTRAP_EMAIL SM_USERS_BOOTSTRAP_PASSWORD + +if [ "$SM_USERS_BOOTSTRAP_PASSWORD" = "changeme" ]; then + echo "entrypoint: WARNING - no SM_USERS_BOOTSTRAP_PASSWORD set, so the" >&2 + echo "entrypoint: first-boot admin is the public default:" >&2 + echo "entrypoint: $SM_USERS_BOOTSTRAP_EMAIL / changeme" >&2 + echo "entrypoint: Seeded only while no user exists — an existing install is" >&2 + echo "entrypoint: left alone. Set SM_USERS_BOOTSTRAP_EMAIL/_PASSWORD before" >&2 + echo "entrypoint: first boot, or change it later with:" >&2 + echo "entrypoint: smpy users create-admin --email ... --password ... --force" >&2 +fi + +# `upgrade heads` (plural) applies every per-module migration branch; +# `upgrade head` (singular) errors once a second module ships a branch label. +echo "entrypoint: applying migrations..." >&2 +alembic -c host/alembic.ini upgrade heads + +exec "$@" diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 1f950329..10671967 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -19,6 +19,39 @@ Before serving traffic: ## Build +### This repo's own app + +The framework repo ships a default image at the root — `./Dockerfile` — that +builds the reference app (host + every bundled module) and serves it with +uvicorn. It is standalone: SQLite under `/app/data`, no Postgres and no Redis +needed to boot. + +```bash +make docker-app # build ./Dockerfile and run it on :8000 +make docker-compose-app # same image via the `app` service in docker-compose.yml +``` + +`docker/entrypoint.sh` runs `alembic upgrade heads` and fills in any unset +production secret (`SM_SECRET_KEY`, `SM_USERS_RESET_PASSWORD_TOKEN_SECRET`, +`SM_USERS_VERIFICATION_TOKEN_SECRET`) with an ephemeral random value so a bare +`docker run` boots — set them yourself for anything that must survive a +restart. It also seeds `admin@example.com` / `changeme` (warning printed on +every boot that uses it) unless `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` say +otherwise; that seed only lands while the users table is empty, so it never +overwrites an existing account. A deployment that anyone else can reach should +set both vars before first boot — the default password is public. The checklist above still applies: that image is a demo/local +target, not a production deployment (SQLite, ephemeral secrets). + +That image carries no Celery: the build passes +`--no-install-package simple-module-background-tasks`, so the module has no +entry point to discover and the web process needs no broker at all. Background +jobs are the `worker` / `beat` services in `docker-compose.yml` +(`docker/worker.Dockerfile`), which is where a second process and a Redis +belong. Drop that flag and set `SM_BG_TASKS_BROKER_URL` / `_RESULT_BACKEND` to +put tasks back in the web image. + +### Scaffolded apps + **Every `smpy new` scaffold ships its own Docker assets** — you don't write these by hand: | Path | What it is | diff --git a/modules/background_tasks/background_tasks/settings.py b/modules/background_tasks/background_tasks/settings.py index 63d84a6d..ef3d56f0 100644 --- a/modules/background_tasks/background_tasks/settings.py +++ b/modules/background_tasks/background_tasks/settings.py @@ -1,11 +1,19 @@ """BackgroundTasks module settings (DB-backed). -Construction no longer reads ``SM_BG_TASKS_*`` environment variables. Values -come from pydantic defaults at boot, then get hydrated from the DB by the +Values come from defaults at boot, then get hydrated from the DB by the hosting lifespan before module ``on_startup`` runs. Runtime changes go through ``settings.reload.apply_changes_and_reload``. -The one remaining env read is ``SM_ENVIRONMENT``, consulted by the +Two of those defaults are deployment plumbing rather than module config, so +they stay env-readable: ``SM_BG_TASKS_BROKER_URL`` and +``SM_BG_TASKS_RESULT_BACKEND`` name the Redis a container can actually reach. +They have to work *before* any DB row exists — the production validator below +rejects the localhost defaults, so without them a containerised app can't +boot far enough to hydrate settings, and a worker process (which never sees +``app.state``) has no other source at all. A DB value still wins once +hydration runs. + +The other env read is ``SM_ENVIRONMENT``, consulted by the ``@model_validator`` to refuse a localhost broker in production — that's a host-level setting, not a background_tasks-module field. @@ -34,6 +42,7 @@ DEFAULT_RETENTION_DAYS, DEFAULT_STUCK_AFTER_SECONDS, DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS, + ENV_PREFIX, ) _CELERY_RESTART = {"requires_restart": True, "group": "Celery"} @@ -44,8 +53,16 @@ class BackgroundTasksSettings(BaseSettings): model_config = SettingsConfigDict(extra="ignore") - broker_url: str = Field(default=DEFAULT_BROKER_URL, json_schema_extra=_CELERY_RESTART) - result_backend: str = Field(default=DEFAULT_RESULT_BACKEND, json_schema_extra=_CELERY_RESTART) + broker_url: str = Field( + default_factory=lambda: os.environ.get(f"{ENV_PREFIX}BROKER_URL", DEFAULT_BROKER_URL), + json_schema_extra=_CELERY_RESTART, + ) + result_backend: str = Field( + default_factory=lambda: os.environ.get( + f"{ENV_PREFIX}RESULT_BACKEND", DEFAULT_RESULT_BACKEND + ), + json_schema_extra=_CELERY_RESTART, + ) task_default_queue: str = Field(default=DEFAULT_QUEUE, json_schema_extra=_CELERY_RESTART) # Run tasks synchronously inside the calling process. Read at diff --git a/modules/background_tasks/tests/test_bg_settings_env.py b/modules/background_tasks/tests/test_bg_settings_env.py new file mode 100644 index 00000000..1337f063 --- /dev/null +++ b/modules/background_tasks/tests/test_bg_settings_env.py @@ -0,0 +1,58 @@ +"""Broker/result-backend env plumbing for BackgroundTasksSettings. + +These two fields are read from the environment at construction because they +have to be right *before* the DB-backed settings exist: a container boots in +production, where the localhost defaults are rejected, and a Celery worker +process never sees ``app.state`` at all. Everything else is DB-backed. +""" + +from __future__ import annotations + +import pytest +from background_tasks.constants import DEFAULT_BROKER_URL, DEFAULT_RESULT_BACKEND +from background_tasks.settings import BackgroundTasksSettings + + +def test_defaults_when_env_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SM_BG_TASKS_BROKER_URL", raising=False) + monkeypatch.delenv("SM_BG_TASKS_RESULT_BACKEND", raising=False) + + settings = BackgroundTasksSettings() + + assert settings.broker_url == DEFAULT_BROKER_URL + assert settings.result_backend == DEFAULT_RESULT_BACKEND + + +def test_env_overrides_broker_urls(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SM_BG_TASKS_BROKER_URL", "redis://redis:6379/4") + monkeypatch.setenv("SM_BG_TASKS_RESULT_BACKEND", "redis://redis:6379/5") + + settings = BackgroundTasksSettings() + + assert settings.broker_url == "redis://redis:6379/4" + assert settings.result_backend == "redis://redis:6379/5" + + +def test_env_urls_satisfy_the_production_validator(monkeypatch: pytest.MonkeyPatch) -> None: + """The container path: production + a reachable broker must construct. + + Without env-backed defaults this raised, so no production container could + boot with the module installed — the localhost defaults were the only + values the validator ever saw. + """ + monkeypatch.setenv("SM_ENVIRONMENT", "production") + monkeypatch.setenv("SM_BG_TASKS_BROKER_URL", "redis://redis:6379/0") + monkeypatch.setenv("SM_BG_TASKS_RESULT_BACKEND", "redis://redis:6379/1") + + settings = BackgroundTasksSettings() + + assert settings.broker_url == "redis://redis:6379/0" + + +def test_localhost_still_rejected_in_production(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SM_ENVIRONMENT", "production") + monkeypatch.delenv("SM_BG_TASKS_BROKER_URL", raising=False) + monkeypatch.delenv("SM_BG_TASKS_RESULT_BACKEND", raising=False) + + with pytest.raises(ValueError, match="must not point at localhost"): + BackgroundTasksSettings()