|
|
|
|
@ -3,6 +3,7 @@ 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 |
|
|
|
|
@ -18,7 +19,7 @@ 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") |
|
|
|
|
_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", |
|
|
|
|
@ -94,27 +95,25 @@ def create_backup(name=None, *, runner=None, _target=None):
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
_ensure_available(destination, manifest_destination) |
|
|
|
|
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: |
|
|
|
|
os.close(dump_fd) |
|
|
|
|
with _exported_snapshot(target) as (snapshot_id, snapshot): |
|
|
|
|
runner( |
|
|
|
|
_pg_dump_argv(target), |
|
|
|
|
_pg_dump_argv(target, dump_path, snapshot_id), |
|
|
|
|
env=_database_env(target), |
|
|
|
|
check=True, |
|
|
|
|
stdout=stream, |
|
|
|
|
capture_output=False, |
|
|
|
|
stdout=None, |
|
|
|
|
capture_output=True, |
|
|
|
|
) |
|
|
|
|
stream.flush() |
|
|
|
|
with dump_path.open("rb") as stream: |
|
|
|
|
os.fsync(stream.fileno()) |
|
|
|
|
|
|
|
|
|
content_digest, byte_size = _file_digest(dump_path) |
|
|
|
|
@ -148,14 +147,11 @@ def create_backup(name=None, *, runner=None, _target=None):
|
|
|
|
|
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() |
|
|
|
|
_publish_pair( |
|
|
|
|
root, |
|
|
|
|
((manifest_path, manifest_destination), (dump_path, destination)), |
|
|
|
|
) |
|
|
|
|
manifest_path = None |
|
|
|
|
dump_path.unlink() |
|
|
|
|
dump_path = None |
|
|
|
|
return BackupResult( |
|
|
|
|
name=name, |
|
|
|
|
@ -167,14 +163,16 @@ def create_backup(name=None, *, runner=None, _target=None):
|
|
|
|
|
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) |
|
|
|
|
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): |
|
|
|
|
@ -192,6 +190,7 @@ def verify_backup(name, target, *, runner=None):
|
|
|
|
|
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" |
|
|
|
|
@ -203,8 +202,6 @@ def verify_backup(name, target, *, runner=None):
|
|
|
|
|
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") |
|
|
|
|
@ -229,12 +226,14 @@ def verify_backup(name, target, *, runner=None):
|
|
|
|
|
return VerificationResult(name=name, version=BACKUP_VERSION, row_counts=after.row_counts) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pg_dump_argv(target): |
|
|
|
|
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", |
|
|
|
|
@ -287,8 +286,13 @@ def _validate_name(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): |
|
|
|
|
@ -300,6 +304,48 @@ def _publish_no_clobber(source, destination):
|
|
|
|
|
raise BackupError("publication_failed") from None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _publish_pair(root, pairs): |
|
|
|
|
publications = [] |
|
|
|
|
guards = [] |
|
|
|
|
try: |
|
|
|
|
for source, destination in pairs: |
|
|
|
|
_publish_no_clobber(source, destination) |
|
|
|
|
publications.append((destination, source)) |
|
|
|
|
_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: |
|
|
|
|
ownership_sources = [*guards, *(source for source, _destination in pairs if source.exists())] |
|
|
|
|
_remove_owned_publications(publications, ownership_sources) |
|
|
|
|
for source, _destination in pairs: |
|
|
|
|
_unlink_private(source) |
|
|
|
|
for guard in guards: |
|
|
|
|
_unlink_private(guard) |
|
|
|
|
raise BackupError("publication_failed") from None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _remove_owned_publications(publications, owned_sources): |
|
|
|
|
for destination, original_source in reversed(publications): |
|
|
|
|
sources = [source for source in owned_sources if source.exists()] |
|
|
|
|
if original_source.exists(): |
|
|
|
|
sources.append(original_source) |
|
|
|
|
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): |
|
|
|
|
for destination, source in reversed(publications): |
|
|
|
|
try: |
|
|
|
|
@ -310,11 +356,13 @@ def _remove_our_publications(publications):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _unlink_private(path): |
|
|
|
|
if path is not None: |
|
|
|
|
if path is None: |
|
|
|
|
return True |
|
|
|
|
try: |
|
|
|
|
path.unlink(missing_ok=True) |
|
|
|
|
return True |
|
|
|
|
except OSError: |
|
|
|
|
pass |
|
|
|
|
return False |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fsync_directory(root): |
|
|
|
|
@ -340,11 +388,19 @@ 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: |
|
|
|
|
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(): |
|
|
|
|
@ -364,9 +420,8 @@ def _load_manifest(path):
|
|
|
|
|
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 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"]) |
|
|
|
|
@ -391,7 +446,7 @@ def _load_manifest(path):
|
|
|
|
|
if any( |
|
|
|
|
not isinstance(table, str) |
|
|
|
|
or not _TABLE_NAME.fullmatch(table) |
|
|
|
|
or not isinstance(count, int) |
|
|
|
|
or type(count) is not int |
|
|
|
|
or count < 0 |
|
|
|
|
for table, count in manifest["row_counts"].items() |
|
|
|
|
): |
|
|
|
|
@ -399,6 +454,26 @@ def _load_manifest(path):
|
|
|
|
|
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, |
|
|
|
|
@ -409,27 +484,34 @@ def _read_snapshot(target):
|
|
|
|
|
connect_timeout=3, |
|
|
|
|
) as database: |
|
|
|
|
with database.cursor() as cursor: |
|
|
|
|
return _read_snapshot_cursor(cursor) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_snapshot_cursor(cursor): |
|
|
|
|
cursor.execute( |
|
|
|
|
""" |
|
|
|
|
SELECT table_name |
|
|
|
|
SELECT table_schema, table_name |
|
|
|
|
FROM information_schema.tables |
|
|
|
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE' |
|
|
|
|
ORDER BY table_name |
|
|
|
|
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] for row in cursor.fetchall()] |
|
|
|
|
tables = [(row[0], row[1]) for row in cursor.fetchall()] |
|
|
|
|
migrations = () |
|
|
|
|
if "django_migrations" in tables: |
|
|
|
|
cursor.execute( |
|
|
|
|
"SELECT app, name FROM django_migrations ORDER BY app, name" |
|
|
|
|
) |
|
|
|
|
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 table in tables: |
|
|
|
|
for schema, table in tables: |
|
|
|
|
cursor.execute( |
|
|
|
|
sql.SQL("SELECT count(*) FROM {}").format(sql.Identifier(table)) |
|
|
|
|
sql.SQL("SELECT count(*) FROM {}.{}").format( |
|
|
|
|
sql.Identifier(schema), sql.Identifier(table) |
|
|
|
|
) |
|
|
|
|
) |
|
|
|
|
row_counts[table] = cursor.fetchone()[0] |
|
|
|
|
key = table if schema == "public" else f"{schema}.{table}" |
|
|
|
|
row_counts[key] = cursor.fetchone()[0] |
|
|
|
|
return DatabaseSnapshot(migrations=migrations, row_counts=row_counts) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -563,14 +645,19 @@ def run_synthetic_restore_verification():
|
|
|
|
|
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) |
|
|
|
|
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) |
|
|
|
|
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): |
|
|
|
|
@ -599,5 +686,22 @@ def _clear_disposable_database(target):
|
|
|
|
|
with database.cursor() as cursor: |
|
|
|
|
cursor.execute("DROP SCHEMA public CASCADE") |
|
|
|
|
cursor.execute("CREATE SCHEMA public") |
|
|
|
|
except psycopg.Error: |
|
|
|
|
pass |
|
|
|
|
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") |
|
|
|
|
|