Files
team-tryouts/docs/database-restore.md
GGThedandClaude Opus 5 2f40290f00 fix(deps): nommer le pilote PostgreSQL, sinon rien ne demarre
QUA-001, volet dialecte.

`postgresql://` ne veut pas dire "le pilote installe" : SQLAlchemy y lit
psycopg2 et importe ce module a la creation du moteur. requirements.txt
epingle psycopg 3 (`psycopg[binary]`) et pas psycopg2. Une installation
propre demarree sur cette URL leve donc

  ModuleNotFoundError: No module named 'psycopg2'

avant la premiere requete. Verifie dans le .venv du depot, et c est
exactement la forme que Render distribue -- celle que docs/deployment.md et
docs/database-restore.md donnaient en exemple.

normalise_database_url() nomme le pilote quand l URL n en nomme pas.
`postgres://` (alias hérite, abandonne par SQLAlchemy en 1.4) est traite de
meme. Une URL qui nomme deja son pilote est laissee telle quelle, y compris
`postgresql+psycopg2://` : un environnement qui a psycopg2 garde le choix.

La normalisation a lieu apres l application de la configuration passee en
argument, pour couvrir aussi les appels de test. backup.py n avait pas
besoin d etre touche : il retirait deja le suffixe +pilote.

Documentation alignee sur les trois fichiers qui donnaient l exemple, dont
docs/deployment.md qui proposait sqlite:/// pour DATABASE_URL alors que
create_app refuse de demarrer sans PostgreSQL.

Reste de QUA-001, dit franchement
  - les trois paquets parasites (dotenv, login, discord) ne sont plus dans
    requirements.txt : deja retires. psycopg est deja epingle.
  - la consolidation vers des groupes de dependances n est PAS faite. Le
    deploiement est un miroir de fichiers lftp sans etape de construction ;
    les groupes PEP 735 demandent pip >= 25.1 sur une machine dont on ne
    peut pas verifier la version d ici. A revoir avec OPS-011.

14 tests, dont trois qui prouvent que l echec est reel et non theorique.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:50:35 -04:00

176 lines
5.9 KiB
Markdown

