You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

699 lines
22 KiB

import json
import os
import re
import subprocess
import tempfile
from contextlib import contextmanager
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_]*(?:\.[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"
target = _target or DatabaseTarget.from_default(settings.DATABASES["default"])
dump_path = None
manifest_path = None
try:
_ensure_available(destination, manifest_destination)
dump_fd, dump_name = tempfile.mkstemp(
prefix=".gizmoduck-backup-", suffix=".dump.tmp", dir=root
)
dump_path = Path(dump_name)
os.close(dump_fd)
with _exported_snapshot(target) as (snapshot_id, snapshot):
runner(
_pg_dump_argv(target, dump_path, snapshot_id),
env=_database_env(target),
check=True,
stdout=None,
capture_output=True,
)
with dump_path.open("rb") as stream:
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_pair(
root,
((manifest_path, manifest_destination), (dump_path, destination)),
)
manifest_path = None
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:
raise
except Exception:
raise BackupError("backup_failed") from None
finally:
cleanup_failed = False
for path in (manifest_path, dump_path):
if not _unlink_private(path):
cleanup_failed = True
if cleanup_failed:
raise BackupError("cleanup_failed") from None
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")
try:
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")
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, dump_path, snapshot_id):
return [
"pg_dump",
"--format=custom",
"--no-owner",
"--no-acl",
f"--snapshot={snapshot_id}",
f"--file={dump_path}",
"--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):
try:
if any(path.exists() or path.is_symlink() for path in paths):
raise BackupError("existing_destination")
except BackupError:
raise
except Exception:
raise BackupError("publication_failed") from None
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 _publish_pair(root, pairs):
publications = []
try:
for source, destination in pairs:
_publish_no_clobber(source, destination)
publications.append((destination, source))
_fsync_directory(root)
except Exception:
_remove_owned_publications(publications)
for source, _destination in pairs:
_unlink_private(source)
raise BackupError("publication_failed") from None
cleanup_failed = False
for source, _destination in pairs:
if not _unlink_private(source):
cleanup_failed = True
if cleanup_failed:
raise BackupError("cleanup_failed")
def _remove_owned_publications(publications):
for destination, source in reversed(publications):
try:
if source.exists() and destination.exists() and os.path.samefile(
destination, source
):
destination.unlink()
except OSError:
pass
def _unlink_private(path):
if path is None:
return True
try:
path.unlink(missing_ok=True)
return True
except OSError:
return False
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 _is_pg17_version(version):
raise BackupError("client_version_failed")
return version
def _is_pg17_version(version):
return (
isinstance(version, str)
and version.startswith("pg_dump (PostgreSQL) 17.")
and len(version) <= 80
)
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 type(manifest["version"]) is not int
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 _is_pg17_version(manifest["postgresql_client_version"])
or type(manifest["byte_size"]) is not 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 type(count) is not int
or count < 0
for table, count in manifest["row_counts"].items()
):
raise BackupError("invalid_manifest")
return manifest
@contextmanager
def _exported_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(
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
)
cursor.execute("SELECT pg_export_snapshot()")
snapshot_id = cursor.fetchone()[0]
snapshot = _read_snapshot_cursor(cursor)
yield snapshot_id, snapshot
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:
return _read_snapshot_cursor(cursor)
def _read_snapshot_cursor(cursor):
cursor.execute(
"""
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_type = 'BASE TABLE'
AND table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
AND table_schema NOT LIKE 'pg\\_%' ESCAPE '\\'
ORDER BY table_schema, table_name
"""
)
tables = [(row[0], row[1]) for row in cursor.fetchall()]
migrations = ()
if ("public", "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 schema, table in tables:
cursor.execute(
sql.SQL("SELECT count(*) FROM {}.{}").format(
sql.Identifier(schema), sql.Identifier(table)
)
)
key = table if schema == "public" else f"{schema}.{table}"
row_counts[key] = 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")
try:
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")
except BackupError:
raise
except Exception:
raise BackupError("integration_verification_failed") from None
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:
cleanup_failed = False
try:
default_connection.close()
default_connection.settings_dict.clear()
default_connection.settings_dict.update(original_database)
except Exception:
cleanup_failed = True
try:
_cleanup_synthetic_resources(artifact, source_target, restore_target)
except BackupError:
cleanup_failed = True
if cleanup_failed:
raise BackupError("cleanup_failed") from None
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("SET lock_timeout = '5s'")
cursor.execute("SET statement_timeout = '15s'")
cursor.execute("DROP SCHEMA public CASCADE")
cursor.execute("CREATE SCHEMA public")
except Exception:
raise BackupError("cleanup_failed") from None
def _cleanup_synthetic_resources(artifact, source_target, restore_target):
failed = False
if artifact is not None:
for path in (artifact.path, artifact.manifest_path):
try:
path.unlink(missing_ok=True)
except Exception:
failed = True
for target in (source_target, restore_target):
try:
_clear_disposable_database(target)
except BackupError:
failed = True
if failed:
raise BackupError("cleanup_failed")