intent/docs/operations.md

130 lines
6.4 KiB
Markdown
Raw Permalink Normal View History

# Operating Intent
How to deploy, configure and look after a production Intent. For what the app does, see
[concept.md](concept.md); for how it's built, [plan.md](plan.md).
## Shape
One Fly.io machine with a volume at `/data` holding the SQLite database. Litestream streams
the database to a Backblaze B2 bucket continuously. Uploaded files go to a private Tigris
bucket. `bin/start` runs as root only long enough to take ownership of the volume, then as
the `app` user it:
1. restores the database from B2 if the volume has none (a fresh volume),
2. runs migrations and creates the cache table,
3. starts gunicorn under `litestream replicate`.
## Secrets and settings
Set with `fly secrets set NAME=value`. Non-secret settings live in `fly.toml`.
| Name | Required | Purpose |
|---|---|---|
| `DJANGO_SECRET_KEY` | yes | Signs sessions and tokens. Long and random. |
| `DJANGO_ALLOWED_HOSTS` | yes (`fly.toml`) | Comma-separated hostnames. Also sets the trusted CSRF origins. |
| `EMAIL_BACKEND` | yes | Production refuses to start with the console backend. See Email. |
| `ANYMAIL_*` | with Anymail | The provider's settings, e.g. `ANYMAIL_POSTMARK_SERVER_TOKEN`. |
| `DEFAULT_FROM_EMAIL` | yes | e.g. `Intent <intent@example.org>`. Must be a sender the provider allows. |
| `LITESTREAM_BUCKET`, `LITESTREAM_ENDPOINT`, `LITESTREAM_ACCESS_KEY_ID`, `LITESTREAM_SECRET_ACCESS_KEY` | yes | B2 backup bucket and an application key limited to it. |
| `BUCKET_NAME`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_ENDPOINT_URL_S3`, `AWS_REGION` | yes | Tigris uploads. `fly storage create` sets these. |
| `CSP_BUCKET_ORIGIN` | no | Only if signed URLs aren't `https://<bucket>.fly.storage.tigris.dev`. |
| `SENTRY_DSN` | no | Error reporting. |
## First deploy
1. Replace the `CHANGEME` values in `fly.toml` (app name, region, host).
2. `fly launch --no-deploy`, then `fly volumes create intent_data --size 1`.
3. `fly storage create` for the Tigris bucket.
4. Set the secrets above.
5. `fly deploy`. Check `fly logs` for "running without backups" (Litestream not configured) or a restore message.
6. `fly ssh console`, then create the first administrator (next section).
## Administrators
Administrators are separate from regular users: they sign in only at `/admin/`, with a
password and a code from an authenticator app, and can't join organizations.
```sh
fly ssh console
cd /app
python manage.py createsuperuser
python manage.py add_admin_totp <username>
```
The second command prints an `otpauth://` link. Add it to an authenticator app (most can scan
it if you paste it into any QR generator you trust, or enter the secret by hand). If someone
loses their device, run it again with `--replace`.
Only superusers can make other accounts staff or give out permissions.
In local development the admin uses a password only. To try the real flow, run with
`ADMIN_REQUIRE_OTP=1`.
## Organizations and sign-in providers
- **Organizations** are created in the admin (Organizations → Add), naming the first owner. They invite everyone else.
- **GitHub and Discord sign-in:** in the admin, Social applications → Add, with the provider's client ID and secret.
- **A Forgejo instance:** register an OAuth2 application on the instance with the redirect URL `https://<host>/accounts/oidc/<provider_id>/login/callback/`, then add a Social application with provider "OpenID Connect", the same `provider_id`, the client ID and secret, and under Settings `{"server_url": "https://<forgejo-host>"}`. No deploy is needed.
## Email
Sign-in codes and email invitations need working email. Any provider
[Anymail](https://anymail.dev) supports works without code changes. For example, Postmark:
```sh
fly secrets set \
EMAIL_BACKEND=anymail.backends.postmark.EmailBackend \
ANYMAIL_POSTMARK_SERVER_TOKEN=... \
DEFAULT_FROM_EMAIL="Intent <intent@example.org>"
```
Then send a test and sign in with an emailed code:
```sh
fly ssh console -C "python /app/manage.py sendtestemail you@example.org"
```
## Moderation
- **Delete a comment:** the author, or an owner of the project, from the comment itself. It leaves a "Comment deleted" marker.
- **Close an issue:** any project collaborator or owner.
- **Block a person:** admin → Users → select → "Deactivate". They're signed out everywhere and can't sign in; what they wrote stays under their name. "Reactivate" undoes it.
- **Delete an account:** admin → the user → Delete. What they wrote stays, shown as written by "ghost". The admin won't delete an organization's last owner; make someone else an owner first.
## Limits
Per person: 30 comments or new threads per 10 minutes, 20 new issues per hour, 30 file uploads
per hour, 30 invitations per hour (and 3 per address per organization). allauth limits
sign-in attempts and code requests itself. Limits live in the database cache and reset when
their window ends. Change them in `intent/core/throttle.py`.
## Backups and the restore drill
Litestream keeps B2 within about a second of the database. Fly volume snapshots (daily by
default; check with `fly volumes snapshots list <volume-id>`) are a second, independent copy.
Run the restore drill before launch and after any change to `bin/start` or `litestream.yml`:
1. Note a recent change in the app, e.g. a comment you just posted.
2. Create a new volume and a new machine with it, from the same image:
`fly volumes create intent_data --size 1 --region <region>` then
`fly machine clone <machine-id> --attach-volume <new-volume-id>:/data`.
3. In `fly logs` for the new machine, confirm a Litestream restore happens before "Applying migrations" (or "No migrations to apply").
4. `fly ssh console --machine <new-machine-id>` and check the change from step 1 is there:
`python manage.py shell -c "from intent.comments.models import Comment; print(Comment.objects.latest('created_at').body)"`.
5. Destroy the clone and its volume. Two machines must never write to the same B2 path for long.
To recover for real, stop the machine, destroy its volume, create a new one, and start the
machine: `bin/start` restores from B2 on an empty volume.
## Tests
```sh
uv run playwright install chromium # once per machine
uv run pytest # everything, including real-browser tests
uv run pytest -m "not browser" # fast run without the browser
```
The browser tests run axe (WCAG 2.1 A and AA) on every page in light and dark mode, check
there are no CSP violations or script errors, and walk the main flow by keyboard.