Skip to content

Commit f6a334d

Browse files
author
Nils Bars
committed
Re-render settings.env and docker-compose.yml from settings.yaml on demand
prepare.py now has two modes: bootstrap (no settings.yaml) generates fresh secrets as before, and re-render (settings.yaml exists) loads the existing yaml and re-propagates it into settings.env and docker-compose.yml without touching the secrets. Routine config edits are now "edit settings.yaml, re-run ./prepare.py, ./ctrl.sh restart" instead of hand-syncing two files. Pass --fresh to force regeneration; the existing yaml is moved to settings.yaml.backup first. Lift data_path, exercises_path, ref_utils_path, and binfmt_support from hard-coded values in prepare.py into new paths and runtime sections of settings.yaml, and parameterize the ref-utils bind-mount source in the compose template via {{ ref_utils_path }}. load_settings_yaml backfills these sections into older yamls and always re-emits the file so the current schema, key order, and section comments propagate automatically. The test harness passes an absolute ref_utils_path so its generated compose still renders correctly. Drop the dead debug / maintenance_enabled fields from settings.yaml and settings.env — they were always overridden by ctrl.sh up's shell exports. The compose template now uses ${DEBUG:-0} / ${MAINTENANCE_ENABLED:-0} defaults so non-up commands (build, restart, logs) no longer need the values in settings.env. settings.yaml and settings.env are now self-documenting: each top-level yaml section carries a preamble comment explaining its purpose, and each env var gets a descriptive comment above it. docs/CONFIG.md documents the full configuration pipeline: the three-file data flow, bootstrap vs re-render, the yaml schema, secret rotation, test-harness divergence, and the remaining gotchas. template.env (a redirect stub) and the obsolete settings.env.backup gitignore entry are removed.
1 parent 75efeaa commit f6a334d

6 files changed

Lines changed: 449 additions & 66 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
docker-compose.yml
1010
settings.env
11-
settings.env.backup
1211
settings.yaml
12+
settings.yaml.backup
1313
exercises
1414
data
1515

docker-compose.template.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ services:
4949
environment:
5050
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD not set}
5151
- SSH_TO_WEB_KEY=${SSH_TO_WEB_KEY:?SSH_TO_WEB_KEY not set}
52-
- DEBUG=${DEBUG:?DEBUG not set}
53-
- MAINTENANCE_ENABLED=${MAINTENANCE_ENABLED:?MAINTENANCE_ENABLED not set}
52+
- DEBUG=${DEBUG:-0}
53+
- MAINTENANCE_ENABLED=${MAINTENANCE_ENABLED:-0}
5454
- POSTGRES_USER=ref
5555
- POSTGRES_DB=ref
5656
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not set}
@@ -94,7 +94,7 @@ services:
9494
#Source for ref-utils, bind-mounted read-only into student
9595
#instances so edits on the host apply without rebuilding images.
9696
- type: bind
97-
source: ./ref-docker-base/ref-utils
97+
source: {{ ref_utils_path }}
9898
target: /ref-utils
9999
read_only: true
100100
{% if testing %}

docs/CONFIG.md

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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

Comments
 (0)