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]>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""Backup script.
|
||||
|
||||
DATA-002 / OPS-001. The previous script targeted SQLite while production
|
||||
runs on PostgreSQL: it printed "[WARNING] Database not found" and still
|
||||
exited 0, so a scheduled task watching the exit code saw green while
|
||||
nothing had ever been backed up.
|
||||
|
||||
These tests cover what can be checked without a PostgreSQL server: URL
|
||||
parsing, command construction, password handling, and — above all — that
|
||||
failure now produces a non-zero exit code.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.supporting_scripts import backup as backup_module
|
||||
from app.supporting_scripts.backup import (
|
||||
BackupError,
|
||||
build_dump_command,
|
||||
describe_target,
|
||||
dump_environment,
|
||||
parse_database_url,
|
||||
)
|
||||
|
||||
PASSWORD = 'sup3r-s3cret'
|
||||
URL = f'postgresql://appuser:{PASSWORD}@db.example.test:6432/tryouts'
|
||||
|
||||
|
||||
class TestUrlParsing:
|
||||
def test_a_standard_url_is_split(self):
|
||||
conn = parse_database_url(URL)
|
||||
|
||||
assert conn['host'] == 'db.example.test'
|
||||
assert conn['port'] == '6432'
|
||||
assert conn['dbname'] == 'tryouts'
|
||||
assert conn['user'] == 'appuser'
|
||||
assert conn['password'] == PASSWORD
|
||||
|
||||
def test_the_sqlalchemy_dialect_suffix_is_accepted(self):
|
||||
"""SQLAlchemy writes postgresql+psycopg://, which pg_dump rejects."""
|
||||
conn = parse_database_url(
|
||||
'postgresql+psycopg://u:p@localhost/tryouts')
|
||||
assert conn['dbname'] == 'tryouts'
|
||||
|
||||
def test_the_default_port_is_applied(self):
|
||||
assert parse_database_url('postgresql://u:p@localhost/db')['port'] == '5432'
|
||||
|
||||
def test_percent_encoded_credentials_are_decoded(self):
|
||||
conn = parse_database_url('postgresql://u%40corp:p%23ass@h/db')
|
||||
assert conn['user'] == 'u@corp'
|
||||
assert conn['password'] == 'p#ass'
|
||||
|
||||
def test_a_missing_url_is_refused(self):
|
||||
with pytest.raises(BackupError, match='DATABASE_URL is not set'):
|
||||
parse_database_url(None)
|
||||
|
||||
def test_a_sqlite_url_is_refused(self):
|
||||
"""The exact case that used to pass silently."""
|
||||
with pytest.raises(BackupError, match='not a PostgreSQL'):
|
||||
parse_database_url('sqlite:///instance/team_tryouts.db')
|
||||
|
||||
def test_a_url_without_a_database_name_is_refused(self):
|
||||
with pytest.raises(BackupError, match='does not name a database'):
|
||||
parse_database_url('postgresql://u:p@localhost/')
|
||||
|
||||
|
||||
class TestCommandConstruction:
|
||||
def test_the_password_never_reaches_the_command_line(self):
|
||||
"""Anything on argv is visible to any process listing."""
|
||||
command = build_dump_command(parse_database_url(URL), '/tmp/out.dump')
|
||||
|
||||
assert PASSWORD not in ' '.join(command)
|
||||
|
||||
def test_the_password_travels_through_the_environment(self):
|
||||
env = dump_environment(parse_database_url(URL))
|
||||
assert env['PGPASSWORD'] == PASSWORD
|
||||
|
||||
def test_no_pgpassword_is_set_when_there_is_no_password(self):
|
||||
env = dump_environment(parse_database_url('postgresql://u@localhost/db'))
|
||||
assert 'PGPASSWORD' not in env
|
||||
|
||||
def test_the_dump_is_written_in_the_custom_format(self):
|
||||
"""Custom format is compressed and restorable selectively."""
|
||||
command = build_dump_command(parse_database_url(URL), '/tmp/out.dump')
|
||||
assert '--format=custom' in command
|
||||
assert '--file' in command
|
||||
assert command[command.index('--file') + 1] == '/tmp/out.dump'
|
||||
|
||||
def test_connection_settings_are_passed_through(self):
|
||||
command = build_dump_command(parse_database_url(URL), '/tmp/out.dump')
|
||||
assert command[command.index('--host') + 1] == 'db.example.test'
|
||||
assert command[command.index('--port') + 1] == '6432'
|
||||
assert command[command.index('--dbname') + 1] == 'tryouts'
|
||||
|
||||
def test_the_description_omits_the_password(self):
|
||||
"""It is printed on stdout and lands in scheduler logs."""
|
||||
described = describe_target(parse_database_url(URL))
|
||||
assert PASSWORD not in described
|
||||
assert 'db.example.test:6432/tryouts' in described
|
||||
|
||||
|
||||
class TestExitCodes:
|
||||
"""The regression that matters: silence is no longer success."""
|
||||
|
||||
def test_a_sqlite_url_fails_the_run(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv('DATABASE_URL', 'sqlite:///instance/team_tryouts.db')
|
||||
|
||||
assert backup_module.main([]) == 1
|
||||
assert 'FAILED' in capsys.readouterr().out
|
||||
|
||||
def test_a_missing_url_fails_the_run(self, monkeypatch):
|
||||
monkeypatch.delenv('DATABASE_URL', raising=False)
|
||||
assert backup_module.main([]) == 1
|
||||
|
||||
def test_a_missing_pg_dump_fails_the_run(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv('DATABASE_URL', URL)
|
||||
monkeypatch.setattr(backup_module, 'BACKUP_DIR', str(tmp_path))
|
||||
monkeypatch.setattr(backup_module, 'PG_DUMP', 'pg_dump_that_does_not_exist')
|
||||
|
||||
assert backup_module.main([]) == 1
|
||||
|
||||
def test_verifying_a_missing_archive_fails(self, tmp_path):
|
||||
assert backup_module.main(
|
||||
['--verify-only', str(tmp_path / 'nope.dump')]) == 1
|
||||
Reference in New Issue
Block a user