"""pg_dump + tar.zst-based backup, restorable via pair of subprocess calls. Backups live under /_backups/. Each backup is two files (SQL + tarball) plus a manifest JSON. Tagged backups (e.g. tag='pre_migration') are how rollback.py finds the most recent restorable snapshot. """ from __future__ import annotations import json import shutil import subprocess from datetime import UTC, datetime from pathlib import Path from typing import Any _BACKUPS_DIRNAME = "_backups" def _backups_dir(images_root: Path | None = None) -> Path: # Overridable for tests via monkeypatch. root = images_root if images_root is not None else Path("/images") p = root / _BACKUPS_DIRNAME p.mkdir(parents=True, exist_ok=True) return p def _run_subprocess(cmd: list[str], **kwargs: Any): # Overridable for tests via monkeypatch. return subprocess.run( cmd, capture_output=True, check=True, **{k: v for k, v in kwargs.items() if not k.startswith("_")}, ) def create_backup( *, db_url: str, images_root: Path, tag: str = "manual", ) -> dict: """Create a backup: pg_dump SQL + tar.zst of images. Returns a manifest dict. Writes .sql, .tar.zst, .json into /_backups/. """ ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") out_dir = _backups_dir(images_root) sql_path = out_dir / f"fc_{ts}.sql" tar_path = out_dir / f"fc_{ts}.tar.zst" manifest_path = out_dir / f"fc_{ts}.json" _run_subprocess( ["pg_dump", "--no-owner", "--no-acl", "-f", str(sql_path), db_url], _test_ts=ts, ) _run_subprocess( [ "tar", "--zstd", "-cf", str(tar_path), "-C", str(images_root.parent), images_root.name, f"--exclude={images_root.name}/_backups", f"--exclude={images_root.name}/_quarantine", ], _test_ts=ts, ) manifest = { "backup_id": ts, "tag": tag, "created_at": datetime.now(UTC).isoformat(), "sql_path": str(sql_path), "tar_path": str(tar_path), } manifest_path.write_text(json.dumps(manifest, indent=2)) return manifest def list_backups(images_root: Path) -> list[dict]: out_dir = _backups_dir(images_root) items = [] for mf in sorted(out_dir.glob("fc_*.json"), reverse=True): try: items.append(json.loads(mf.read_text())) except Exception: continue return items def find_latest_backup(images_root: Path, *, tag: str) -> dict | None: for mf in list_backups(images_root): if mf.get("tag") == tag: return mf return None def restore_backup( *, manifest: dict, db_url: str, images_root: Path, ) -> dict: """Restore from a backup manifest. 1. Replay the .sql via psql. 2. Wipe /images/ contents (except _backups/, which holds the file we're using). 3. Untar the .tar.zst into /images/. """ sql_path = Path(manifest["sql_path"]) tar_path = Path(manifest["tar_path"]) _run_subprocess( ["psql", "-d", db_url, "-f", str(sql_path)], ) # Wipe everything in images_root EXCEPT _backups/ (we'd delete the backup # we're restoring from!). for entry in images_root.iterdir(): if entry.name == _BACKUPS_DIRNAME: continue if entry.is_dir(): shutil.rmtree(entry) else: entry.unlink() _run_subprocess( ["tar", "--zstd", "-xf", str(tar_path), "-C", str(images_root.parent)], ) return {"restored_from": manifest["backup_id"]}