fc3h: remove backend/app/services/migrators/backup.py + rollback.py (relocated to backup_service.py)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 22:59:18 -04:00
parent d3d4320ed5
commit 70e1e010d1
2 changed files with 0 additions and 167 deletions
-146
View File
@@ -1,146 +0,0 @@
"""pg_dump + tar.zst-based backup, restorable via pair of subprocess calls.
Backups live under <images_root>/_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 <ts>.sql, <ts>.tar.zst, <ts>.json into
<images_root>/_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"]}
@@ -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 <images_root>/_backups/"
)
return backup_mod.restore_backup(
manifest=manifest, db_url=db_url, images_root=images_root,
)