Files
Jake 4576e6fae3 Docs: production deployment recipe + Arch gotchas
- README: expand the production section with a full systemd recipe
  (useradd, chown, uv sync --project, install -d for ReadWritePaths,
  .env perms 640), a Caddy block, and backup note.
- AGENTS.md: add the Arch python-uv package-name note and the root-rooted
  venv caveat, plus a new "Deploy / production" section documenting the
  volundr unit, data location, .env perms, and forward-proxy expectations.
2026-09-10 13:25:19 +01:00

126 lines
6.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md
Guidance for working in this repo.
## What this is
**volundr** (packaged as `bike_admin`) is a small single-user web app for logging bike service/repair
"events" (type, date, mileage, cost, notes) and seeing per-bike hints: a
**service-due** status derived from the newest mileage and the most recent
`service` event, plus **MOT / insurance renewal** statuses derived from the most
recent `mot` / `insurance` events.
Stack: FastAPI (ASGI) + stdlib `sqlite3` (WAL) + Jinja2 + HTMX. Server-rendered
pages with HTMX partial swaps on the bike page. No build step. Dependencies via
`uv` (see `pyproject.toml`).
## Commands
```bash
uv sync --all-extras # install all deps (fastapi, dev tools)
uv run uvicorn app.main:app --reload # run dev server (http://127.0.0.1:8000)
uv run pytest -q # run the test suite
uv run ruff check app tests # lint
uv run ruff format --check app tests # format check
uv run ruff format app tests # apply formatting
```
There is **no `main.py` at the repo root** — the app is the `app` package,
entry point `app.main:app`.
**Arch Linux note:** `uv` ships as `python-uv` (not `uv` or `python3-uv`) so
`sudo pacman -S --needed python-uv` is the install command. If you create the
venv with `sudo`, the interpreter will provision under `/root/.local/share/uv`
and a non-root service user can never execute it; build the venv as the
service user (or against a system Python — see README "systemd recipe").
## Configuration
All settings come from env vars (or a `.env` file) via `pydantic-settings`,
`env_prefix="BIKE_ADMIN_"` (see `app/config.py`).
- **Required:** `BIKE_ADMIN_USERNAME`, `BIKE_ADMIN_PASSWORD`. The app **raises at
startup** if `BIKE_ADMIN_PASSWORD` is not set (no insecure default).
- `BIKE_ADMIN_SECRET_KEY` — used to derive the per-session CSRF token.
- `BIKE_ADMIN_IS_PRODUCTION=true` — adds `Secure` to the session cookie (set when
behind a TLS reverse proxy over HTTPS).
- `BIKE_ADMIN_BIND_HOST` / `BIND_PORT` — bind address/port.
- `BIKE_ADMIN_DATA_DIR` — directory for the SQLite file (`bike_admin.db`).
- Hints: `BIKE_ADMIN_SERVICE_DUE_SOON_MILES` (service "book soon" lead time,
default 200), `BIKE_ADMIN_RENEWAL_DUE_SOON_DAYS` (MOT/insurance "due soon"
lead time, default 60), `BIKE_ADMIN_RENEWAL_MONTHS` (MOT/insurance validity,
default 12).
## Deploy / production
`deploy/bike_admin.service` is the systemd unit for the production server
(volundr). Key points:
- Runs as the unprivileged `bike` system user (no home dir needed beyond the
uv-provisioned interpreter in `/home/bike`.local/share/uv/...).
- App data lives next to the checkout: `/opt/bike_admin/data`. The app creates
`data/bike_admin.db` on startup, but systemd requires the `ReadWritePaths=`
target to exist before start — hence the one-time
`sudo install -d -o bike -g bike /opt/bike_admin/data` step (see README).
- `.env` at `/opt/bike_admin/.env`, `chown bike:bike` and `chmod 640`.
(systemd reads it at ExecStart.)
- Hardening kept: `NoNewPrivileges`, `PrivateTmp`, `ProtectSystem=full`,
`ProtectHome=true`. Do not remove `ProtectHome` unless the interpreter path
moves back to `/home`; `StateDirectory=` is the cleaner alternative for
data, but we chose `ReadWritePaths=` to keep data next to the app.
Caddy (hermodr, on a separate host) reverse-proxies
`volundr.local.jakes-asgard.uk` to the app's IP:8000. The app trusts
`X-Forwarded-For` (BIKE_ADMIN_TRUST_PROXY defaults true) and uvicorn's
`--forwarded-allow-ips` whitelist in the unit governs which proxies it'll
accept.
## Architecture / layout
- `app/config.py` – `Settings` (pydantic-settings) + `get_settings()` (cached).
- `app/db.py` – SQLite schema + typed DAO helpers + `get_conn()` context manager.
- `app/security.py`– Argon2 password hashing, DB session tokens, CSRF, login throttle.
- `app/services.py`– Business logic: `compute_service_due`, `compute_renewal_due`,
`summarize_bike`, `all_summaries`.
- `app/main.py` – App factory, lifespan (schema init + user seed), security-header
middleware, all routes, HTMX partial rendering.
- `app/templates/` – Jinja2 templates (login, dashboard, bike detail, event partial).
- `app/static/` – `main.css`, `favicon.svg`, and vendored `htmx.min.js`.
- `tests/` – `conftest.py` (Client helper + temp-data-dir env) and per-area
tests (`test_auth`, `test_due`, `test_renewals`, `test_events`, `test_security`).
## Conventions & gotchas
- **Starlette's `TemplateResponse(request, name, context)` signature is required.**
This `starlette` version (1.6.0) uses the new signature — calling
`TemplateResponse(name, context)` raises `TypeError: unhashable type: 'dict'`.
Always pass `request` as the first arg (see all call sites in `app/main.py`).
- **Templates must not contain single `-` inside `{{ }}` as a bare token**, and
especially avoid `else '- '` — Jinja will mis-lex it (unbalanced braces →
`TemplateSyntaxError: unexpected char '?' at ...`). Use
`{% if ... %}{{ x }}{% else %}-{% endif %}` instead.
- **CSRF:** every mutating form must carry the `csrf` hidden input (token =
HMAC of `SECRET_KEY` over the session cookie). `_csrf(request)` in `app/main.py`
returns it for the current session.
- **Throttling is per-IP and stored in the `login_attempts` table** for the last
`LOGIN_WINDOW_SECONDS`.
- **Single user** model; `bikes` and `events` are global (no per-user scoping).
### Testing
- Tests use `pythonpath = ["."]` (set in `pyproject.toml`) so `import app.*` works.
- `conftest.py` has an autouse `env` fixture that points `BIKE_ADMIN_DATA_DIR`,
`BIKE_ADMIN_SECRET_KEY`, `BIKE_ADMIN_USERNAME`, and `BIKE_ADMIN_PASSWORD` to a
fresh temp dir + known values per test, and clears the `get_settings()` cache.
- The `Client` helper wraps `TestClient` with `.login()`, `.csrf()`,
`.create_bike()`, `.create_event()` helpers.
- `TestClient` is instantiated with `follow_redirects=False` so 303s are observed
(otherwise successful logins redirect to `/` and you lose the `set-cookie`).
### Security headers
Applied via an HTTP middleware in `app/main.py`:
`X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`,
`Permissions-Policy`, and a strict CSP (`default-src 'self'`, `script-src 'self'`,
`frame-ancestors 'none'`).