|
| 1 | +# Configuration |
| 2 | + |
| 3 | +This document describes how REF's bootstrap configuration is generated, stored, |
| 4 | +and consumed. It covers the first-run flow, the three generated files, how to |
| 5 | +change settings after the initial install, and the subtle interactions between |
| 6 | +`ctrl.sh` and `docker compose`. |
| 7 | + |
| 8 | +For in-app runtime settings that administrators edit through the web UI (group |
| 9 | +configuration, SSH settings, maintenance banner, etc.), see |
| 10 | +`webapp/ref/model/system_settings.py` and `SystemSettingsManager`. Those are a |
| 11 | +separate layer and live in the database, not on disk. |
| 12 | + |
| 13 | +## Overview |
| 14 | + |
| 15 | +REF's bootstrap configuration has one canonical source and two derived |
| 16 | +artifacts: |
| 17 | + |
| 18 | +``` |
| 19 | +settings.yaml (canonical, hand-editable, contains secrets) |
| 20 | + | |
| 21 | + v prepare.py renders |
| 22 | + | |
| 23 | + +---> settings.env (consumed by docker compose --env-file) |
| 24 | + | |
| 25 | + +---> docker-compose.yml (rendered from docker-compose.template.yml |
| 26 | + via jinja; references ${VAR} placeholders |
| 27 | + that resolve against the shell env or |
| 28 | + settings.env at runtime) |
| 29 | +``` |
| 30 | + |
| 31 | +All three files plus `container-keys/root_key` and `container-keys/user_key` |
| 32 | +are produced on a fresh checkout by `./prepare.py`. All three are gitignored |
| 33 | +(`settings.yaml`, `settings.env`, `docker-compose.yml`), and `settings.yaml` / |
| 34 | +`settings.env` are written with mode `0600` because they contain plaintext |
| 35 | +secrets. |
| 36 | + |
| 37 | +## Running `prepare.py` |
| 38 | + |
| 39 | +`prepare.py` has two modes: |
| 40 | + |
| 41 | +- **Bootstrap** (no `settings.yaml`): generates cryptographically secure |
| 42 | + secrets (`admin.password`, `secrets.secret_key`, `secrets.ssh_to_web_key`, |
| 43 | + `secrets.postgres_password` — 32 bytes each via `secrets.token_urlsafe`), |
| 44 | + auto-detects the host's docker group ID (`getent group docker`, fallback |
| 45 | + `999`), writes `settings.yaml` (mode `0600`), and then renders the |
| 46 | + downstream files. Prints the generated admin password to stdout. |
| 47 | +- **Re-render** (`settings.yaml` already exists): loads the existing yaml and |
| 48 | + re-renders `settings.env`, `docker-compose.yml`, and the SSH host keys |
| 49 | + from it. Secrets are not touched. This is the mode you want for routine |
| 50 | + config edits (see "Changing configuration" below). |
| 51 | + |
| 52 | +Pass `--fresh` to force bootstrap mode even when `settings.yaml` exists. The |
| 53 | +existing file is moved to `settings.yaml.backup` first so the previous |
| 54 | +secrets can be recovered if needed. |
| 55 | + |
| 56 | +Downstream rendering steps (run in both modes): |
| 57 | + |
| 58 | +1. `render_settings_env()` writes `settings.env` from the yaml. |
| 59 | +2. `generate_docker_compose()` renders `docker-compose.yml` from |
| 60 | + `docker-compose.template.yml` via jinja, threading `paths.*` and |
| 61 | + `runtime.*` values from the yaml through as template variables. Production |
| 62 | + cgroup slice names (`ref-core.slice`, `ref-instances.slice`) and |
| 63 | + `testing=False` / `bridge_id=""` are the only values still hard-coded in |
| 64 | + `prepare.py`. |
| 65 | +3. `generate_ssh_keys()` creates ed25519 SSH host keys in `container-keys/` |
| 66 | + if missing (existing keys are left alone) and mirrors them into |
| 67 | + `ref-docker-base/container-keys/` for the base image build. |
| 68 | + |
| 69 | +`ctrl.sh` handles the first-run case automatically: if neither |
| 70 | +`settings.yaml` nor `settings.env` exists, it invokes `./prepare.py` before |
| 71 | +running any docker-compose command. If exactly one of them exists, or if |
| 72 | +`docker-compose.yml` / `container-keys/*` are missing, it errors out and |
| 73 | +asks the operator to re-run `prepare.py` or `prepare.py --fresh`. |
| 74 | + |
| 75 | +## The three files |
| 76 | + |
| 77 | +### `settings.yaml` — canonical configuration |
| 78 | + |
| 79 | +The only file you should edit by hand. Structure: |
| 80 | + |
| 81 | +```yaml |
| 82 | +docker_group_id: 999 |
| 83 | +ports: |
| 84 | + ssh_host_port: 2222 |
| 85 | + http_host_port: 8000 |
| 86 | +paths: |
| 87 | + data: ./data # bind-mounted into web as /data |
| 88 | + exercises: ./exercises # bind-mounted into web as /exercises |
| 89 | + ref_utils: ./ref-docker-base/ref-utils # bind-mounted read-only as /ref-utils |
| 90 | +runtime: |
| 91 | + binfmt_support: false # if true, renders the foreign-arch-runner service |
| 92 | +admin: |
| 93 | + password: <random 32-byte url-safe secret> |
| 94 | + ssh_key: null # if null, web app generates one on first boot |
| 95 | +secrets: |
| 96 | + secret_key: <random> # Flask session / CSRF signing key |
| 97 | + ssh_to_web_key: <random> # HMAC shared between SSH proxy and web API |
| 98 | + postgres_password: <random> # Postgres superuser password |
| 99 | +``` |
| 100 | +
|
| 101 | +Field semantics: |
| 102 | +
|
| 103 | +- `docker_group_id` — must match the host's `docker` group (`getent group |
| 104 | + docker`); `ctrl.sh` fails fast if they diverge. |
| 105 | +- `ports.ssh_host_port` / `ports.http_host_port` — host ports published by |
| 106 | + the `ssh-reverse-proxy` and `web` services respectively. |
| 107 | +- `paths.*` — on-host paths that get bind-mounted into the web container. |
| 108 | + Changing these requires re-running `./prepare.py` and then |
| 109 | + `./ctrl.sh restart` (the paths are compiled into `docker-compose.yml` at |
| 110 | + render time). |
| 111 | +- `runtime.binfmt_support` — if `true`, `prepare.py` renders a |
| 112 | + `foreign-arch-runner` service into `docker-compose.yml` that installs |
| 113 | + `qemu-user-static` for running foreign-architecture binaries. Leave |
| 114 | + `false` unless you actually need it. |
| 115 | +- `admin.password` — first-login password for admin user `0`. |
| 116 | +- `admin.ssh_key` — optional. If `null`, the web app generates a keypair on |
| 117 | + first boot and exposes the private key through the admin web interface. |
| 118 | +- `secrets.*` — three independent random secrets. They can be rotated |
| 119 | + individually (see "Rotating secrets" below). |
| 120 | + |
| 121 | +### `settings.env` — derived, consumed by docker compose |
| 122 | + |
| 123 | +Auto-generated artifact. Do not edit by hand — your changes will be lost the |
| 124 | +next time `prepare.py` runs. The file carries a header warning to that effect. |
| 125 | + |
| 126 | +Variables rendered from the yaml: |
| 127 | + |
| 128 | +| Variable | Source | Required | |
| 129 | +|---------------------|----------------------------------------|----------| |
| 130 | +| `ADMIN_PASSWORD` | `admin.password` | yes | |
| 131 | +| `ADMIN_SSH_KEY` | `admin.ssh_key` (empty string if null) | no | |
| 132 | +| `DOCKER_GROUP_ID` | `docker_group_id` | yes | |
| 133 | +| `SSH_HOST_PORT` | `ports.ssh_host_port` | yes | |
| 134 | +| `HTTP_HOST_PORT` | `ports.http_host_port` | yes | |
| 135 | +| `SECRET_KEY` | `secrets.secret_key` | yes | |
| 136 | +| `SSH_TO_WEB_KEY` | `secrets.ssh_to_web_key` | yes | |
| 137 | +| `POSTGRES_PASSWORD` | `secrets.postgres_password` | yes | |
| 138 | + |
| 139 | +"Required" means `ctrl.sh` refuses to start if the value is empty, and the |
| 140 | +compose template uses the `${VAR:?message}` form that causes `docker compose` |
| 141 | +itself to fail with a clear error. `DEBUG` and `MAINTENANCE_ENABLED` are |
| 142 | +**not** in `settings.env` — they default to `0` in the compose template and |
| 143 | +are only flipped on by `ctrl.sh up` based on its `--debug` / `--maintenance` |
| 144 | +CLI flags. |
| 145 | + |
| 146 | +### `docker-compose.yml` — derived, consumed by docker compose |
| 147 | + |
| 148 | +Rendered by `prepare.py` from `docker-compose.template.yml` using jinja. The |
| 149 | +template variables are fixed in `prepare.py` for the production flow |
| 150 | +(`data_path=./data`, `exercises_path=./exercises`, production cgroup names), |
| 151 | +so regenerating does not normally change the output unless the template |
| 152 | +itself changes. |
| 153 | + |
| 154 | +The rendered compose file is the only file docker compose actually reads. |
| 155 | +Variables in the template fall into two classes: |
| 156 | + |
| 157 | +- **Jinja template variables** (`{{ cgroup_parent }}`, `{{ data_path }}`, |
| 158 | + `{% if testing %}`, …) — resolved at render time by `prepare.py`. To |
| 159 | + change these you must edit `prepare.py` and re-render. |
| 160 | +- **Compose interpolation variables** (`${POSTGRES_PASSWORD}`, `${DEBUG}`, |
| 161 | + …) — resolved at `docker compose` runtime. These either come from the |
| 162 | + shell environment or from `settings.env` (via `--env-file`). |
| 163 | + |
| 164 | +## Runtime data flow |
| 165 | + |
| 166 | +`ctrl.sh` is the production entrypoint. For every command that touches docker |
| 167 | +compose, it does three things: |
| 168 | + |
| 169 | +1. Sources `settings.env` into its own shell so it can run pre-flight checks: |
| 170 | + docker group ID match, required values non-empty, docker daemon address |
| 171 | + pool sanity (`ctrl.sh:256`). |
| 172 | +2. For the `up` command specifically, exports runtime toggles |
| 173 | + (`REAL_HOSTNAME`, `DEBUG`, `MAINTENANCE_ENABLED`, `DISABLE_TELEGRAM`, |
| 174 | + `DEBUG_TOOLBAR`, `HOT_RELOADING`, `DISABLE_RESPONSE_CACHING`) based on |
| 175 | + CLI flags. |
| 176 | +3. Invokes `docker compose -p ref --env-file settings.env <cmd>`. Docker |
| 177 | + compose then resolves every `${VAR}` placeholder in `docker-compose.yml` |
| 178 | + against: **shell environment first, then `--env-file` values, then the |
| 179 | + defaults written into the compose template**. |
| 180 | + |
| 181 | +The runtime dev/debug flags in the compose template (`DEBUG`, |
| 182 | +`MAINTENANCE_ENABLED`, `DISABLE_TELEGRAM`, `DEBUG_TOOLBAR`, `HOT_RELOADING`, |
| 183 | +`DISABLE_RESPONSE_CACHING`, `RATELIMIT_ENABLED`, `DOCKER_RESSOURCE_PREFIX`, |
| 184 | +`REAL_HOSTNAME`) are intentionally **not** in `settings.env`. They default |
| 185 | +to `0` / empty in the compose template (via `${VAR:-0}` and `${VAR}`) and |
| 186 | +are only flipped on when `ctrl.sh up` exports them based on its CLI flags. |
| 187 | +Any command that doesn't export them (`build`, `restart`, `logs`, …) |
| 188 | +therefore gets the template defaults. |
| 189 | + |
| 190 | +## Changing configuration |
| 191 | + |
| 192 | +### Routine config edits |
| 193 | + |
| 194 | +`settings.yaml` is the canonical file — edit it and re-run `./prepare.py` to |
| 195 | +propagate the changes into `settings.env` and `docker-compose.yml`. Then |
| 196 | +restart the affected services with `./ctrl.sh restart` (or |
| 197 | +`./ctrl.sh restart-web` if only the web container needs to pick up the |
| 198 | +change). |
| 199 | + |
| 200 | +```bash |
| 201 | +$EDITOR settings.yaml # e.g. change ports.ssh_host_port to 2223 |
| 202 | +./prepare.py # re-renders settings.env + docker-compose.yml |
| 203 | +./ctrl.sh restart |
| 204 | +``` |
| 205 | + |
| 206 | +Re-running is safe: `prepare.py` loads the existing yaml, never touches the |
| 207 | +secrets, and the SSH host key generation step skips keys that already exist. |
| 208 | +`settings.env` and `docker-compose.yml` are overwritten from the yaml on |
| 209 | +every run. |
| 210 | + |
| 211 | +### Rotating secrets |
| 212 | + |
| 213 | +To rotate a single secret (e.g. `SECRET_KEY`): |
| 214 | + |
| 215 | +1. Generate a new value: `python3 -c "import secrets; |
| 216 | + print(secrets.token_urlsafe(32))"` |
| 217 | +2. Paste it into `settings.yaml` under `secrets:`. |
| 218 | +3. Re-run `./prepare.py` and then `./ctrl.sh restart`. |
| 219 | + |
| 220 | +Secret-specific notes: |
| 221 | + |
| 222 | +- `postgres_password` — Postgres sets the password when the data directory is |
| 223 | + first initialised. Rotating after initialisation requires also updating the |
| 224 | + password inside Postgres (e.g. via `ALTER USER ref PASSWORD '...'`) |
| 225 | + otherwise the web app will fail to connect. Do this before updating |
| 226 | + `settings.yaml`. |
| 227 | +- `ssh_to_web_key` — shared between the web API and the SSH reverse proxy. |
| 228 | + Both containers must restart together for the new key to take effect; |
| 229 | + `./ctrl.sh restart` is the correct command. |
| 230 | +- `secret_key` — Flask session / CSRF signing key. Rotating invalidates all |
| 231 | + existing user sessions. |
| 232 | +- `admin.password` — used only for the initial admin user creation. After |
| 233 | + the admin exists, rotating this value has no effect; change the password |
| 234 | + through the web UI instead. |
| 235 | + |
| 236 | +To rotate **every** secret at once, run `./prepare.py --fresh`. This moves |
| 237 | +the existing `settings.yaml` to `settings.yaml.backup`, generates fresh |
| 238 | +secrets, and re-renders everything. You must then either reset |
| 239 | +`postgres_password` inside Postgres or wipe `data/postgresql-db/` and |
| 240 | +re-initialise the database. |
| 241 | + |
| 242 | +## Test harness |
| 243 | + |
| 244 | +The test suite in `tests/helpers/ref_instance.py` does not use the repo's |
| 245 | +`settings.yaml` or `settings.env`. Each test instance generates its own |
| 246 | +`settings.env` via `RefInstance._generate_settings_env()` into a per-test |
| 247 | +work directory, with a test-specific `DOCKER_RESSOURCE_PREFIX` so that |
| 248 | +parallel instances do not clash. It also renders its own `docker-compose.yml` |
| 249 | +via `_generate_docker_compose()` with `testing=True`, which skips the host |
| 250 | +port mappings (tests allocate ephemeral ports) and injects per-test cgroup |
| 251 | +slice names and bridge names. |
| 252 | + |
| 253 | +The upshot: editing the repo's `settings.yaml` or `settings.env` has no |
| 254 | +effect on the test suite. Test behaviour is controlled by the `RefInstance` |
| 255 | +config dataclass. |
| 256 | + |
| 257 | +## Gotchas |
| 258 | + |
| 259 | +- **`settings.env` is not automatically loaded by `docker compose` alone.** |
| 260 | + It only takes effect because `ctrl.sh` passes `--env-file settings.env`. |
| 261 | + If you run `docker compose` directly from the repo root without that |
| 262 | + flag, compose falls back to its default `.env` lookup, finds nothing, and |
| 263 | + every `${VAR:?...}` placeholder fails. Always go through `ctrl.sh`, or |
| 264 | + replicate its `--env-file` / shell-export pattern manually. |
| 265 | +- **`container-keys/` and `ref-docker-base/container-keys/` must stay in |
| 266 | + sync.** `prepare.py` copies the former into the latter so the base image |
| 267 | + build picks them up. If you rotate the host keys, re-run `./prepare.py` |
| 268 | + or rebuild the base image. |
| 269 | +- **`settings.yaml` and `settings.env` are mode `0600` by design.** Do not |
| 270 | + loosen the permissions — they contain plaintext secrets. |
0 commit comments