#!/usr/bin/env python3 """Create versioned snapshots of critical configuration files on the Avamposto VPS via SSH.""" from __future__ import annotations import json import subprocess import tarfile import tempfile from datetime import datetime, timezone from pathlib import Path CONFIG_PATH = Path(__file__).resolve().parent / "avamposto_targets.json" OUTPUT_DIR = Path(__file__).resolve().parent / "avamposto_snapshots" def _load_config() -> dict: return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) def _ssh_run(host: str, user: str, cmd: str) -> subprocess.CompletedProcess: return subprocess.run( ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", f"{user}@{host}", cmd], capture_output=True, text=True, ) def _fetch_path(host: str, user: str, remote_path: str, local_dir: Path) -> tuple[bool, str]: """Download a remote path (file or directory) into local_dir via scp.""" dest = local_dir / remote_path.lstrip("/") dest.parent.mkdir(parents=True, exist_ok=True) result = subprocess.run( ["scp", "-r", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", f"{user}@{host}:{remote_path}", str(dest.parent) + "/"], capture_output=True, text=True, ) if result.returncode != 0: return False, result.stderr.strip() return True, None def main() -> None: cfg = _load_config() host: str = cfg["host"] user: str = cfg["user"] paths: list[str] = cfg["paths"] retention: int = cfg.get("retention", 14) timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") OUTPUT_DIR.mkdir(parents=True, exist_ok=True) archive_path = OUTPUT_DIR / f"avamposto_snapshot_{timestamp}.tar.gz" included: list[str] = [] skipped: list[dict] = [] with tempfile.TemporaryDirectory() as tmpdir: tmp = Path(tmpdir) for remote_path in paths: ok, err = _fetch_path(host, user, remote_path, tmp) if ok: included.append(remote_path) else: skipped.append({"path": remote_path, "reason": err or "fetch failed"}) with tarfile.open(archive_path, "w:gz") as tar: for remote_path in included: local = tmp / remote_path.lstrip("/") if local.exists(): tar.add(str(local), arcname=remote_path.lstrip("/")) metadata = { "created_at_utc": timestamp, "host": host, "archive": archive_path.name, "included": included, "skipped": skipped, "retention": retention, } meta_path = archive_path.with_suffix(archive_path.suffix + ".json") meta_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8") # Apply retention archives = sorted(OUTPUT_DIR.glob("avamposto_snapshot_*.tar.gz")) for old in archives[:-retention]: old.unlink(missing_ok=True) old.with_suffix(old.suffix + ".json").unlink(missing_ok=True) print(f"[SNAPSHOT] {archive_path}") print(f" Included ({len(included)}): {', '.join(included)}") if skipped: print(f" Skipped ({len(skipped)}): {', '.join(s['path'] for s in skipped)}") if __name__ == "__main__": main()