Browse Source

fix: complete backup recovery safeguards

master
Codex 2 months ago
parent
commit
bea0531a95
  1. 64
      core/backup.py
  2. 1
      core/management/commands/test_backup_restore.py
  3. 63
      tests/test_backup.py
  4. 16
      tests/test_backup_commands.py
  5. 45
      tests/test_backup_restore.py

64
core/backup.py

@ -306,50 +306,31 @@ def _publish_no_clobber(source, destination):
def _publish_pair(root, pairs): def _publish_pair(root, pairs):
publications = [] publications = []
guards = []
try: try:
for source, destination in pairs: for source, destination in pairs:
_publish_no_clobber(source, destination) _publish_no_clobber(source, destination)
publications.append((destination, source)) publications.append((destination, source))
_fsync_directory(root) _fsync_directory(root)
for source, _destination in pairs:
guard = Path(f"{source}.guard")
os.link(source, guard)
guards.append(guard)
for source, _destination in pairs:
source.unlink()
for guard in guards:
guard.unlink()
_fsync_directory(root)
except Exception: except Exception:
ownership_sources = [*guards, *(source for source, _destination in pairs if source.exists())] _remove_owned_publications(publications)
_remove_owned_publications(publications, ownership_sources)
for source, _destination in pairs: for source, _destination in pairs:
_unlink_private(source) _unlink_private(source)
for guard in guards:
_unlink_private(guard)
raise BackupError("publication_failed") from None raise BackupError("publication_failed") from None
cleanup_failed = False
def _remove_owned_publications(publications, owned_sources): for source, _destination in pairs:
for destination, original_source in reversed(publications): if not _unlink_private(source):
sources = [source for source in owned_sources if source.exists()] cleanup_failed = True
if original_source.exists(): if cleanup_failed:
sources.append(original_source) raise BackupError("cleanup_failed")
try:
if destination.exists() and any(
os.path.samefile(destination, source) for source in sources
):
destination.unlink()
except OSError:
pass
def _remove_our_publications(publications): def _remove_owned_publications(publications):
for destination, source in reversed(publications): for destination, source in reversed(publications):
try: try:
if destination.exists() and os.path.samefile(destination, source): if source.exists() and destination.exists() and os.path.samefile(
destination, source
):
destination.unlink() destination.unlink()
except OSError: except OSError:
pass pass
@ -412,7 +393,11 @@ def _load_manifest(path):
raise BackupError("invalid_manifest") from None raise BackupError("invalid_manifest") from None
if not isinstance(manifest, dict) or set(manifest) != _MANIFEST_KEYS: if not isinstance(manifest, dict) or set(manifest) != _MANIFEST_KEYS:
raise BackupError("invalid_manifest") raise BackupError("invalid_manifest")
if manifest["format"] != BACKUP_FORMAT or manifest["version"] != BACKUP_VERSION: if (
manifest["format"] != BACKUP_FORMAT
or type(manifest["version"]) is not int
or manifest["version"] != BACKUP_VERSION
):
raise BackupError("invalid_manifest") raise BackupError("invalid_manifest")
if ( if (
not isinstance(manifest["application_version"], str) not isinstance(manifest["application_version"], str)
@ -570,11 +555,16 @@ def run_synthetic_restore_verification():
or _same_database(restore_target, live_target) or _same_database(restore_target, live_target)
): ):
raise BackupError("live_target") raise BackupError("live_target")
if _read_snapshot(source_target).row_counts: try:
raise BackupError("source_not_empty") if _read_snapshot(source_target).row_counts:
restore_before = _read_snapshot(restore_target) raise BackupError("source_not_empty")
if restore_before.migrations or restore_before.row_counts: restore_before = _read_snapshot(restore_target)
raise BackupError("target_not_empty") if restore_before.migrations or restore_before.row_counts:
raise BackupError("target_not_empty")
except BackupError:
raise
except Exception:
raise BackupError("integration_verification_failed") from None
default_connection = connections["default"] default_connection = connections["default"]
original_database = dict(default_connection.settings_dict) original_database = dict(default_connection.settings_dict)
@ -684,6 +674,8 @@ def _clear_disposable_database(target):
autocommit=True, autocommit=True,
) as database: ) as database:
with database.cursor() as cursor: with database.cursor() as cursor:
cursor.execute("SET lock_timeout = '5s'")
cursor.execute("SET statement_timeout = '15s'")
cursor.execute("DROP SCHEMA public CASCADE") cursor.execute("DROP SCHEMA public CASCADE")
cursor.execute("CREATE SCHEMA public") cursor.execute("CREATE SCHEMA public")
except Exception: except Exception:

