e1b057f494
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
3.5 KiB
Python
125 lines
3.5 KiB
Python
"""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 _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 <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), 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"]}
|