Files
team-tryouts/docs/database-restore.md
T
GGThedandClaude Opus 5 7bf428f9a0 fix(ops): sauvegarder reellement la base PostgreSQL
DATA-002 / OPS-001. backup.py ciblait SQLite : import sqlite3, DATABASE_PATH
par defaut instance/team_tryouts.db, et l'API de sauvegarde sqlite3. La
production tourne sur PostgreSQL, donc le fichier n'existait pas. Le script
affichait "[WARNING] Database not found... Skipping database backup" -- puis,
main() ne suivant que le resultat de la verification, **sortait avec le code
0**. Toute tache planifiee surveillant le code de sortie voyait vert alors
qu'aucune sauvegarde n'avait jamais ete produite.

Il n'existait donc aucune sauvegarde applicative de la base.

Reecriture
  pg_dump en --format=custom : compresse, et pg_restore permet une
  restauration selective, ce qu'un dump SQL a plat ne permet pas.
  parse_database_url accepte les suffixes de dialecte SQLAlchemy
  (postgresql+psycopg://) que pg_dump ne comprend pas, et refuse
  explicitement une URL SQLite -- le cas exact qui passait en silence.

  Le mot de passe ne figure jamais dans la ligne de commande : il serait
  visible de tout processus capable de lister argv. Il passe par PGPASSWORD.
  Il est egalement absent des messages affiches, qui atterrissent dans les
  journaux du planificateur.

  verify_backup lit l'archive avec pg_restore --list et exige au moins une
  table : une archive illisible ne se restaure pas, et une archive sans
  table signifie que le dump a vise la mauvaise cible. Les deux sont des
  echecs silencieux qu'il vaut mieux attraper maintenant que pendant un
  incident.

  Le code de sortie vaut 0 uniquement si le dump a ete produit ET verifie.

L'archive des documents est conservee : les contrats signes n'existent que
sur disque, la base ne stocke que des chemins. Restaurer l'une sans l'autre
laisse des lignes pointant vers des fichiers absents.

docs/database-restore.md
  Procedure de restauration testable sur une base jetable, requetes de
  controle, demarrage de l'application sur la copie restauree, plan de
  reprise par scenario. ENABLE_DISCORD_BOT=false y est signale comme non
  optionnel : sans lui, l'exercice demarre un vrai bot et envoie de vraies
  notifications a de vraies personnes, a partir de donnees restaurees.

  Les points ouverts sont listes tels quels : aucune copie hors site, pas de
  chiffrement au repos, aucune planification, et l'exercice de restauration
  n'a jamais ete effectue.

17 tests sur ce qui est verifiable sans serveur PostgreSQL : analyse de
l'URL, construction de la commande, non-fuite du mot de passe, et surtout
codes de sortie -- le silence ne vaut plus succes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:06:51 -04:00

5.8 KiB

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

# 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
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

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.

createdb -h localhost -U postgres tryouts_restore_test

3.2 Restore

# --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

-- 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

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

dropdb -h localhost -U postgres tryouts_restore_test

4. Restoring documents

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.