Files
hermes-recovery/scripts/backup_portal.py
T

462 lines
17 KiB
Python
Executable File

#!/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()