#!/usr/bin/env python3 """ Restore PostgreSQL database from S3 backup. Usage: python restore.py - List available backups python restore.py - Restore from backup (cleans DB first) python restore.py --no-clean - Restore without cleaning DB first """ import gzip import os import subprocess import sys import boto3 from botocore.config import Config as BotoConfig from botocore.exceptions import ClientError from config import config def create_s3_client(): """Initialize S3 client.""" return boto3.client( "s3", endpoint_url=config.S3_ENDPOINT_URL, aws_access_key_id=config.S3_ACCESS_KEY_ID, aws_secret_access_key=config.S3_SECRET_ACCESS_KEY, region_name=config.S3_REGION or "us-east-1", config=BotoConfig(signature_version="s3v4"), ) def list_backups(s3_client) -> list[tuple[str, float, str]]: """List all available backups.""" print("Available backups:\n") try: paginator = s3_client.get_paginator("list_objects_v2") pages = paginator.paginate( Bucket=config.S3_BUCKET_NAME, Prefix=config.S3_BACKUP_PREFIX, ) backups = [] for page in pages: for obj in page.get("Contents", []): filename = obj["Key"].replace(config.S3_BACKUP_PREFIX, "") size_mb = obj["Size"] / (1024 * 1024) modified = obj["LastModified"].strftime("%Y-%m-%d %H:%M:%S") backups.append((filename, size_mb, modified)) # Sort by date descending (newest first) backups.sort(key=lambda x: x[2], reverse=True) for filename, size_mb, modified in backups: print(f" {filename} ({size_mb:.2f} MB) - {modified}") return backups except ClientError as e: print(f"Error listing backups: {e}") return [] def clean_database() -> None: """Drop and recreate public schema to clean the database.""" print("Cleaning database (dropping and recreating public schema)...") env = os.environ.copy() env["PGPASSWORD"] = config.DB_PASSWORD # Drop and recreate public schema clean_sql = b""" DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO public; """ cmd = [ "psql", "-h", config.DB_HOST, "-p", config.DB_PORT, "-U", config.DB_USER, "-d", config.DB_NAME, ] result = subprocess.run( cmd, env=env, input=clean_sql, capture_output=True, ) if result.returncode != 0: stderr = result.stderr.decode() if "ERROR" in stderr: raise Exception(f"Database cleanup failed: {stderr}") print("Database cleaned successfully!") def restore_backup(s3_client, filename: str, clean_first: bool = True) -> None: """Download and restore backup.""" key = f"{config.S3_BACKUP_PREFIX}{filename}" print(f"Downloading {filename} from S3...") try: response = s3_client.get_object( Bucket=config.S3_BUCKET_NAME, Key=key, ) compressed_data = response["Body"].read() except ClientError as e: raise Exception(f"Failed to download backup: {e}") print("Decompressing...") sql_data = gzip.decompress(compressed_data) # Clean database before restore if requested if clean_first: clean_database() print(f"Restoring to database {config.DB_NAME}...") # Build psql command env = os.environ.copy() env["PGPASSWORD"] = config.DB_PASSWORD cmd = [ "psql", "-h", config.DB_HOST, "-p", config.DB_PORT, "-U", config.DB_USER, "-d", config.DB_NAME, ] result = subprocess.run( cmd, env=env, input=sql_data, capture_output=True, ) if result.returncode != 0: stderr = result.stderr.decode() # psql may return warnings that aren't fatal errors if "ERROR" in stderr: raise Exception(f"psql restore failed: {stderr}") else: print(f"Warnings: {stderr}") print("Restore completed successfully!") def main() -> int: """Main restore routine.""" # Validate configuration if not config.S3_BUCKET_NAME: print("Error: S3_BUCKET_NAME is not configured") return 1 s3_client = create_s3_client() # Parse arguments args = sys.argv[1:] clean_first = True if "--no-clean" in args: clean_first = False args.remove("--no-clean") if len(args) < 1: # List available backups backups = list_backups(s3_client) if backups: print(f"\nTo restore, run: python restore.py ") print("Add --no-clean to skip database cleanup before restore") else: print("No backups found.") return 0 filename = args[0] # Confirm restore print(f"WARNING: This will restore database from {filename}") if clean_first: print("Database will be CLEANED (all existing data will be DELETED)!") else: print("Database will NOT be cleaned (may cause conflicts with existing data)") print() confirm = input("Type 'yes' to continue: ") if confirm.lower() != "yes": print("Restore cancelled.") return 0 try: restore_backup(s3_client, filename, clean_first=clean_first) return 0 except Exception as e: print(f"Restore failed: {e}") return 1 if __name__ == "__main__": sys.exit(main())