#!/usr/bin/env python3 """ PostgreSQL Backup Service for WebApp. - Creates pg_dump backup - Compresses with gzip - Uploads to S3 FirstVDS - Rotates old backups (configurable retention) - Sends Telegram notifications """ import gzip import os import subprocess import sys from datetime import datetime, timedelta, timezone import boto3 import httpx from botocore.config import Config as BotoConfig from botocore.exceptions import ClientError from config import config def create_s3_client(): """Initialize S3 client (same pattern as backend storage.py).""" 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 send_telegram_notification(message: str, is_error: bool = False) -> None: """Send notification to Telegram admin.""" if not config.TELEGRAM_BOT_TOKEN or not config.TELEGRAM_ADMIN_ID: print("Telegram not configured, skipping notification") return emoji = "\u274c" if is_error else "\u2705" text = f"{emoji} *Database Backup*\n\n{message}" url = f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage" data = { "chat_id": config.TELEGRAM_ADMIN_ID, "text": text, "parse_mode": "Markdown", } try: response = httpx.post(url, json=data, timeout=30) response.raise_for_status() print("Telegram notification sent") except Exception as e: print(f"Failed to send Telegram notification: {e}") def create_backup() -> tuple[str, bytes]: """Create pg_dump backup and compress it.""" timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") filename = f"marathon_backup_{timestamp}.sql.gz" # Build pg_dump command env = os.environ.copy() env["PGPASSWORD"] = config.DB_PASSWORD cmd = [ "pg_dump", "-h", config.DB_HOST, "-p", config.DB_PORT, "-U", config.DB_USER, "-d", config.DB_NAME, "--no-owner", "--no-acl", "-F", "p", # plain SQL format ] print(f"Running pg_dump for database {config.DB_NAME}...") result = subprocess.run( cmd, env=env, capture_output=True, ) if result.returncode != 0: raise Exception(f"pg_dump failed: {result.stderr.decode()}") # Compress the output print("Compressing backup...") compressed = gzip.compress(result.stdout, compresslevel=9) return filename, compressed def upload_to_s3(s3_client, filename: str, data: bytes) -> str: """Upload backup to S3.""" key = f"{config.S3_BACKUP_PREFIX}{filename}" print(f"Uploading to S3: {key}...") s3_client.put_object( Bucket=config.S3_BUCKET_NAME, Key=key, Body=data, ContentType="application/gzip", ) return key def rotate_old_backups(s3_client) -> int: """Delete backups older than BACKUP_RETENTION_DAYS.""" cutoff_date = datetime.now(timezone.utc) - timedelta( days=config.BACKUP_RETENTION_DAYS ) deleted_count = 0 print(f"Rotating backups older than {config.BACKUP_RETENTION_DAYS} days...") # List all objects with backup prefix try: paginator = s3_client.get_paginator("list_objects_v2") pages = paginator.paginate( Bucket=config.S3_BUCKET_NAME, Prefix=config.S3_BACKUP_PREFIX, ) for page in pages: for obj in page.get("Contents", []): last_modified = obj["LastModified"] if last_modified.tzinfo is None: last_modified = last_modified.replace(tzinfo=timezone.utc) if last_modified < cutoff_date: s3_client.delete_object( Bucket=config.S3_BUCKET_NAME, Key=obj["Key"], ) deleted_count += 1 print(f"Deleted old backup: {obj['Key']}") except ClientError as e: print(f"Error during rotation: {e}") return deleted_count def main() -> int: """Main backup routine.""" start_time = datetime.now() print(f"{'=' * 50}") print(f"Backup started at {start_time}") print(f"{'=' * 50}") try: # Validate configuration if not config.S3_BUCKET_NAME: raise Exception("S3_BUCKET_NAME is not configured") if not config.S3_ACCESS_KEY_ID: raise Exception("S3_ACCESS_KEY_ID is not configured") if not config.S3_SECRET_ACCESS_KEY: raise Exception("S3_SECRET_ACCESS_KEY is not configured") if not config.S3_ENDPOINT_URL: raise Exception("S3_ENDPOINT_URL is not configured") # Create S3 client s3_client = create_s3_client() # Create backup filename, data = create_backup() size_mb = len(data) / (1024 * 1024) print(f"Backup created: {filename} ({size_mb:.2f} MB)") # Upload to S3 s3_key = upload_to_s3(s3_client, filename, data) print(f"Uploaded to S3: {s3_key}") # Rotate old backups deleted_count = rotate_old_backups(s3_client) print(f"Deleted {deleted_count} old backups") # Calculate duration duration = datetime.now() - start_time # Send success notification message = ( f"Backup completed successfully!\n\n" f"*File:* `{filename}`\n" f"*Size:* {size_mb:.2f} MB\n" f"*Duration:* {duration.seconds}s\n" f"*Deleted old:* {deleted_count} files" ) send_telegram_notification(message, is_error=False) print(f"{'=' * 50}") print("Backup completed successfully!") print(f"{'=' * 50}") return 0 except Exception as e: error_msg = f"Backup failed!\n\n*Error:* `{str(e)}`" send_telegram_notification(error_msg, is_error=True) print(f"{'=' * 50}") print(f"Backup failed: {e}") print(f"{'=' * 50}") return 1 if __name__ == "__main__": sys.exit(main())