#!/usr/bin/env python3 """ IT Pro Partner Portal — Backup & Disaster Recovery Backs up the portal database + config + uploaded files to S3. Supports two targets: primary (local region) and cross-region (DR). Usage: python3 backup_portal.py # Full backup to configured targets python3 backup_portal.py --target primary # Primary only python3 backup_portal.py --target cross_region # DR only python3 backup_portal.py --restore backup_2026-07-03_040001.tar.gz # Restore from file Required env vars: PORTAL_DB_URL — sqlite:///path or postgresql://... PORTAL_FILES_DIR — /path/to/uploads AWS_ACCESS_KEY_ID — Wasabi or AWS IAM key AWS_SECRET_ACCESS_KEY — Wasabi or AWS IAM secret Backup targets (configured in backup_config table or env): S3_BUCKET_PRIMARY — itpropartner-backups-prod S3_BUCKET_DR — itpropartner-backups-dr (cross-region) S3_ENDPOINT — https://s3.us-east-1.wasabisys.com (optional) """ import gzip import hashlib import io import json import os import shutil import subprocess import sys import tarfile import tempfile import time import uuid from datetime import datetime, timezone from pathlib import Path # ─── Configuration ──────────────────────────────────────────────── BACKUP_DIR = Path("/tmp/itpropartner-backups") RETENTION_DAYS = int(os.environ.get("BACKUP_RETENTION_DAYS", "30")) TIMESTAMP = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S") BACKUP_FILENAME = f"itpropartner_backup_{TIMESTAMP}.tar.gz" CHECKSUM_FILENAME = f"{BACKUP_FILENAME}.sha256" # AWS/S3 config S3_ENDPOINT = os.environ.get("S3_ENDPOINT", "") S3_REGION = os.environ.get("S3_REGION", "us-east-1") BACKUP_TARGETS = {} primary_bucket = os.environ.get("S3_BUCKET_PRIMARY") if primary_bucket: BACKUP_TARGETS["primary"] = { "bucket": primary_bucket, "region": S3_REGION, "endpoint": S3_ENDPOINT, "prefix": "portal-backup/", } dr_bucket = os.environ.get("S3_BUCKET_DR") if dr_bucket: BACKUP_TARGETS["cross_region"] = { "bucket": dr_bucket, "region": os.environ.get("S3_REGION_DR", "us-west-2"), "endpoint": os.environ.get("S3_ENDPOINT_DR", ""), "prefix": "portal-backup/", } def now_iso() -> str: return datetime.now(timezone.utc).isoformat() # ─── Database ───────────────────────────────────────────────────── DB_URL = os.environ.get("PORTAL_DB_URL", "") FILES_DIR = os.environ.get("PORTAL_FILES_DIR", "") class BackupRunner: """Manages the full backup lifecycle for a single target.""" def __init__(self, target_name: str, target_config: dict): self.target_name = target_name self.bucket = target_config["bucket"] self.region = target_config["region"] self.endpoint = target_config["endpoint"] self.prefix = target_config["prefix"] self.run_id = str(uuid.uuid4()) self.start_time = time.time() self.status = "running" self.error = None self.db_size = 0 self.files_size = 0 self.archive_size = 0 self.checksum = "" def run(self) -> dict: """Execute backup: dump DB, collect files, archive, upload.""" try: BACKUP_DIR.mkdir(parents=True, exist_ok=True) work_dir = BACKUP_DIR / self.target_name if work_dir.exists(): shutil.rmtree(work_dir) work_dir.mkdir(parents=True) # 1. Dump database db_path = self._dump_database(work_dir) if db_path: self.db_size = os.path.getsize(db_path) # 2. Collect files (config, uploads, scripts) files_path = self._collect_files(work_dir) # 3. Write metadata self._write_metadata(work_dir) # 4. Create compressed archive archive_path, self.archive_size, self.checksum = self._archive(work_dir) # 5. Upload to S3 self._upload(archive_path) # 6. Cleanup old backups self._cleanup_old() # 7. Cleanup temp shutil.rmtree(work_dir) self.status = "success" except Exception as e: self.status = "failed" self.error = str(e) raise finally: self.duration = time.time() - self.start_time return { "target": self.target_name, "bucket": self.bucket, "status": self.status, "archive_key": f"{self.prefix}{BACKUP_FILENAME}", "checksum": self.checksum, "size_bytes": self.archive_size, "duration_seconds": round(self.duration, 2), "db_backup_size": self.db_size, "files_backup_size": self.files_size, } def _dump_database(self, work_dir: Path) -> Path | None: """Dump the database to a compressed SQL file.""" if not DB_URL: return None db_file = work_dir / "portal-database.sql" db_gz_file = work_dir / "portal-database.sql.gz" print(f" 📦 Dumping database: {DB_URL[:60]}...") if DB_URL.startswith("sqlite://"): # SQLite — just copy the file sqlite_path = DB_URL.replace("sqlite:///", "") if not sqlite_path.startswith("/"): sqlite_path = os.path.expanduser(f"~/{sqlite_path}") if not os.path.exists(sqlite_path): print(f" ⚠️ SQLite file not found: {sqlite_path}") return None # Dump to SQL with open(sqlite_path) as src: data = src.read() with gzip.open(db_gz_file, "wt") as dst: dst.write(f"-- SQLite dump from {sqlite_path}\n-- Generated: {now_iso()}\n\n") dst.write(data) return db_gz_file elif DB_URL.startswith("postgresql"): # PostgreSQL — use pg_dump try: result = subprocess.run( ["pg_dump", "--no-owner", "--no-acl", "--compress=9", "--file", str(db_gz_file), DB_URL], capture_output=True, text=True, timeout=120, ) if result.returncode != 0: print(f" ⚠️ pg_dump failed: {result.stderr[:200]}") return None return db_gz_file except FileNotFoundError: print(" ⚠️ pg_dump not installed") return None except subprocess.TimeoutExpired: print(" ⚠️ pg_dump timed out") return None print(f" ⚠️ Unsupported DB URL: {DB_URL.split('://')[0]}://...") return None def _collect_files(self, work_dir: Path) -> Path: """Collect config files, uploads, and scripts.""" files_dir = work_dir / "files" files_dir.mkdir() # Portal config directory config_dirs = [ FILES_DIR if FILES_DIR and os.path.exists(FILES_DIR) else None, Path(os.path.expanduser("~/.hermes")), ] for src_dir in config_dirs: if not src_dir or not Path(src_dir).exists(): continue src_path = Path(src_dir) dst_path = files_dir / src_path.name try: shutil.copytree(src_path, dst_path, ignore=shutil.ignore_patterns("*.pyc", "__pycache__", ".git")) size = sum(f.stat().st_size for f in dst_path.rglob("*") if f.is_file()) print(f" 📁 Collected {src_path.name}: {size:,} bytes") except Exception as e: print(f" ⚠️ Could not copy {src_path}: {e}") # Write file manifest manifest = [] for f in files_dir.rglob("*"): if f.is_file(): rel = f.relative_to(files_dir) manifest.append({"path": str(rel), "size": f.stat().st_size}) self.files_size = sum(m["size"] for m in manifest) (files_dir / "manifest.json").write_text(json.dumps(manifest, indent=2)) return files_dir def _write_metadata(self, work_dir: Path) -> None: """Write backup metadata JSON.""" metadata = { "backup_version": "1.0", "created_at": now_iso(), "hostname": os.uname().nodename, "target": self.target_name, "bucket": self.bucket, "components": { "database": bool(DB_URL), "files": bool(FILES_DIR), }, "config": { "db_url_prefix": DB_URL.split("://")[0] if DB_URL else None, "files_dir": FILES_DIR or None, "retention_days": RETENTION_DAYS, }, } (work_dir / "metadata.json").write_text(json.dumps(metadata, indent=2)) def _archive(self, work_dir: Path) -> tuple[Path, int, str]: """Create compressed tar archive and compute checksum.""" archive_path = BACKUP_DIR / BACKUP_FILENAME with tarfile.open(archive_path, "w:gz") as tar: tar.add(work_dir, arcname=self.target_name) # Checksum sha256 = hashlib.sha256() with open(archive_path, "rb") as f: while chunk := f.read(65536): sha256.update(chunk) checksum = sha256.hexdigest() # Write checksum file alongside archive checksum_path = BACKUP_DIR / CHECKSUM_FILENAME checksum_path.write_text(f"{checksum} {BACKUP_FILENAME}\n") size = os.path.getsize(archive_path) print(f" 📦 Archive: {BACKUP_FILENAME} ({size:,} bytes, sha256: {checksum[:16]}...)") return archive_path, size, checksum def _upload(self, archive_path: Path) -> None: """Upload archive and checksum to S3 via aws-cli.""" key = f"{self.prefix}{BACKUP_FILENAME}" checksum_key = f"{self.prefix}{CHECKSUM_FILENAME}" endpoint_flag = [] if self.endpoint: endpoint_flag = ["--endpoint-url", self.endpoint] region_flag = ["--region", self.region] # Upload archive print(f" ☁️ Uploading to s3://{self.bucket}/{key}") result = subprocess.run( ["aws", "s3", "cp", str(archive_path), f"s3://{self.bucket}/{key}"] + endpoint_flag + region_flag, capture_output=True, text=True, timeout=120, ) if result.returncode != 0: raise RuntimeError(f"S3 upload failed: {result.stderr}") # Upload checksum checksum_path = BACKUP_DIR / CHECKSUM_FILENAME result = subprocess.run( ["aws", "s3", "cp", str(checksum_path), f"s3://{self.bucket}/{checksum_key}"] + endpoint_flag + region_flag, capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"S3 checksum upload failed: {result.stderr}") print(f" ✅ Uploaded to s3://{self.bucket}/{key}") def _cleanup_old(self) -> None: """Remove local temporary files.""" for f in BACKUP_DIR.glob("*.tar.gz"): if f.name != BACKUP_FILENAME: f.unlink() for f in BACKUP_DIR.glob("*.sha256"): if f.name != CHECKSUM_FILENAME: f.unlink() # Remote cleanup via S3 lifecycle is preferred, but we can also # list and delete old backups here if needed. print(f" 🧹 Retention: {RETENTION_DAYS} days") def restore_backup(archive_name: str) -> None: """Restore portal from a backup archive stored in S3.""" print(f"🔄 Restoring from {archive_name}") # Try primary bucket first, then DR for target_name, target in BACKUP_TARGETS.items(): key = f"{target['prefix']}{archive_name}" endpoint_flag = ["--endpoint-url", target["endpoint"]] if target["endpoint"] else [] print(f" Looking in s3://{target['bucket']}/{key}...") result = subprocess.run( ["aws", "s3", "ls", f"s3://{target['bucket']}/{key}"] + endpoint_flag, capture_output=True, text=True, timeout=15, ) if result.returncode == 0: # Download local_path = BACKUP_DIR / archive_name result = subprocess.run( ["aws", "s3", "cp", f"s3://{target['bucket']}/{key}", str(local_path)] + endpoint_flag, capture_output=True, text=True, timeout=120, ) if result.returncode != 0: print(f" ❌ Download failed: {result.stderr}") continue # Verify checksum checksum_key = f"{target['prefix']}{archive_name}.sha256" checksum_local = BACKUP_DIR / f"{archive_name}.sha256" result = subprocess.run( ["aws", "s3", "cp", f"s3://{target['bucket']}/{checksum_key}", str(checksum_local)] + endpoint_flag, capture_output=True, text=True, timeout=15, ) if result.returncode == 0: import hashlib sha256 = hashlib.sha256() with open(local_path, "rb") as f: while chunk := f.read(65536): sha256.update(chunk) expected = checksum_local.read_text().strip().split()[0] if sha256.hexdigest() != expected: print(f" ⚠️ Checksum mismatch! Expected {expected}, got {sha256.hexdigest()}") # Extract print(f" 📂 Extracting {local_path}...") with tarfile.open(local_path, "r:gz") as tar: tar.extractall(BACKUP_DIR) print(f"\n✅ Restored to {BACKUP_DIR}/{target_name}/") print(f" To restore database:") if DB_URL: print(f" cat {BACKUP_DIR}/{target_name}/portal-database.sql.gz | gunzip | sqlite3 portal.db") print(f" To restore files:") print(f" cp -r {BACKUP_DIR}/{target_name}/files/* /path/to/portal/") return print(f"❌ Could not find {archive_name} in any backup target") # ─── Main ────────────────────────────────────────────────────────── def main(): import argparse parser = argparse.ArgumentParser(description="IT Pro Partner Portal Backup") parser.add_argument("--target", choices=["primary", "cross_region", "all"], default="all", help="Backup target (default: all configured)") parser.add_argument("--restore", metavar="ARCHIVE.tar.gz", help="Restore from a backup archive") parser.add_argument("--list", action="store_true", help="List available backups in S3") args = parser.parse_args() if not BACKUP_TARGETS: print("❌ No backup targets configured. Set S3_BUCKET_PRIMARY or S3_BUCKET_DR.") sys.exit(1) if args.restore: restore_backup(args.restore) return if args.list: for target_name, target in BACKUP_TARGETS.items(): endpoint_flag = ["--endpoint-url", target["endpoint"]] if target["endpoint"] else [] print(f"📋 Backups in {target['bucket']} ({target_name}):") result = subprocess.run( ["aws", "s3", "ls", f"s3://{target['bucket']}/{target['prefix']}"] + endpoint_flag, capture_output=True, text=True, timeout=15, ) if result.returncode == 0: for line in result.stdout.strip().split("\n"): if line: print(f" {line}") else: print(f" {result.stderr}") return # Run backups results = [] for target_name, target_config in BACKUP_TARGETS.items(): if args.target != "all" and args.target != target_name: continue print(f"\n{'='*60}") print(f"Backing up to {target_name}: {target_config['bucket']}") print(f"{'='*60}") runner = BackupRunner(target_name, target_config) try: result = runner.run() results.append(result) print(f" ✅ Success: {result['size_bytes']:,} bytes in {result['duration_seconds']:.1f}s") except Exception as e: print(f" ❌ Failed: {e}") results.append({"target": target_name, "status": "failed", "error": str(e)}) # Print summary print(f"\n{'='*60}") print("Backup Summary") print(f"{'='*60}") for r in results: status_icon = "✅" if r["status"] == "success" else "❌" print(f" {status_icon} {r['target']}: {r['status']}") if r["status"] == "success": print(f" Archive: {r['archive_key']}") print(f" Size: {r['size_bytes']:,} bytes") print(f" Duration: {r['duration_seconds']:.1f}s") else: print(f" Error: {r.get('error', 'unknown')}") if DRY_RUN: print(f"\n🔍 DRY RUN — No backups were uploaded") if __name__ == "__main__": # Check for DRY_RUN DRY_RUN = os.environ.get("DRY_RUN", "").lower() in ("1", "true", "yes") main()