218 lines
6.1 KiB
Python
218 lines
6.1 KiB
Python
#!/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())
|