# Database Backup and Restore
A backup that has never been restored is not a backup. This document exists
so that the restore path is exercised **before** it is needed, not during an
incident.
---
## 1. What is backed up
`app/supporting_scripts/backup.py` produces two artefacts per run, in
`BACKUP_DIR` (default `./backups`):
| Artefact | Contents | Why it matters |
|---|---|---|
| `db_backup_<timestamp>.dump` | Full PostgreSQL dump, custom format | Every account, tryout, evaluation, note and contract record |
| `documents_backup_<timestamp>.zip` | `documents/` directory | The contract **files** themselves — they exist only on disk, the database stores paths |
Losing either one alone loses data. A database restore without the document
archive leaves contract rows pointing at files that no longer exist.
---
## 2. Running a backup
```bash
# From the project root, with DATABASE_URL set
python app/supporting_scripts/backup.py
```
Requires the PostgreSQL client tools (`pg_dump`, `pg_restore`) on `PATH`, or
`PG_DUMP` / `PG_RESTORE` pointing at them.
| Variable | Default | Purpose |
|---|---|---|
| `DATABASE_URL` | — | Required. `postgresql://user:pass@host:port/dbname`. The application names the psycopg 3 driver itself; this script accepts either form. |
| `BACKUP_DIR` | `./backups` | Destination directory |
| `BACKUP_RETENTION_DAYS` | `30` | Files older than this are deleted |
| `PG_DUMP` / `PG_RESTORE` | `pg_dump` / `pg_restore` | Full paths if not on `PATH` |
**Exit code 0 means the dump was produced *and* verified.** Anything else
means you have no usable backup from that run — treat a non-zero exit as an
incident, not a warning. Whatever schedules this job must check the exit
code; the previous version of the script returned 0 even when it had backed
up nothing at all.
### Verifying an existing archive
```bash
python app/supporting_scripts/backup.py --verify-only backups/db_backup_20260807_030000.dump
```
This reads the archive with `pg_restore --list` and confirms it contains
table data. It touches no database.
---
## 3. Restore drill
**Run this on a throwaway database, at least once a quarter, and after any
change to the schema tooling.** It is the only thing that turns a file into
a guarantee.
### 3.1 Create an isolated target
Never restore onto the production database to "test" a backup.
```bash
createdb -h localhost -U postgres tryouts_restore_test
```
### 3.2 Restore
```bash
# --clean --if-exists makes the restore repeatable
pg_restore \
--host localhost --port 5432 --username postgres \
--dbname tryouts_restore_test \
--clean --if-exists --no-owner --no-privileges \
backups/db_backup_20260807_030000.dump
```
`pg_restore` reports errors per object and continues. **Read its output.**
A restore that emits errors and returns 0 has still lost something.
### 3.3 Check the data is actually there
```sql
-- Connected to tryouts_restore_test
SELECT COUNT(*) FROM users;
SELECT role, COUNT(*) FROM users GROUP BY role ORDER BY role;
SELECT COUNT(*) FROM tryouts;
SELECT COUNT(*) FROM evaluations;
SELECT COUNT(*) FROM contracts;
SELECT MAX(created_at) FROM users;
```
Compare against production. The last query tells you how old the backup is
— the number that matters during an incident.
### 3.4 Start the application against the restored copy
```bash
DATABASE_URL="postgresql://postgres@localhost:5432/tryouts_restore_test" \
SECRET_KEY="throwaway-for-the-drill" \
ENABLE_DISCORD_BOT=false \
FORCE_HTTPS=false \
python wsgi.py
```
`ENABLE_DISCORD_BOT=false` is not optional. Without it the drill starts a
real bot against the real Discord server and sends real notifications to
real people, from restored data.
Smoke test:
1. `GET /health` returns 200 with `"database": "connected"`.
2. Log in with a known account.
3. Open a tryout and check its registrations are present.
4. Open the calendar.
### 3.5 Tear down
```bash
dropdb -h localhost -U postgres tryouts_restore_test
```
---
## 4. Restoring documents
```bash
unzip backups/documents_backup_20260807_030000.zip -d documents/
```
Then confirm a contract downloads through the application, not just that
the file exists: `Contract.file_path` stores an **absolute** path recorded
at upload time. If the deployment root has changed since, the rows point
somewhere that no longer exists and the files must be placed at the old
path, or the column updated.
---
## 5. Recovery plan
| Scenario | First move | Then |
|---|---|---|
| Accidental deletion of a few records | Restore to a throwaway database (§3), extract the rows, re-insert them | Do **not** restore over production |
| Database corrupted or lost | Restore the latest verified dump onto a fresh database, repoint `DATABASE_URL` | Restore documents (§4), then smoke test (§3.4) |
| Bad deployment | Redeploy the previous commit | The database is untouched unless a migration ran |
| Bad migration | Restore the pre-migration dump | Always take one immediately before migrating |
| Server lost entirely | Provision a host, restore database and documents, redeploy | Discord bot token and `SECRET_KEY` must be reissued if they were on the lost host |
### Two numbers to agree on
- **RPO** — how much data may be lost. It equals the backup interval.
Nightly backups mean up to 24 hours of tryouts, evaluations and notes.
- **RTO** — how long recovery may take. Measure it during the drill; do not
estimate it.
Neither number is currently set for this project. Deciding them is a
prerequisite to claiming there is a backup policy.
---
## 6. Open points
- **Off-site copy.** Backups written next to the application are lost with
the host. Nothing currently copies them elsewhere.
- **Encryption at rest.** The dump contains every account record and
password hash. It is not encrypted.
- **Scheduling.** No scheduled task or cron job is configured in the
repository. The script must be wired to one, with its exit code monitored.
- **Restore drill.** Has never been performed. Until it is, the restore
path is untested.