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.
 
 
 
 
 

603 lines
19 KiB

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