13 changed files with 1153 additions and 1 deletions
@ -0,0 +1,6 @@ |
|||||||
|
# Copy to .env and replace all values with independently generated secrets. |
||||||
|
DJANGO_SECRET_KEY=replace-with-random-secret |
||||||
|
POSTGRES_PASSWORD=replace-with-bootstrap-secret |
||||||
|
MIGRATION_PASSWORD=replace-with-migration-secret |
||||||
|
RUNTIME_PASSWORD=replace-with-runtime-secret |
||||||
|
TEST_PASSWORD=replace-with-test-secret |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
FROM python:3.13-slim |
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /usr/local/bin/uv |
||||||
|
RUN apt-get update \ |
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl \ |
||||||
|
&& install -d /usr/share/postgresql-common/pgdg \ |
||||||
|
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ |
||||||
|
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ |
||||||
|
&& . /etc/os-release \ |
||||||
|
&& echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" \ |
||||||
|
> /etc/apt/sources.list.d/pgdg.list \ |
||||||
|
&& apt-get update \ |
||||||
|
&& apt-get install -y --no-install-recommends postgresql-client-17 \ |
||||||
|
&& rm -rf /var/lib/apt/lists/* |
||||||
|
WORKDIR /app |
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH="/app/.venv/bin:$PATH" |
||||||
|
COPY pyproject.toml uv.lock ./ |
||||||
|
RUN uv sync --frozen --no-install-project |
||||||
|
COPY config ./config |
||||||
|
COPY core ./core |
||||||
|
COPY finance ./finance |
||||||
|
COPY ingestion ./ingestion |
||||||
|
COPY tests ./tests |
||||||
|
COPY manage.py ./ |
||||||
|
RUN useradd --uid 10001 --create-home app |
||||||
|
USER app |
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--noreload"] |
||||||
@ -0,0 +1,603 @@ |
|||||||
|
import json |
||||||
|
import os |
||||||
|
import re |
||||||
|
import subprocess |
||||||
|
import tempfile |
||||||
|
from dataclasses import dataclass |
||||||
|
from datetime import datetime, timezone as datetime_timezone |
||||||
|
from hashlib import sha256 |
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
import psycopg |
||||||
|
from psycopg import sql |
||||||
|
from django.conf import settings |
||||||
|
|
||||||
|
|
||||||
|
BACKUP_FORMAT = "gizmoduck-postgresql-backup" |
||||||
|
BACKUP_VERSION = 1 |
||||||
|
APPLICATION_VERSION = "0.1.0" |
||||||
|
|
||||||
|
_PLAIN_DUMP_NAME = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]{0,198}\.dump\Z") |
||||||
|
_TABLE_NAME = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*\Z") |
||||||
|
_MANIFEST_KEYS = { |
||||||
|
"application_version", |
||||||
|
"byte_size", |
||||||
|
"created_at", |
||||||
|
"database_name", |
||||||
|
"format", |
||||||
|
"migrations", |
||||||
|
"postgresql_client_version", |
||||||
|
"row_counts", |
||||||
|
"sha256", |
||||||
|
"version", |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
class BackupError(Exception): |
||||||
|
def __init__(self, code): |
||||||
|
self.code = code |
||||||
|
super().__init__(code) |
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True) |
||||||
|
class DatabaseTarget: |
||||||
|
host: str |
||||||
|
port: str |
||||||
|
name: str |
||||||
|
user: str |
||||||
|
password: str |
||||||
|
|
||||||
|
@classmethod |
||||||
|
def from_default(cls, database): |
||||||
|
return cls( |
||||||
|
host=str(database.get("HOST", "")), |
||||||
|
port=str(database.get("PORT") or "5432"), |
||||||
|
name=str(database.get("NAME", "")), |
||||||
|
user=str(database.get("USER", "")), |
||||||
|
password=str(database.get("PASSWORD", "")), |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True) |
||||||
|
class DatabaseSnapshot: |
||||||
|
migrations: tuple[tuple[str, str], ...] |
||||||
|
row_counts: dict[str, int] |
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True) |
||||||
|
class BackupResult: |
||||||
|
name: str |
||||||
|
path: Path |
||||||
|
manifest_path: Path |
||||||
|
version: int |
||||||
|
created_at: datetime |
||||||
|
postgresql_client_version: str |
||||||
|
row_counts: dict[str, int] |
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True) |
||||||
|
class VerificationResult: |
||||||
|
name: str |
||||||
|
version: int |
||||||
|
row_counts: dict[str, int] |
||||||
|
|
||||||
|
|
||||||
|
def create_backup(name=None, *, runner=None, _target=None): |
||||||
|
runner = runner or subprocess.run |
||||||
|
root = _backup_root() |
||||||
|
if name is None: |
||||||
|
now = datetime.now(datetime_timezone.utc) |
||||||
|
name = f"gizmoduck-backup-{now:%Y%m%dT%H%M%SZ}.dump" |
||||||
|
else: |
||||||
|
now = datetime.now(datetime_timezone.utc) |
||||||
|
_validate_name(name) |
||||||
|
|
||||||
|
destination = root / name |
||||||
|
manifest_destination = root / f"{name}.json" |
||||||
|
_ensure_available(destination, manifest_destination) |
||||||
|
|
||||||
|
target = _target or DatabaseTarget.from_default(settings.DATABASES["default"]) |
||||||
|
snapshot = _read_snapshot(target) |
||||||
|
dump_path = None |
||||||
|
manifest_path = None |
||||||
|
published = [] |
||||||
|
try: |
||||||
|
dump_fd, dump_name = tempfile.mkstemp( |
||||||
|
prefix=".gizmoduck-backup-", suffix=".dump.tmp", dir=root |
||||||
|
) |
||||||
|
dump_path = Path(dump_name) |
||||||
|
with os.fdopen(dump_fd, "wb") as stream: |
||||||
|
runner( |
||||||
|
_pg_dump_argv(target), |
||||||
|
env=_database_env(target), |
||||||
|
check=True, |
||||||
|
stdout=stream, |
||||||
|
capture_output=False, |
||||||
|
) |
||||||
|
stream.flush() |
||||||
|
os.fsync(stream.fileno()) |
||||||
|
|
||||||
|
content_digest, byte_size = _file_digest(dump_path) |
||||||
|
version_process = runner( |
||||||
|
["pg_dump", "--version"], |
||||||
|
env=_database_env(target), |
||||||
|
check=True, |
||||||
|
stdout=None, |
||||||
|
capture_output=True, |
||||||
|
) |
||||||
|
client_version = _safe_client_version(getattr(version_process, "stdout", b"")) |
||||||
|
manifest = { |
||||||
|
"application_version": APPLICATION_VERSION, |
||||||
|
"byte_size": byte_size, |
||||||
|
"created_at": now.isoformat(), |
||||||
|
"database_name": target.name, |
||||||
|
"format": BACKUP_FORMAT, |
||||||
|
"migrations": [list(item) for item in snapshot.migrations], |
||||||
|
"postgresql_client_version": client_version, |
||||||
|
"row_counts": snapshot.row_counts, |
||||||
|
"sha256": content_digest, |
||||||
|
"version": BACKUP_VERSION, |
||||||
|
} |
||||||
|
manifest_fd, manifest_name = tempfile.mkstemp( |
||||||
|
prefix=".gizmoduck-backup-", suffix=".json.tmp", dir=root |
||||||
|
) |
||||||
|
manifest_path = Path(manifest_name) |
||||||
|
with os.fdopen(manifest_fd, "w", encoding="utf-8", newline="\n") as stream: |
||||||
|
json.dump(manifest, stream, ensure_ascii=False, separators=(",", ":")) |
||||||
|
stream.write("\n") |
||||||
|
stream.flush() |
||||||
|
os.fsync(stream.fileno()) |
||||||
|
|
||||||
|
_publish_no_clobber(manifest_path, manifest_destination) |
||||||
|
published.append((manifest_destination, manifest_path)) |
||||||
|
_publish_no_clobber(dump_path, destination) |
||||||
|
published.append((destination, dump_path)) |
||||||
|
_fsync_directory(root) |
||||||
|
manifest_path.unlink() |
||||||
|
manifest_path = None |
||||||
|
dump_path.unlink() |
||||||
|
dump_path = None |
||||||
|
return BackupResult( |
||||||
|
name=name, |
||||||
|
path=destination, |
||||||
|
manifest_path=manifest_destination, |
||||||
|
version=BACKUP_VERSION, |
||||||
|
created_at=now, |
||||||
|
postgresql_client_version=client_version, |
||||||
|
row_counts=dict(snapshot.row_counts), |
||||||
|
) |
||||||
|
except BackupError: |
||||||
|
_remove_our_publications(published) |
||||||
|
raise |
||||||
|
except Exception: |
||||||
|
_remove_our_publications(published) |
||||||
|
raise BackupError("backup_failed") from None |
||||||
|
finally: |
||||||
|
_unlink_private(manifest_path) |
||||||
|
_unlink_private(dump_path) |
||||||
|
|
||||||
|
|
||||||
|
def verify_backup(name, target, *, runner=None): |
||||||
|
runner = runner or subprocess.run |
||||||
|
_validate_name(name) |
||||||
|
live = DatabaseTarget( |
||||||
|
host=str(getattr(settings, "LIVE_DATABASE_HOST", None) or settings.DATABASES["default"].get("HOST", "")), |
||||||
|
port=str(settings.DATABASES["default"].get("PORT") or "5432"), |
||||||
|
name=str(getattr(settings, "LIVE_DATABASE_NAME", None) or settings.DATABASES["default"].get("NAME", "")), |
||||||
|
user="", |
||||||
|
password="", |
||||||
|
) |
||||||
|
if _same_database(live, target): |
||||||
|
raise BackupError("live_target") |
||||||
|
if not getattr(settings, "BACKUP_VERIFY_ALLOWED", False): |
||||||
|
raise BackupError("verification_not_allowed") |
||||||
|
|
||||||
|
root = _backup_root() |
||||||
|
dump_path = root / name |
||||||
|
manifest_path = root / f"{name}.json" |
||||||
|
manifest = _load_manifest(manifest_path) |
||||||
|
if not dump_path.is_file() or dump_path.is_symlink(): |
||||||
|
raise BackupError("missing_backup") |
||||||
|
digest, byte_size = _file_digest(dump_path) |
||||||
|
if manifest["byte_size"] != byte_size: |
||||||
|
raise BackupError("size_mismatch") |
||||||
|
if manifest["sha256"] != digest: |
||||||
|
raise BackupError("digest_mismatch") |
||||||
|
|
||||||
|
try: |
||||||
|
before = _read_snapshot(target) |
||||||
|
if before.migrations or before.row_counts: |
||||||
|
raise BackupError("target_not_empty") |
||||||
|
runner( |
||||||
|
_pg_restore_argv(target, dump_path), |
||||||
|
env=_database_env(target), |
||||||
|
check=True, |
||||||
|
stdout=None, |
||||||
|
capture_output=True, |
||||||
|
) |
||||||
|
after = _read_snapshot(target) |
||||||
|
except BackupError: |
||||||
|
raise |
||||||
|
except Exception: |
||||||
|
raise BackupError("verification_failed") from None |
||||||
|
|
||||||
|
expected_migrations = tuple(tuple(item) for item in manifest["migrations"]) |
||||||
|
if after.migrations != expected_migrations: |
||||||
|
raise BackupError("migration_mismatch") |
||||||
|
if after.row_counts != manifest["row_counts"]: |
||||||
|
raise BackupError("row_count_mismatch") |
||||||
|
return VerificationResult(name=name, version=BACKUP_VERSION, row_counts=after.row_counts) |
||||||
|
|
||||||
|
|
||||||
|
def _pg_dump_argv(target): |
||||||
|
return [ |
||||||
|
"pg_dump", |
||||||
|
"--format=custom", |
||||||
|
"--no-owner", |
||||||
|
"--no-acl", |
||||||
|
"--host", |
||||||
|
target.host, |
||||||
|
"--port", |
||||||
|
target.port, |
||||||
|
"--username", |
||||||
|
target.user, |
||||||
|
"--dbname", |
||||||
|
target.name, |
||||||
|
] |
||||||
|
|
||||||
|
|
||||||
|
def _pg_restore_argv(target, dump_path): |
||||||
|
return [ |
||||||
|
"pg_restore", |
||||||
|
"--exit-on-error", |
||||||
|
"--single-transaction", |
||||||
|
"--no-owner", |
||||||
|
"--no-acl", |
||||||
|
"--host", |
||||||
|
target.host, |
||||||
|
"--port", |
||||||
|
target.port, |
||||||
|
"--username", |
||||||
|
target.user, |
||||||
|
"--dbname", |
||||||
|
target.name, |
||||||
|
str(dump_path), |
||||||
|
] |
||||||
|
|
||||||
|
|
||||||
|
def _database_env(target): |
||||||
|
env = os.environ.copy() |
||||||
|
env["PGPASSWORD"] = target.password |
||||||
|
return env |
||||||
|
|
||||||
|
|
||||||
|
def _backup_root(): |
||||||
|
try: |
||||||
|
root = Path(settings.BACKUP_ROOT).resolve(strict=True) |
||||||
|
except (AttributeError, OSError, RuntimeError, TypeError): |
||||||
|
raise BackupError("invalid_root") from None |
||||||
|
if not root.is_dir(): |
||||||
|
raise BackupError("invalid_root") |
||||||
|
return root |
||||||
|
|
||||||
|
|
||||||
|
def _validate_name(name): |
||||||
|
if not isinstance(name, str) or not _PLAIN_DUMP_NAME.fullmatch(name): |
||||||
|
raise BackupError("invalid_name") |
||||||
|
|
||||||
|
|
||||||
|
def _ensure_available(*paths): |
||||||
|
if any(path.exists() or path.is_symlink() for path in paths): |
||||||
|
raise BackupError("existing_destination") |
||||||
|
|
||||||
|
|
||||||
|
def _publish_no_clobber(source, destination): |
||||||
|
try: |
||||||
|
os.link(source, destination) |
||||||
|
except FileExistsError: |
||||||
|
raise BackupError("existing_destination") from None |
||||||
|
except OSError: |
||||||
|
raise BackupError("publication_failed") from None |
||||||
|
|
||||||
|
|
||||||
|
def _remove_our_publications(publications): |
||||||
|
for destination, source in reversed(publications): |
||||||
|
try: |
||||||
|
if destination.exists() and os.path.samefile(destination, source): |
||||||
|
destination.unlink() |
||||||
|
except OSError: |
||||||
|
pass |
||||||
|
|
||||||
|
|
||||||
|
def _unlink_private(path): |
||||||
|
if path is not None: |
||||||
|
try: |
||||||
|
path.unlink(missing_ok=True) |
||||||
|
except OSError: |
||||||
|
pass |
||||||
|
|
||||||
|
|
||||||
|
def _fsync_directory(root): |
||||||
|
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) |
||||||
|
descriptor = os.open(root, flags) |
||||||
|
try: |
||||||
|
os.fsync(descriptor) |
||||||
|
finally: |
||||||
|
os.close(descriptor) |
||||||
|
|
||||||
|
|
||||||
|
def _file_digest(path): |
||||||
|
digest = sha256() |
||||||
|
size = 0 |
||||||
|
with path.open("rb") as stream: |
||||||
|
while chunk := stream.read(1024 * 1024): |
||||||
|
digest.update(chunk) |
||||||
|
size += len(chunk) |
||||||
|
return digest.hexdigest(), size |
||||||
|
|
||||||
|
|
||||||
|
def _safe_client_version(output): |
||||||
|
if isinstance(output, bytes): |
||||||
|
output = output.decode("ascii", errors="replace") |
||||||
|
version = str(output).strip() |
||||||
|
if not version.startswith("pg_dump (PostgreSQL) 17.") or len(version) > 80: |
||||||
|
raise BackupError("client_version_failed") |
||||||
|
return version |
||||||
|
|
||||||
|
|
||||||
|
def _load_manifest(path): |
||||||
|
try: |
||||||
|
if path.is_symlink(): |
||||||
|
raise BackupError("invalid_manifest") |
||||||
|
manifest = json.loads(path.read_text(encoding="utf-8")) |
||||||
|
except BackupError: |
||||||
|
raise |
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError): |
||||||
|
raise BackupError("invalid_manifest") from None |
||||||
|
if not isinstance(manifest, dict) or set(manifest) != _MANIFEST_KEYS: |
||||||
|
raise BackupError("invalid_manifest") |
||||||
|
if manifest["format"] != BACKUP_FORMAT or manifest["version"] != BACKUP_VERSION: |
||||||
|
raise BackupError("invalid_manifest") |
||||||
|
if ( |
||||||
|
not isinstance(manifest["application_version"], str) |
||||||
|
or not manifest["application_version"] |
||||||
|
or not isinstance(manifest["created_at"], str) |
||||||
|
or not isinstance(manifest["database_name"], str) |
||||||
|
or not manifest["database_name"] |
||||||
|
or not isinstance(manifest["postgresql_client_version"], str) |
||||||
|
or not manifest["postgresql_client_version"].startswith("pg_dump (PostgreSQL) 17.") |
||||||
|
or not isinstance(manifest["byte_size"], int) |
||||||
|
or manifest["byte_size"] < 0 |
||||||
|
or not isinstance(manifest["sha256"], str) |
||||||
|
or not re.fullmatch(r"[0-9a-f]{64}", manifest["sha256"]) |
||||||
|
or not isinstance(manifest["migrations"], list) |
||||||
|
or not isinstance(manifest["row_counts"], dict) |
||||||
|
): |
||||||
|
raise BackupError("invalid_manifest") |
||||||
|
try: |
||||||
|
created_at = datetime.fromisoformat(manifest["created_at"]) |
||||||
|
except ValueError: |
||||||
|
raise BackupError("invalid_manifest") from None |
||||||
|
if created_at.tzinfo is None: |
||||||
|
raise BackupError("invalid_manifest") |
||||||
|
migrations = manifest["migrations"] |
||||||
|
if any( |
||||||
|
not isinstance(item, list) |
||||||
|
or len(item) != 2 |
||||||
|
or not all(isinstance(value, str) and value for value in item) |
||||||
|
for item in migrations |
||||||
|
): |
||||||
|
raise BackupError("invalid_manifest") |
||||||
|
if any( |
||||||
|
not isinstance(table, str) |
||||||
|
or not _TABLE_NAME.fullmatch(table) |
||||||
|
or not isinstance(count, int) |
||||||
|
or count < 0 |
||||||
|
for table, count in manifest["row_counts"].items() |
||||||
|
): |
||||||
|
raise BackupError("invalid_manifest") |
||||||
|
return manifest |
||||||
|
|
||||||
|
|
||||||
|
def _read_snapshot(target): |
||||||
|
with psycopg.connect( |
||||||
|
host=target.host, |
||||||
|
port=target.port, |
||||||
|
dbname=target.name, |
||||||
|
user=target.user, |
||||||
|
password=target.password, |
||||||
|
connect_timeout=3, |
||||||
|
) as database: |
||||||
|
with database.cursor() as cursor: |
||||||
|
cursor.execute( |
||||||
|
""" |
||||||
|
SELECT table_name |
||||||
|
FROM information_schema.tables |
||||||
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE' |
||||||
|
ORDER BY table_name |
||||||
|
""" |
||||||
|
) |
||||||
|
tables = [row[0] for row in cursor.fetchall()] |
||||||
|
migrations = () |
||||||
|
if "django_migrations" in tables: |
||||||
|
cursor.execute( |
||||||
|
"SELECT app, name FROM django_migrations ORDER BY app, name" |
||||||
|
) |
||||||
|
migrations = tuple((row[0], row[1]) for row in cursor.fetchall()) |
||||||
|
row_counts = {} |
||||||
|
for table in tables: |
||||||
|
cursor.execute( |
||||||
|
sql.SQL("SELECT count(*) FROM {}").format(sql.Identifier(table)) |
||||||
|
) |
||||||
|
row_counts[table] = cursor.fetchone()[0] |
||||||
|
return DatabaseSnapshot(migrations=migrations, row_counts=row_counts) |
||||||
|
|
||||||
|
|
||||||
|
def _same_database(first, second): |
||||||
|
return ( |
||||||
|
first.host.strip().casefold(), |
||||||
|
first.name.strip().casefold(), |
||||||
|
) == ( |
||||||
|
second.host.strip().casefold(), |
||||||
|
second.name.strip().casefold(), |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def run_synthetic_restore_verification(): |
||||||
|
"""Exercise backup and restore only against disposable synthetic databases.""" |
||||||
|
from datetime import date |
||||||
|
from decimal import Decimal |
||||||
|
from uuid import uuid4 |
||||||
|
|
||||||
|
from django.core.management import call_command |
||||||
|
from django.db import connections |
||||||
|
|
||||||
|
from finance.models import Account, AuditEvent, Category, Payee, SourceRecord, Transaction |
||||||
|
from ingestion.models import ImportBatch, ImportItem |
||||||
|
|
||||||
|
if not getattr(settings, "BACKUP_VERIFY_ALLOWED", False): |
||||||
|
raise BackupError("verification_not_allowed") |
||||||
|
required = ( |
||||||
|
"BACKUP_SOURCE_HOST", |
||||||
|
"BACKUP_SOURCE_NAME", |
||||||
|
"BACKUP_SOURCE_USER", |
||||||
|
"BACKUP_SOURCE_PASSWORD", |
||||||
|
) |
||||||
|
if any(not os.environ.get(name) for name in required): |
||||||
|
raise BackupError("source_not_configured") |
||||||
|
|
||||||
|
restore_target = DatabaseTarget.from_default(settings.DATABASES["default"]) |
||||||
|
source_target = 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"], |
||||||
|
) |
||||||
|
live_target = DatabaseTarget( |
||||||
|
host=str(getattr(settings, "LIVE_DATABASE_HOST", None) or ""), |
||||||
|
port="5432", |
||||||
|
name=str(getattr(settings, "LIVE_DATABASE_NAME", None) or ""), |
||||||
|
user="", |
||||||
|
password="", |
||||||
|
) |
||||||
|
if _same_database(source_target, restore_target): |
||||||
|
raise BackupError("source_is_target") |
||||||
|
if live_target.host and ( |
||||||
|
_same_database(source_target, live_target) |
||||||
|
or _same_database(restore_target, live_target) |
||||||
|
): |
||||||
|
raise BackupError("live_target") |
||||||
|
if _read_snapshot(source_target).row_counts: |
||||||
|
raise BackupError("source_not_empty") |
||||||
|
restore_before = _read_snapshot(restore_target) |
||||||
|
if restore_before.migrations or restore_before.row_counts: |
||||||
|
raise BackupError("target_not_empty") |
||||||
|
|
||||||
|
default_connection = connections["default"] |
||||||
|
original_database = dict(default_connection.settings_dict) |
||||||
|
artifact = None |
||||||
|
try: |
||||||
|
default_connection.close() |
||||||
|
_configure_connection(default_connection, source_target) |
||||||
|
call_command("migrate", interactive=False, verbosity=0) |
||||||
|
|
||||||
|
account = Account.objects.create( |
||||||
|
name="Synthetic Restore Bank", kind=Account.Kind.BANK, currency="BRL" |
||||||
|
) |
||||||
|
payee = Payee.objects.create(name="Synthetic Restore Merchant") |
||||||
|
category = Category.objects.create( |
||||||
|
name="Synthetic Restore Expense", kind=Category.Kind.EXPENSE |
||||||
|
) |
||||||
|
transaction = Transaction.objects.create( |
||||||
|
account=account, |
||||||
|
amount=Decimal("-12.34"), |
||||||
|
posting_date=date(2026, 9, 8), |
||||||
|
description="Synthetic restore purchase", |
||||||
|
kind=Transaction.Kind.EXPENSE, |
||||||
|
payee=payee, |
||||||
|
category=category, |
||||||
|
) |
||||||
|
SourceRecord.objects.create( |
||||||
|
transaction=transaction, |
||||||
|
document_hash="a" * 64, |
||||||
|
source_format=SourceRecord.Format.BANK_CSV, |
||||||
|
parser_version="synthetic-v1", |
||||||
|
item_locator="row:1", |
||||||
|
) |
||||||
|
AuditEvent.objects.create( |
||||||
|
actor_kind=AuditEvent.ActorKind.AGENT, |
||||||
|
actor_identifier="synthetic-backup-verification", |
||||||
|
operation="synthetic_backup_verification", |
||||||
|
target_id=transaction.id, |
||||||
|
outcome=AuditEvent.Outcome.SUCCESS, |
||||||
|
) |
||||||
|
batch = ImportBatch.objects.create( |
||||||
|
document_hash="b" * 64, |
||||||
|
source_format="BANK_CSV", |
||||||
|
profile="SYNTHETIC", |
||||||
|
encoding="utf-8", |
||||||
|
actor_kind="AGENT", |
||||||
|
actor_identifier="synthetic-backup-verification", |
||||||
|
) |
||||||
|
ImportItem.objects.create( |
||||||
|
batch=batch, |
||||||
|
locator="row:1", |
||||||
|
candidate={"description": "Synthetic restore candidate"}, |
||||||
|
) |
||||||
|
before = _read_snapshot(source_target) |
||||||
|
artifact = create_backup( |
||||||
|
f"synthetic-restore-check-{uuid4().hex}.dump", _target=source_target |
||||||
|
) |
||||||
|
|
||||||
|
default_connection.close() |
||||||
|
default_connection.settings_dict.clear() |
||||||
|
default_connection.settings_dict.update(original_database) |
||||||
|
result = verify_backup(artifact.name, restore_target) |
||||||
|
call_command("check", databases=["default"], verbosity=0) |
||||||
|
if _read_snapshot(source_target) != before: |
||||||
|
raise BackupError("source_changed") |
||||||
|
return result |
||||||
|
except BackupError: |
||||||
|
raise |
||||||
|
except Exception: |
||||||
|
raise BackupError("integration_verification_failed") from None |
||||||
|
finally: |
||||||
|
default_connection.close() |
||||||
|
default_connection.settings_dict.clear() |
||||||
|
default_connection.settings_dict.update(original_database) |
||||||
|
if artifact is not None: |
||||||
|
artifact.path.unlink(missing_ok=True) |
||||||
|
artifact.manifest_path.unlink(missing_ok=True) |
||||||
|
_clear_disposable_database(source_target) |
||||||
|
_clear_disposable_database(restore_target) |
||||||
|
|
||||||
|
|
||||||
|
def _configure_connection(connection, target): |
||||||
|
connection.settings_dict.update( |
||||||
|
{ |
||||||
|
"HOST": target.host, |
||||||
|
"PORT": target.port, |
||||||
|
"NAME": target.name, |
||||||
|
"USER": target.user, |
||||||
|
"PASSWORD": target.password, |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def _clear_disposable_database(target): |
||||||
|
try: |
||||||
|
with psycopg.connect( |
||||||
|
host=target.host, |
||||||
|
port=target.port, |
||||||
|
dbname=target.name, |
||||||
|
user=target.user, |
||||||
|
password=target.password, |
||||||
|
connect_timeout=3, |
||||||
|
autocommit=True, |
||||||
|
) as database: |
||||||
|
with database.cursor() as cursor: |
||||||
|
cursor.execute("DROP SCHEMA public CASCADE") |
||||||
|
cursor.execute("CREATE SCHEMA public") |
||||||
|
except psycopg.Error: |
||||||
|
pass |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
from django.core.management.base import BaseCommand, CommandError |
||||||
|
|
||||||
|
from core.backup import BackupError, create_backup |
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand): |
||||||
|
help = "Create a local PostgreSQL backup and checksum manifest." |
||||||
|
|
||||||
|
def add_arguments(self, parser): |
||||||
|
parser.add_argument("--output", dest="output") |
||||||
|
|
||||||
|
def handle(self, *args, **options): |
||||||
|
try: |
||||||
|
result = create_backup(options["output"]) |
||||||
|
except BackupError as error: |
||||||
|
raise CommandError(error.code) from None |
||||||
|
self.stdout.write(f"backup={result.name} format={result.version}") |
||||||
|
for table, count in sorted(result.row_counts.items()): |
||||||
|
self.stdout.write(f"table={table} rows={count}") |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
from django.core.management.base import BaseCommand, CommandError |
||||||
|
|
||||||
|
from core.backup import BackupError, run_synthetic_restore_verification |
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand): |
||||||
|
help = "Run the synthetic backup and disposable restore acceptance check." |
||||||
|
|
||||||
|
def handle(self, *args, **options): |
||||||
|
try: |
||||||
|
result = run_synthetic_restore_verification() |
||||||
|
except BackupError as error: |
||||||
|
raise CommandError(error.code) from None |
||||||
|
self.stdout.write(f"backup={result.name} format={result.version} verified") |
||||||
|
for table, count in sorted(result.row_counts.items()): |
||||||
|
self.stdout.write(f"table={table} rows={count}") |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
from django.conf import settings |
||||||
|
from django.core.management.base import BaseCommand, CommandError |
||||||
|
|
||||||
|
from core.backup import BackupError, DatabaseTarget, verify_backup |
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand): |
||||||
|
help = "Restore a backup into an explicitly allowed disposable database and verify it." |
||||||
|
|
||||||
|
def add_arguments(self, parser): |
||||||
|
parser.add_argument("--input", dest="input", required=True) |
||||||
|
parser.add_argument("--target-name", required=True) |
||||||
|
parser.add_argument("--target-host", required=True) |
||||||
|
parser.add_argument("--target-user", required=True) |
||||||
|
|
||||||
|
def handle(self, *args, **options): |
||||||
|
default = settings.DATABASES["default"] |
||||||
|
target = DatabaseTarget( |
||||||
|
host=options["target_host"], |
||||||
|
port=str(default.get("PORT") or "5432"), |
||||||
|
name=options["target_name"], |
||||||
|
user=options["target_user"], |
||||||
|
password=str(default.get("PASSWORD", "")), |
||||||
|
) |
||||||
|
try: |
||||||
|
result = verify_backup(options["input"], target) |
||||||
|
except BackupError as error: |
||||||
|
raise CommandError(error.code) from None |
||||||
|
self.stdout.write(f"backup={result.name} format={result.version} verified") |
||||||
|
for table, count in sorted(result.row_counts.items()): |
||||||
|
self.stdout.write(f"table={table} rows={count}") |
||||||
@ -0,0 +1,260 @@ |
|||||||
|
import json |
||||||
|
from hashlib import sha256 |
||||||
|
from pathlib import Path |
||||||
|
from types import SimpleNamespace |
||||||
|
|
||||||
|
import pytest |
||||||
|
|
||||||
|
from core.backup import ( |
||||||
|
BACKUP_FORMAT, |
||||||
|
BACKUP_VERSION, |
||||||
|
BackupError, |
||||||
|
DatabaseSnapshot, |
||||||
|
DatabaseTarget, |
||||||
|
create_backup, |
||||||
|
verify_backup, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
class RecordingRunner: |
||||||
|
def __init__(self, output=b"PGDMP synthetic"): |
||||||
|
self.calls = [] |
||||||
|
self.output = output |
||||||
|
|
||||||
|
def __call__(self, argv, *, env, check, stdout=None, capture_output=False): |
||||||
|
self.calls.append((list(argv), dict(env), check, stdout, capture_output)) |
||||||
|
if stdout is not None: |
||||||
|
stdout.write(self.output) |
||||||
|
return SimpleNamespace(stdout=b"pg_dump (PostgreSQL) 17.6\n") |
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture |
||||||
|
def database_settings(settings): |
||||||
|
settings.DATABASES["default"] = { |
||||||
|
"ENGINE": "django.db.backends.postgresql", |
||||||
|
"HOST": "live-db", |
||||||
|
"PORT": "5432", |
||||||
|
"NAME": "gizmoduck", |
||||||
|
"USER": "gizmoduck_migrate", |
||||||
|
"PASSWORD": "synthetic-secret", |
||||||
|
} |
||||||
|
return settings.DATABASES["default"] |
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture |
||||||
|
def backup_root(tmp_path, settings): |
||||||
|
settings.BACKUP_ROOT = tmp_path |
||||||
|
return tmp_path |
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture |
||||||
|
def synthetic_snapshot(monkeypatch): |
||||||
|
snapshot = DatabaseSnapshot( |
||||||
|
migrations=(("finance", "0001_initial"), ("ingestion", "0001_initial")), |
||||||
|
row_counts={"finance_account": 2, "ingestion_importbatch": 1}, |
||||||
|
) |
||||||
|
monkeypatch.setattr("core.backup._read_snapshot", lambda _target: snapshot) |
||||||
|
return snapshot |
||||||
|
|
||||||
|
|
||||||
|
def test_pg_dump_receives_password_only_in_environment( |
||||||
|
backup_root, database_settings, synthetic_snapshot |
||||||
|
): |
||||||
|
runner = RecordingRunner() |
||||||
|
|
||||||
|
result = create_backup("synthetic.dump", runner=runner) |
||||||
|
|
||||||
|
argv, env, check, stdout, capture_output = runner.calls[0] |
||||||
|
assert argv[:4] == ["pg_dump", "--format=custom", "--no-owner", "--no-acl"] |
||||||
|
assert database_settings["PASSWORD"] not in argv |
||||||
|
assert env["PGPASSWORD"] == database_settings["PASSWORD"] |
||||||
|
assert check is True |
||||||
|
assert stdout is not None |
||||||
|
assert capture_output is False |
||||||
|
assert result.path.read_bytes() == runner.output |
||||||
|
|
||||||
|
|
||||||
|
def test_backup_writes_versioned_checksum_manifest( |
||||||
|
backup_root, database_settings, synthetic_snapshot |
||||||
|
): |
||||||
|
runner = RecordingRunner() |
||||||
|
|
||||||
|
result = create_backup("synthetic.dump", runner=runner) |
||||||
|
|
||||||
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) |
||||||
|
assert manifest == { |
||||||
|
"application_version": "0.1.0", |
||||||
|
"byte_size": len(runner.output), |
||||||
|
"created_at": result.created_at.isoformat(), |
||||||
|
"database_name": "gizmoduck", |
||||||
|
"format": BACKUP_FORMAT, |
||||||
|
"migrations": [ |
||||||
|
["finance", "0001_initial"], |
||||||
|
["ingestion", "0001_initial"], |
||||||
|
], |
||||||
|
"postgresql_client_version": result.postgresql_client_version, |
||||||
|
"row_counts": { |
||||||
|
"finance_account": 2, |
||||||
|
"ingestion_importbatch": 1, |
||||||
|
}, |
||||||
|
"sha256": sha256(runner.output).hexdigest(), |
||||||
|
"version": BACKUP_VERSION, |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize( |
||||||
|
"name", |
||||||
|
["../outside.dump", "nested/file.dump", "nested\\file.dump", ".dump", "file.zip"], |
||||||
|
) |
||||||
|
def test_backup_rejects_non_plain_dump_names( |
||||||
|
backup_root, database_settings, synthetic_snapshot, name |
||||||
|
): |
||||||
|
with pytest.raises(BackupError, match="invalid_name"): |
||||||
|
create_backup(name, runner=RecordingRunner()) |
||||||
|
|
||||||
|
assert list(backup_root.iterdir()) == [] |
||||||
|
|
||||||
|
|
||||||
|
def test_backup_does_not_replace_existing_artifacts( |
||||||
|
backup_root, database_settings, synthetic_snapshot |
||||||
|
): |
||||||
|
dump = backup_root / "synthetic.dump" |
||||||
|
manifest = backup_root / "synthetic.dump.json" |
||||||
|
dump.write_bytes(b"existing dump") |
||||||
|
manifest.write_text("existing manifest", encoding="utf-8") |
||||||
|
|
||||||
|
with pytest.raises(BackupError, match="existing_destination"): |
||||||
|
create_backup("synthetic.dump", runner=RecordingRunner()) |
||||||
|
|
||||||
|
assert dump.read_bytes() == b"existing dump" |
||||||
|
assert manifest.read_text(encoding="utf-8") == "existing manifest" |
||||||
|
|
||||||
|
|
||||||
|
def test_backup_cleans_private_temporary_files_on_dump_failure( |
||||||
|
backup_root, database_settings, synthetic_snapshot |
||||||
|
): |
||||||
|
def failing_runner(argv, **kwargs): |
||||||
|
raise OSError("synthetic private detail") |
||||||
|
|
||||||
|
with pytest.raises(BackupError, match="backup_failed"): |
||||||
|
create_backup("synthetic.dump", runner=failing_runner) |
||||||
|
|
||||||
|
assert list(backup_root.iterdir()) == [] |
||||||
|
|
||||||
|
|
||||||
|
def test_verification_rejects_live_database( |
||||||
|
backup_root, database_settings, synthetic_snapshot |
||||||
|
): |
||||||
|
target = DatabaseTarget.from_default(database_settings) |
||||||
|
|
||||||
|
with pytest.raises(BackupError, match="live_target"): |
||||||
|
verify_backup("synthetic.dump", target, runner=RecordingRunner()) |
||||||
|
|
||||||
|
|
||||||
|
def test_verification_requires_explicit_allow_flag( |
||||||
|
backup_root, database_settings, synthetic_snapshot, settings |
||||||
|
): |
||||||
|
settings.BACKUP_VERIFY_ALLOWED = False |
||||||
|
target = DatabaseTarget( |
||||||
|
host="restore-db", |
||||||
|
port="5432", |
||||||
|
name="gizmoduck_restore", |
||||||
|
user="restore_user", |
||||||
|
password="restore-secret", |
||||||
|
) |
||||||
|
|
||||||
|
with pytest.raises(BackupError, match="verification_not_allowed"): |
||||||
|
verify_backup("synthetic.dump", target, runner=RecordingRunner()) |
||||||
|
|
||||||
|
|
||||||
|
def test_verification_rejects_changed_dump_before_restore( |
||||||
|
backup_root, database_settings, synthetic_snapshot, settings |
||||||
|
): |
||||||
|
settings.BACKUP_VERIFY_ALLOWED = True |
||||||
|
result = create_backup("synthetic.dump", runner=RecordingRunner()) |
||||||
|
result.path.write_bytes(b"PGDMP tampered!") |
||||||
|
restore_runner = RecordingRunner() |
||||||
|
target = DatabaseTarget( |
||||||
|
host="restore-db", |
||||||
|
port="5432", |
||||||
|
name="gizmoduck_restore", |
||||||
|
user="restore_user", |
||||||
|
password="restore-secret", |
||||||
|
) |
||||||
|
|
||||||
|
with pytest.raises(BackupError, match="digest_mismatch"): |
||||||
|
verify_backup(result.name, target, runner=restore_runner) |
||||||
|
|
||||||
|
assert restore_runner.calls == [] |
||||||
|
|
||||||
|
|
||||||
|
def test_verification_rejects_manifest_with_wrong_field_type( |
||||||
|
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["application_version"] = 1 |
||||||
|
result.manifest_path.write_text(json.dumps(manifest), encoding="utf-8") |
||||||
|
target = DatabaseTarget( |
||||||
|
host="restore-db", |
||||||
|
port="5432", |
||||||
|
name="gizmoduck_restore", |
||||||
|
user="restore_user", |
||||||
|
password="restore-secret", |
||||||
|
) |
||||||
|
|
||||||
|
with pytest.raises(BackupError, match="invalid_manifest"): |
||||||
|
verify_backup(result.name, target, runner=RecordingRunner()) |
||||||
|
|
||||||
|
|
||||||
|
def test_backup_rejects_nonmatching_postgresql_client_major( |
||||||
|
backup_root, database_settings, synthetic_snapshot |
||||||
|
): |
||||||
|
class WrongVersionRunner(RecordingRunner): |
||||||
|
def __call__(self, argv, **kwargs): |
||||||
|
result = super().__call__(argv, **kwargs) |
||||||
|
if argv == ["pg_dump", "--version"]: |
||||||
|
result.stdout = b"pg_dump (PostgreSQL) 16.9\n" |
||||||
|
return result |
||||||
|
|
||||||
|
with pytest.raises(BackupError, match="client_version_failed"): |
||||||
|
create_backup("synthetic.dump", runner=WrongVersionRunner()) |
||||||
|
|
||||||
|
assert list(backup_root.iterdir()) == [] |
||||||
|
|
||||||
|
|
||||||
|
def test_pg_restore_receives_password_only_in_environment( |
||||||
|
backup_root, database_settings, synthetic_snapshot, settings, monkeypatch |
||||||
|
): |
||||||
|
settings.BACKUP_VERIFY_ALLOWED = True |
||||||
|
result = create_backup("synthetic.dump", runner=RecordingRunner()) |
||||||
|
snapshots = iter( |
||||||
|
[DatabaseSnapshot(migrations=(), row_counts={}), synthetic_snapshot] |
||||||
|
) |
||||||
|
monkeypatch.setattr("core.backup._read_snapshot", lambda _target: next(snapshots)) |
||||||
|
runner = RecordingRunner() |
||||||
|
target = DatabaseTarget( |
||||||
|
host="restore-db", |
||||||
|
port="5432", |
||||||
|
name="gizmoduck_restore", |
||||||
|
user="restore_user", |
||||||
|
password="synthetic-restore-secret", |
||||||
|
) |
||||||
|
|
||||||
|
verification = verify_backup(result.name, target, runner=runner) |
||||||
|
|
||||||
|
argv, env, check, stdout, capture_output = runner.calls[0] |
||||||
|
assert argv[:5] == [ |
||||||
|
"pg_restore", |
||||||
|
"--exit-on-error", |
||||||
|
"--single-transaction", |
||||||
|
"--no-owner", |
||||||
|
"--no-acl", |
||||||
|
] |
||||||
|
assert target.password not in argv |
||||||
|
assert env["PGPASSWORD"] == target.password |
||||||
|
assert check is True |
||||||
|
assert stdout is None |
||||||
|
assert capture_output is True |
||||||
|
assert verification.row_counts == synthetic_snapshot.row_counts |
||||||
@ -0,0 +1,135 @@ |
|||||||
|
from datetime import datetime, timezone |
||||||
|
from io import StringIO |
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
import pytest |
||||||
|
from django.core.management import call_command |
||||||
|
from django.core.management.base import CommandError |
||||||
|
|
||||||
|
from core.backup import BackupError, BackupResult, VerificationResult |
||||||
|
|
||||||
|
|
||||||
|
def backup_result(): |
||||||
|
return BackupResult( |
||||||
|
name="synthetic.dump", |
||||||
|
path=Path("/private/backup/synthetic.dump"), |
||||||
|
manifest_path=Path("/private/backup/synthetic.dump.json"), |
||||||
|
version=1, |
||||||
|
created_at=datetime(2026, 9, 8, tzinfo=timezone.utc), |
||||||
|
postgresql_client_version="pg_dump (PostgreSQL) 17.6", |
||||||
|
row_counts={"finance_account": 2, "finance_transaction": 4}, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def test_backup_database_prints_only_safe_summary(monkeypatch): |
||||||
|
monkeypatch.setattr( |
||||||
|
"core.management.commands.backup_database.create_backup", |
||||||
|
lambda name: backup_result(), |
||||||
|
) |
||||||
|
stdout = StringIO() |
||||||
|
|
||||||
|
call_command("backup_database", output="synthetic.dump", stdout=stdout) |
||||||
|
|
||||||
|
assert stdout.getvalue().splitlines() == [ |
||||||
|
"backup=synthetic.dump format=1", |
||||||
|
"table=finance_account rows=2", |
||||||
|
"table=finance_transaction rows=4", |
||||||
|
] |
||||||
|
assert "/private/backup" not in stdout.getvalue() |
||||||
|
|
||||||
|
|
||||||
|
def test_backup_database_converts_service_error_to_safe_code(monkeypatch): |
||||||
|
def fail(_name): |
||||||
|
raise BackupError("backup_failed") |
||||||
|
|
||||||
|
monkeypatch.setattr("core.management.commands.backup_database.create_backup", fail) |
||||||
|
|
||||||
|
with pytest.raises(CommandError, match=r"^backup_failed$"): |
||||||
|
call_command("backup_database", output="synthetic.dump") |
||||||
|
|
||||||
|
|
||||||
|
def test_verify_backup_uses_configured_password_and_prints_safe_summary( |
||||||
|
monkeypatch, settings |
||||||
|
): |
||||||
|
settings.DATABASES["default"] = { |
||||||
|
"HOST": "restore-db", |
||||||
|
"PORT": "5432", |
||||||
|
"NAME": "gizmoduck_restore", |
||||||
|
"USER": "restore_user", |
||||||
|
"PASSWORD": "synthetic-restore-secret", |
||||||
|
} |
||||||
|
captured = {} |
||||||
|
|
||||||
|
def verify(name, target): |
||||||
|
captured["name"] = name |
||||||
|
captured["target"] = target |
||||||
|
return VerificationResult( |
||||||
|
name=name, |
||||||
|
version=1, |
||||||
|
row_counts={"django_migrations": 12, "finance_account": 2}, |
||||||
|
) |
||||||
|
|
||||||
|
monkeypatch.setattr("core.management.commands.verify_backup.verify_backup", verify) |
||||||
|
stdout = StringIO() |
||||||
|
|
||||||
|
call_command( |
||||||
|
"verify_backup", |
||||||
|
input="synthetic.dump", |
||||||
|
target_name="gizmoduck_restore", |
||||||
|
target_host="restore-db", |
||||||
|
target_user="restore_user", |
||||||
|
stdout=stdout, |
||||||
|
) |
||||||
|
|
||||||
|
assert captured["name"] == "synthetic.dump" |
||||||
|
assert captured["target"].password == "synthetic-restore-secret" |
||||||
|
assert stdout.getvalue().splitlines() == [ |
||||||
|
"backup=synthetic.dump format=1 verified", |
||||||
|
"table=django_migrations rows=12", |
||||||
|
"table=finance_account rows=2", |
||||||
|
] |
||||||
|
assert "synthetic-restore-secret" not in stdout.getvalue() |
||||||
|
|
||||||
|
|
||||||
|
def test_verify_backup_converts_service_error_to_safe_code(monkeypatch, settings): |
||||||
|
settings.DATABASES["default"] = { |
||||||
|
"HOST": "restore-db", |
||||||
|
"PORT": "5432", |
||||||
|
"NAME": "gizmoduck_restore", |
||||||
|
"USER": "restore_user", |
||||||
|
"PASSWORD": "synthetic-restore-secret", |
||||||
|
} |
||||||
|
|
||||||
|
def fail(_name, _target): |
||||||
|
raise BackupError("target_not_empty") |
||||||
|
|
||||||
|
monkeypatch.setattr("core.management.commands.verify_backup.verify_backup", fail) |
||||||
|
|
||||||
|
with pytest.raises(CommandError, match=r"^target_not_empty$"): |
||||||
|
call_command( |
||||||
|
"verify_backup", |
||||||
|
input="synthetic.dump", |
||||||
|
target_name="gizmoduck_restore", |
||||||
|
target_host="restore-db", |
||||||
|
target_user="restore_user", |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def test_backup_restore_acceptance_command_prints_only_safe_summary(monkeypatch): |
||||||
|
monkeypatch.setattr( |
||||||
|
"core.management.commands.test_backup_restore.run_synthetic_restore_verification", |
||||||
|
lambda: VerificationResult( |
||||||
|
name="synthetic-restore-check.dump", |
||||||
|
version=1, |
||||||
|
row_counts={"finance_account": 1, "finance_transaction": 1}, |
||||||
|
), |
||||||
|
) |
||||||
|
stdout = StringIO() |
||||||
|
|
||||||
|
call_command("test_backup_restore", stdout=stdout) |
||||||
|
|
||||||
|
assert stdout.getvalue().splitlines() == [ |
||||||
|
"backup=synthetic-restore-check.dump format=1 verified", |
||||||
|
"table=finance_account rows=1", |
||||||
|
"table=finance_transaction rows=1", |
||||||
|
] |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
import subprocess |
||||||
|
|
||||||
|
|
||||||
|
def test_backup_configuration_is_allow_gated(settings): |
||||||
|
assert settings.BACKUP_ROOT == "/app/backups" |
||||||
|
assert settings.BACKUP_VERIFY_ALLOWED is False |
||||||
|
|
||||||
|
|
||||||
|
def test_application_image_installs_matching_postgresql_client(): |
||||||
|
result = subprocess.run( |
||||||
|
["pg_dump", "--version"], check=True, capture_output=True, text=True |
||||||
|
) |
||||||
|
|
||||||
|
assert result.stdout.startswith("pg_dump (PostgreSQL) 17.") |
||||||
Loading…
Reference in new issue