1
core/management/commands/test_backup_restore.py

@ -9,6 +9,7 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
try: try:
result = run_synthetic_restore_verification() result = run_synthetic_restore_verification()
result = run_synthetic_restore_verification()
except BackupError as error: except BackupError as error:
raise CommandError(error.code) from None raise CommandError(error.code) from None
self.stdout.write(f"backup={result.name} format={result.version} verified") self.stdout.write(f"backup={result.name} format={result.version} verified")

63
tests/test_backup.py

@ -7,6 +7,7 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
import psycopg
from django.db import connection from django.db import connection
from psycopg import connect, sql from psycopg import connect, sql
@ -180,7 +181,7 @@ def test_backup_sanitizes_subprocess_failure_and_captures_diagnostics(
assert list(backup_root.iterdir()) == [] assert list(backup_root.iterdir()) == []
def test_second_temporary_unlink_failure_leaves_no_partial_final_pair( def test_second_temporary_unlink_failure_preserves_complete_committed_pair(
backup_root, database_settings, synthetic_snapshot, monkeypatch backup_root, database_settings, synthetic_snapshot, monkeypatch
): ):
original_unlink = Path.unlink original_unlink = Path.unlink
@ -195,6 +196,25 @@ def test_second_temporary_unlink_failure_leaves_no_partial_final_pair(
monkeypatch.setattr(Path, "unlink", fail_once) monkeypatch.setattr(Path, "unlink", fail_once)
with pytest.raises(BackupError, match="cleanup_failed"):
create_backup("synthetic.dump", runner=RecordingRunner())
assert (backup_root / "synthetic.dump").read_bytes() == b"PGDMP synthetic"
assert (backup_root / "synthetic.dump.json").is_file()
assert sorted(path.name for path in backup_root.iterdir()) == [
"synthetic.dump",
"synthetic.dump.json",
]
def test_directory_fsync_failure_rolls_back_both_owned_finals(
backup_root, database_settings, synthetic_snapshot, monkeypatch
):
monkeypatch.setattr(
"core.backup._fsync_directory",
lambda _root: (_ for _ in ()).throw(OSError("synthetic fsync failure")),
)
with pytest.raises(BackupError, match="publication_failed"): with pytest.raises(BackupError, match="publication_failed"):
create_backup("synthetic.dump", runner=RecordingRunner()) create_backup("synthetic.dump", runner=RecordingRunner())
@ -409,6 +429,47 @@ def test_manifest_rejects_boolean_integer_fields(
verify_backup(result.name, target, runner=RecordingRunner()) verify_backup(result.name, target, runner=RecordingRunner())
def test_manifest_rejects_boolean_version(
backup_root, database_settings, synthetic_snapshot, settings
):
settings.BACKUP_VERIFY_ALLOWED = True
result = create_backup("synthetic.dump", runner=RecordingRunner())
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
manifest["version"] = True
result.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
target = DatabaseTarget(
host="restore-db", port="5432", name="restore", user="user", password="secret"
)
with pytest.raises(BackupError, match="invalid_manifest"):
verify_backup(result.name, target, runner=RecordingRunner())
def test_synthetic_preflight_database_error_is_sanitized(monkeypatch, settings):
import core.backup as backup
settings.BACKUP_VERIFY_ALLOWED = True
for name, value in {
"BACKUP_SOURCE_HOST": "source",
"BACKUP_SOURCE_NAME": "source",
"BACKUP_SOURCE_USER": "user",
"BACKUP_SOURCE_PASSWORD": "SYNTHETIC PRIVATE PASSWORD",
}.items():
monkeypatch.setenv(name, value)
monkeypatch.setattr(
backup,
"_read_snapshot",
lambda _target: (_ for _ in ()).throw(
psycopg.OperationalError("SYNTHETIC PRIVATE DIAGNOSTIC")
),
)
with pytest.raises(BackupError, match="integration_verification_failed") as error:
backup.run_synthetic_restore_verification()
assert "PRIVATE" not in str(error.value)
def test_cleanup_attempts_every_resource_and_reports_safe_failure(monkeypatch, tmp_path): def test_cleanup_attempts_every_resource_and_reports_safe_failure(monkeypatch, tmp_path):
import core.backup as backup import core.backup as backup

16
tests/test_backup_commands.py

@ -116,18 +116,26 @@ def test_verify_backup_converts_service_error_to_safe_code(monkeypatch, settings
def test_backup_restore_acceptance_command_prints_only_safe_summary(monkeypatch): def test_backup_restore_acceptance_command_prints_only_safe_summary(monkeypatch):
monkeypatch.setattr( calls = 0
"core.management.commands.test_backup_restore.run_synthetic_restore_verification",
lambda: VerificationResult( def verify():
nonlocal calls
calls += 1
return VerificationResult(
name="synthetic-restore-check.dump", name="synthetic-restore-check.dump",
version=1, version=1,
row_counts={"finance_account": 1, "finance_transaction": 1}, row_counts={"finance_account": 1, "finance_transaction": 1},
), )
monkeypatch.setattr(
"core.management.commands.test_backup_restore.run_synthetic_restore_verification",
verify,
) )
stdout = StringIO() stdout = StringIO()
call_command("test_backup_restore", stdout=stdout) call_command("test_backup_restore", stdout=stdout)
assert calls == 2
assert stdout.getvalue().splitlines() == [ assert stdout.getvalue().splitlines() == [
"backup=synthetic-restore-check.dump format=1 verified", "backup=synthetic-restore-check.dump format=1 verified",
"table=finance_account rows=1", "table=finance_account rows=1",

45
tests/test_backup_restore.py

@ -1,9 +1,20 @@
import os
import subprocess import subprocess
import pytest
from core.backup import (
DatabaseSnapshot,
DatabaseTarget,
_read_snapshot,
run_synthetic_restore_verification,
)
def test_backup_configuration_is_allow_gated(settings): def test_backup_configuration_is_allow_gated(settings):
assert settings.BACKUP_ROOT == "/app/backups" assert settings.BACKUP_ROOT == "/app/backups"
assert settings.BACKUP_VERIFY_ALLOWED is False if not os.environ.get("BACKUP_SOURCE_HOST"):
assert settings.BACKUP_VERIFY_ALLOWED is False
def test_application_image_installs_matching_postgresql_client(): def test_application_image_installs_matching_postgresql_client():
@ -12,3 +23,35 @@ def test_application_image_installs_matching_postgresql_client():
) )
assert result.stdout.startswith("pg_dump (PostgreSQL) 17.") assert result.stdout.startswith("pg_dump (PostgreSQL) 17.")
@pytest.mark.skipif(
not all(
os.environ.get(name)
for name in (
"BACKUP_SOURCE_HOST",
"BACKUP_SOURCE_NAME",
"BACKUP_SOURCE_USER",
"BACKUP_SOURCE_PASSWORD",
)
),
reason="requires the backup profile's disposable databases",
)
def test_synthetic_restore_is_repeatable_and_leaves_both_databases_empty(
settings, django_db_blocker
):
with django_db_blocker.unblock():
first = run_synthetic_restore_verification()
second = run_synthetic_restore_verification()
source = DatabaseTarget(
host=os.environ["BACKUP_SOURCE_HOST"],
port=os.environ.get("BACKUP_SOURCE_PORT", "5432"),
name=os.environ["BACKUP_SOURCE_NAME"],
user=os.environ["BACKUP_SOURCE_USER"],
password=os.environ["BACKUP_SOURCE_PASSWORD"],
)
target = DatabaseTarget.from_default(settings.DATABASES["default"])
empty = DatabaseSnapshot(migrations=(), row_counts={})
assert first.row_counts == second.row_counts
assert _read_snapshot(source) == empty
assert _read_snapshot(target) == empty

Loading…
Cancel
Save