From 70e1e010d1be34e7850840d44c7ac08b6352292f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:59:18 -0400 Subject: [PATCH] fc3h: remove backend/app/services/migrators/backup.py + rollback.py (relocated to backup_service.py) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/migrators/backup.py | 146 --------------------- backend/app/services/migrators/rollback.py | 21 --- 2 files changed, 167 deletions(-) delete mode 100644 backend/app/services/migrators/backup.py delete mode 100644 backend/app/services/migrators/rollback.py diff --git a/backend/app/services/migrators/backup.py b/backend/app/services/migrators/backup.py deleted file mode 100644 index d0bbf72..0000000 --- a/backend/app/services/migrators/backup.py +++ /dev/null @@ -1,146 +0,0 @@ -"""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 _libpq_url(sa_url: str) -> str: - """Strip SQLAlchemy driver suffix so pg_dump/psql accept the URL. - - SQLAlchemy uses URLs like `postgresql+psycopg://...` or - `postgresql+asyncpg://...`. libpq tools (pg_dump, psql) only know - the plain `postgresql://` scheme. - """ - for driver in ("postgresql+psycopg", "postgresql+asyncpg", "postgresql+psycopg2"): - if sa_url.startswith(driver + "://"): - return "postgresql://" + sa_url[len(driver) + 3:] - return sa_url - - -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 - - -_DEFAULT_SUBPROCESS_TIMEOUT_S = 30 * 60 # 30 minutes - - -def _run_subprocess(cmd: list[str], **kwargs: Any): - # Overridable for tests via monkeypatch. Hard wall-clock timeout - # guards against pg_dump / tar / zstd hangs on NFS — without it the - # task pretends to be 'running' forever (operator hit this 2026-05- - # 23 with two backups stuck in MigrationRun). On timeout - # subprocess.run raises TimeoutExpired which the caller surfaces as - # a task error. - return subprocess.run( - cmd, - capture_output=True, - check=True, - timeout=_DEFAULT_SUBPROCESS_TIMEOUT_S, - **{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), _libpq_url(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", _libpq_url(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"]} diff --git a/backend/app/services/migrators/rollback.py b/backend/app/services/migrators/rollback.py deleted file mode 100644 index 55ae7ef..0000000 --- a/backend/app/services/migrators/rollback.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Restore from the most recent 'pre_migration'-tagged backup.""" -from __future__ import annotations - -from pathlib import Path - -from . import backup as backup_mod - - -class NoBackupFoundError(Exception): - """Raised when rollback() is called with no pre_migration backup on disk.""" - - -def rollback_to_pre_migration(*, db_url: str, images_root: Path) -> dict: - manifest = backup_mod.find_latest_backup(images_root, tag="pre_migration") - if manifest is None: - raise NoBackupFoundError( - "no pre_migration-tagged backup found under /_backups/" - ) - return backup_mod.restore_backup( - manifest=manifest, db_url=db_url, images_root=images_root, - )