| """ |
| Database Backup Manager for AegisLM SaaS Backend. |
| |
| Production-ready backup system with integrity verification, |
| automated scheduling, and restore capabilities. |
| """ |
|
|
| import asyncio |
| import hashlib |
| import json |
| import subprocess |
| import tempfile |
| from datetime import datetime, timedelta |
| from pathlib import Path |
| from typing import List, Dict, Optional, Any, Tuple |
| from sqlalchemy import text |
| from sqlalchemy.ext.asyncio import AsyncSession |
| import logging |
| import gzip |
| import shutil |
| import os |
|
|
| from .database import async_engine, get_redis |
| from .config import settings |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class BackupMetadata: |
| """Backup metadata information.""" |
| |
| def __init__(self, backup_id: str, timestamp: datetime, checksum: str, |
| size_bytes: int, tables_count: int, records_count: int): |
| self.backup_id = backup_id |
| self.timestamp = timestamp |
| self.checksum = checksum |
| self.size_bytes = size_bytes |
| self.tables_count = tables_count |
| self.records_count = records_count |
| |
| def to_dict(self) -> Dict[str, Any]: |
| """Convert to dictionary.""" |
| return { |
| "backup_id": self.backup_id, |
| "timestamp": self.timestamp.isoformat(), |
| "checksum": self.checksum, |
| "size_bytes": self.size_bytes, |
| "tables_count": self.tables_count, |
| "records_count": self.records_count |
| } |
|
|
|
|
| class BackupManager: |
| """Database backup and restore manager.""" |
| |
| def __init__(self): |
| self.backup_dir = Path(settings.BACKUP_DIR if hasattr(settings, 'BACKUP_DIR') else "./backups") |
| self.backup_dir.mkdir(exist_ok=True) |
| self.retention_days = getattr(settings, 'BACKUP_RETENTION_DAYS', 30) |
| self.compression_enabled = getattr(settings, 'BACKUP_COMPRESSION', True) |
| |
| def _generate_backup_id(self) -> str: |
| """Generate unique backup ID.""" |
| timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") |
| return f"backup_{timestamp}" |
| |
| def _calculate_file_checksum(self, file_path: Path) -> str: |
| """Calculate SHA-256 checksum of file.""" |
| hash_sha256 = hashlib.sha256() |
| with open(file_path, "rb") as f: |
| for chunk in iter(lambda: f.read(4096), b""): |
| hash_sha256.update(chunk) |
| return hash_sha256.hexdigest() |
| |
| async def _get_database_stats(self) -> Tuple[int, int]: |
| """Get database statistics (tables count, records count).""" |
| async with async_engine.begin() as conn: |
| |
| tables_result = await conn.execute(text(""" |
| SELECT COUNT(*) FROM information_schema.tables |
| WHERE table_schema = 'public' AND table_type = 'BASE TABLE' |
| """)) |
| tables_count = tables_result.scalar() |
| |
| |
| records_result = await conn.execute(text(""" |
| SELECT SUM(n_tup_ins - n_tup_del) as total_records |
| FROM pg_stat_user_tables |
| """)) |
| records_count = records_result.scalar() or 0 |
| |
| return tables_count, records_count |
| |
| async def create_backup(self, description: Optional[str] = None) -> Optional[BackupMetadata]: |
| """Create a full database backup.""" |
| backup_id = self._generate_backup_id() |
| temp_file = None |
| |
| try: |
| |
| tables_count, records_count = await self._get_database_stats() |
| |
| |
| temp_file = tempfile.NamedTemporaryFile(suffix='.sql', delete=False) |
| temp_path = Path(temp_file.name) |
| |
| |
| db_url = settings.DATABASE_URL |
| if not db_url.startswith('postgresql'): |
| logger.error("Only PostgreSQL backups are supported") |
| return None |
| |
| |
| |
| clean_url = db_url.replace('postgresql+asyncpg://', 'postgresql://') |
| |
| |
| cmd = [ |
| 'pg_dump', |
| '--verbose', |
| '--no-password', |
| '--format=custom', |
| '--compress=9', |
| clean_url |
| ] |
| |
| logger.info(f"Creating backup {backup_id}...") |
| |
| |
| process = await asyncio.create_subprocess_exec( |
| *cmd, |
| stdout=temp_file, |
| stderr=asyncio.subprocess.PIPE |
| ) |
| |
| stdout, stderr = await process.communicate() |
| |
| if process.returncode != 0: |
| logger.error(f"Backup failed: {stderr.decode()}") |
| return None |
| |
| |
| file_size = temp_path.stat().st_size |
| |
| |
| checksum = self._calculate_file_checksum(temp_path) |
| |
| |
| backup_filename = f"{backup_id}.dump" |
| backup_path = self.backup_dir / backup_filename |
| |
| if self.compression_enabled: |
| |
| with open(temp_path, 'rb') as f_in: |
| with gzip.open(f"{backup_path}.gz", 'wb') as f_out: |
| shutil.copyfileobj(f_in, f_out) |
| backup_path = Path(f"{backup_path}.gz") |
| temp_path.unlink() |
| else: |
| shutil.move(temp_path, backup_path) |
| |
| |
| metadata = BackupMetadata( |
| backup_id=backup_id, |
| timestamp=datetime.utcnow(), |
| checksum=checksum, |
| size_bytes=backup_path.stat().st_size, |
| tables_count=tables_count, |
| records_count=records_count |
| ) |
| |
| |
| metadata_path = self.backup_dir / f"{backup_id}_metadata.json" |
| with open(metadata_path, 'w') as f: |
| json.dump(metadata.to_dict(), f, indent=2) |
| |
| |
| redis_client = await get_redis() |
| await redis_client.setex( |
| f"backup:{backup_id}", |
| timedelta(days=self.retention_days), |
| json.dumps(metadata.to_dict()) |
| ) |
| |
| logger.info(f"Backup {backup_id} created successfully") |
| return metadata |
| |
| except Exception as e: |
| logger.error(f"Backup creation failed: {e}") |
| if temp_file and temp_file.name: |
| Path(temp_file.name).unlink(missing_ok=True) |
| return None |
| |
| async def restore_backup(self, backup_id: str) -> bool: |
| """Restore database from backup.""" |
| try: |
| |
| backup_path = None |
| for path in self.backup_dir.glob(f"{backup_id}*"): |
| if path.suffix in ['.dump', '.gz']: |
| backup_path = path |
| break |
| |
| if not backup_path: |
| logger.error(f"Backup file not found: {backup_id}") |
| return False |
| |
| |
| metadata = await self.get_backup_metadata(backup_id) |
| if not metadata: |
| logger.error(f"Backup metadata not found: {backup_id}") |
| return False |
| |
| |
| if backup_path.suffix == '.gz': |
| |
| with gzip.open(backup_path, 'rb') as f: |
| temp_content = f.read() |
| temp_checksum = hashlib.sha256(temp_content).hexdigest() |
| else: |
| temp_checksum = self._calculate_file_checksum(backup_path) |
| |
| if temp_checksum != metadata.checksum: |
| logger.error(f"Backup integrity check failed for {backup_id}") |
| return False |
| |
| |
| temp_file = tempfile.NamedTemporaryFile(suffix='.dump', delete=False) |
| temp_path = Path(temp_file.name) |
| |
| |
| if backup_path.suffix == '.gz': |
| with gzip.open(backup_path, 'rb') as f_in: |
| with open(temp_path, 'wb') as f_out: |
| shutil.copyfileobj(f_in, f_out) |
| else: |
| shutil.copy2(backup_path, temp_path) |
| |
| |
| db_url = settings.DATABASE_URL |
| clean_url = db_url.replace('postgresql+asyncpg://', 'postgresql://') |
| |
| |
| cmd = [ |
| 'pg_restore', |
| '--verbose', |
| '--no-password', |
| '--clean', |
| '--if-exists', |
| '--dbname', clean_url, |
| str(temp_path) |
| ] |
| |
| logger.info(f"Restoring backup {backup_id}...") |
| |
| |
| process = await asyncio.create_subprocess_exec( |
| *cmd, |
| stdout=asyncio.subprocess.PIPE, |
| stderr=asyncio.subprocess.PIPE |
| ) |
| |
| stdout, stderr = await process.communicate() |
| |
| |
| temp_path.unlink() |
| |
| if process.returncode != 0: |
| logger.error(f"Restore failed: {stderr.decode()}") |
| return False |
| |
| logger.info(f"Backup {backup_id} restored successfully") |
| return True |
| |
| except Exception as e: |
| logger.error(f"Backup restore failed: {e}") |
| return False |
| |
| async def get_backup_metadata(self, backup_id: str) -> Optional[BackupMetadata]: |
| """Get backup metadata.""" |
| |
| try: |
| redis_client = await get_redis() |
| metadata_json = await redis_client.get(f"backup:{backup_id}") |
| if metadata_json: |
| data = json.loads(metadata_json) |
| return BackupMetadata( |
| backup_id=data['backup_id'], |
| timestamp=datetime.fromisoformat(data['timestamp']), |
| checksum=data['checksum'], |
| size_bytes=data['size_bytes'], |
| tables_count=data['tables_count'], |
| records_count=data['records_count'] |
| ) |
| except Exception: |
| pass |
| |
| |
| metadata_path = self.backup_dir / f"{backup_id}_metadata.json" |
| if metadata_path.exists(): |
| try: |
| with open(metadata_path, 'r') as f: |
| data = json.load(f) |
| return BackupMetadata( |
| backup_id=data['backup_id'], |
| timestamp=datetime.fromisoformat(data['timestamp']), |
| checksum=data['checksum'], |
| size_bytes=data['size_bytes'], |
| tables_count=data['tables_count'], |
| records_count=data['records_count'] |
| ) |
| except Exception as e: |
| logger.error(f"Failed to load metadata: {e}") |
| |
| return None |
| |
| async def list_backups(self) -> List[BackupMetadata]: |
| """List all available backups.""" |
| backups = [] |
| |
| |
| for metadata_file in self.backup_dir.glob("*_metadata.json"): |
| backup_id = metadata_file.stem.replace('_metadata', '') |
| metadata = await self.get_backup_metadata(backup_id) |
| if metadata: |
| backups.append(metadata) |
| |
| |
| backups.sort(key=lambda x: x.timestamp, reverse=True) |
| return backups |
| |
| async def verify_backup_integrity(self, backup_id: str) -> Dict[str, Any]: |
| """Verify backup file integrity.""" |
| metadata = await self.get_backup_metadata(backup_id) |
| if not metadata: |
| return {"verified": False, "error": "Backup metadata not found"} |
| |
| |
| backup_path = None |
| for path in self.backup_dir.glob(f"{backup_id}*"): |
| if path.suffix in ['.dump', '.gz']: |
| backup_path = path |
| break |
| |
| if not backup_path: |
| return {"verified": False, "error": "Backup file not found"} |
| |
| try: |
| |
| if backup_path.suffix == '.gz': |
| with gzip.open(backup_path, 'rb') as f: |
| content = f.read() |
| current_checksum = hashlib.sha256(content).hexdigest() |
| else: |
| current_checksum = self._calculate_file_checksum(backup_path) |
| |
| |
| is_valid = current_checksum == metadata.checksum |
| |
| return { |
| "verified": is_valid, |
| "stored_checksum": metadata.checksum, |
| "current_checksum": current_checksum, |
| "file_size": backup_path.stat().st_size, |
| "expected_size": metadata.size_bytes, |
| "size_match": backup_path.stat().st_size == metadata.size_bytes |
| } |
| |
| except Exception as e: |
| return {"verified": False, "error": str(e)} |
| |
| async def cleanup_old_backups(self) -> int: |
| """Clean up old backups beyond retention period.""" |
| cutoff_date = datetime.utcnow() - timedelta(days=self.retention_days) |
| cleaned_count = 0 |
| |
| try: |
| backups = await self.list_backups() |
| |
| for backup in backups: |
| if backup.timestamp < cutoff_date: |
| |
| for path in self.backup_dir.glob(f"{backup.backup_id}*"): |
| path.unlink() |
| |
| |
| redis_client = await get_redis() |
| await redis_client.delete(f"backup:{backup.backup_id}") |
| |
| cleaned_count += 1 |
| logger.info(f"Cleaned up old backup: {backup.backup_id}") |
| |
| if cleaned_count > 0: |
| logger.info(f"Cleaned up {cleaned_count} old backups") |
| |
| except Exception as e: |
| logger.error(f"Backup cleanup failed: {e}") |
| |
| return cleaned_count |
| |
| async def get_backup_statistics(self) -> Dict[str, Any]: |
| """Get backup statistics.""" |
| backups = await self.list_backups() |
| |
| if not backups: |
| return { |
| "total_backups": 0, |
| "total_size_bytes": 0, |
| "oldest_backup": None, |
| "newest_backup": None, |
| "average_size_bytes": 0 |
| } |
| |
| total_size = sum(b.size_bytes for b in backups) |
| oldest = min(b.timestamp for b in backups) |
| newest = max(b.timestamp for b in backups) |
| |
| return { |
| "total_backups": len(backups), |
| "total_size_bytes": total_size, |
| "total_size_mb": round(total_size / (1024 * 1024), 2), |
| "oldest_backup": oldest.isoformat(), |
| "newest_backup": newest.isoformat(), |
| "average_size_bytes": total_size // len(backups), |
| "average_size_mb": round(total_size / len(backups) / (1024 * 1024), 2), |
| "retention_days": self.retention_days |
| } |
|
|
|
|
| |
| backup_manager = BackupManager() |
|
|
|
|
| |
| async def scheduled_backup(): |
| """Run scheduled backup.""" |
| logger.info("Starting scheduled backup...") |
| |
| metadata = await backup_manager.create_backup("Scheduled backup") |
| if metadata: |
| logger.info(f"Scheduled backup completed: {metadata.backup_id}") |
| |
| |
| cleaned = await backup_manager.cleanup_old_backups() |
| if cleaned > 0: |
| logger.info(f"Cleaned up {cleaned} old backups") |
| else: |
| logger.error("Scheduled backup failed") |
|
|
|
|
| |
| async def backup_health_check() -> Dict[str, Any]: |
| """Check backup system health.""" |
| try: |
| |
| test_backup = await backup_manager.create_backup("Health check test") |
| if test_backup: |
| |
| verification = await backup_manager.verify_backup_integrity(test_backup.backup_id) |
| |
| |
| for path in backup_manager.backup_dir.glob(f"{test_backup.backup_id}*"): |
| path.unlink() |
| |
| redis_client = await get_redis() |
| await redis_client.delete(f"backup:{test_backup.backup_id}") |
| |
| return { |
| "healthy": verification["verified"], |
| "test_backup_id": test_backup.backup_id, |
| "verification": verification |
| } |
| else: |
| return {"healthy": False, "error": "Failed to create test backup"} |
| |
| except Exception as e: |
| return {"healthy": False, "error": str(e)} |
|
|
|
|
| if __name__ == "__main__": |
| import sys |
| |
| async def main(): |
| command = sys.argv[1] if len(sys.argv) > 1 else "help" |
| |
| if command == "create": |
| metadata = await backup_manager.create_backup() |
| if metadata: |
| print(f"✅ Backup created: {metadata.backup_id}") |
| else: |
| print("❌ Backup creation failed") |
| |
| elif command == "list": |
| backups = await backup_manager.list_backups() |
| print(f"Total backups: {len(backups)}") |
| for backup in backups: |
| print(f" - {backup.backup_id} ({backup.timestamp.isoformat()})") |
| |
| elif command == "verify": |
| backup_id = sys.argv[2] if len(sys.argv) > 2 else None |
| if not backup_id: |
| print("Error: verify requires backup ID") |
| sys.exit(1) |
| |
| verification = await backup_manager.verify_backup_integrity(backup_id) |
| if verification["verified"]: |
| print(f"✅ Backup {backup_id} is valid") |
| else: |
| print(f"❌ Backup {backup_id} verification failed") |
| print(f"Error: {verification.get('error', 'Unknown error')}") |
| |
| elif command == "restore": |
| backup_id = sys.argv[2] if len(sys.argv) > 2 else None |
| if not backup_id: |
| print("Error: restore requires backup ID") |
| sys.exit(1) |
| |
| success = await backup_manager.restore_backup(backup_id) |
| if success: |
| print(f"✅ Backup {backup_id} restored successfully") |
| else: |
| print(f"❌ Backup {backup_id} restore failed") |
| |
| elif command == "stats": |
| stats = await backup_manager.get_backup_statistics() |
| print(f"Total backups: {stats['total_backups']}") |
| print(f"Total size: {stats['total_size_mb']} MB") |
| print(f"Average size: {stats['average_size_mb']} MB") |
| print(f"Oldest backup: {stats['oldest_backup']}") |
| print(f"Newest backup: {stats['newest_backup']}") |
| print(f"Retention days: {stats['retention_days']}") |
| |
| elif command == "cleanup": |
| cleaned = await backup_manager.cleanup_old_backups() |
| print(f"✅ Cleaned up {cleaned} old backups") |
| |
| elif command == "health": |
| health = await backup_health_check() |
| if health["healthy"]: |
| print("✅ Backup system is healthy") |
| else: |
| print("❌ Backup system health check failed") |
| print(f"Error: {health.get('error', 'Unknown error')}") |
| |
| else: |
| print("Usage: python backup_manager.py <command> [args]") |
| print("Commands: create, list, verify <id>, restore <id>, stats, cleanup, health") |
| |
| asyncio.run(main()) |
|
|