""" Config Backup Executor Backs up Docker container configs and host-based service configs. Replicates functionality of maintenance container's backup-configs.sh """ import asyncio import logging import tarfile import tempfile from datetime import datetime, timedelta from pathlib import Path from typing import List, Dict, Any from src.config import Settings from src.executors import health_report logger = logging.getLogger(__name__) async def _run(config: dict, settings: Settings) -> str: """ Execute config backup task. Config schema: { "sources": [ { "path": "/data/docker-data", "name": "docker-data", "excludes": ["*/cache/*", "*/temp/*", "*.log"] } ], "backup_dir": "/backups/docker-configs", "retention_days": 30, "compress": true } Args: config: Backup configuration settings: Global scheduler settings Returns: Summary of backup operation Raises: Exception: On backup failure """ sources = config.get('sources', []) backup_dir = Path(config.get('backup_dir', '/backups/docker-configs')) retention_days = config.get('retention_days', 30) compress = config.get('compress', True) if not sources: raise ValueError("No backup sources configured") timestamp = datetime.now().strftime('%Y%m%d-%H%M%S') backup_filename = f"docker-configs-{timestamp}.tar.gz" backup_file = backup_dir / backup_filename logger.info(f"Starting Docker configs backup: {backup_filename}") # Create backup directory backup_dir.mkdir(parents=True, exist_ok=True) # Create temporary directory for staging with tempfile.TemporaryDirectory(prefix='backup-') as temp_dir: temp_path = Path(temp_dir) results = [] # Backup each source for source in sources: source_path = Path(source['path']) source_name = source['name'] excludes = source.get('excludes', []) if not source_path.exists(): logger.warning(f"Source path does not exist: {source_path}") continue logger.info(f"Backing up {source_name} from {source_path}") # Create tar for this source source_tar = temp_path / f"{source_name}.tar.gz" def tar_filter(tarinfo): """Filter function to exclude patterns.""" for pattern in excludes: # Simple pattern matching (could be enhanced with fnmatch) if pattern.replace('*/', '').replace('/*', '') in tarinfo.name: logger.debug(f"Excluding: {tarinfo.name}") return None return tarinfo with tarfile.open(source_tar, 'w:gz') as tar: tar.add( source_path, arcname=source_name, filter=tar_filter, recursive=True ) source_size = source_tar.stat().st_size / (1024 * 1024) # MB results.append(f"{source_name}: {source_size:.2f}MB") logger.info(f"Backed up {source_name}: {source_size:.2f}MB") # Combine all source backups into final archive logger.info("Creating combined backup archive...") with tarfile.open(backup_file, 'w:gz') as final_tar: for item in temp_path.glob('*.tar.gz'): final_tar.add(item, arcname=item.name) # Verify backup created if not backup_file.exists(): raise Exception("Backup file was not created") backup_size = backup_file.stat().st_size / (1024 * 1024) # MB logger.info(f"Backup created successfully: {backup_size:.2f}MB") # Clean up old backups await cleanup_old_backups(backup_dir, retention_days) # Count remaining backups backup_count = len(list(backup_dir.glob('docker-configs-*.tar.gz'))) total_size = sum(f.stat().st_size for f in backup_dir.glob('docker-configs-*.tar.gz')) total_size_mb = total_size / (1024 * 1024) output = ( f"Backup completed: {backup_filename} ({backup_size:.2f}MB). " f"Sources: {', '.join(results)}. " f"Retention: {backup_count} backups, {total_size_mb:.2f}MB total." ) logger.info(output) return output async def cleanup_old_backups(backup_dir: Path, retention_days: int): """Remove backups older than retention period.""" cutoff_date = datetime.now() - timedelta(days=retention_days) removed_count = 0 removed_size = 0 logger.info(f"Cleaning up backups older than {retention_days} days...") for backup_file in backup_dir.glob('docker-configs-*.tar.gz'): # Get file modification time file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime) if file_mtime < cutoff_date: file_size = backup_file.stat().st_size logger.info(f"Removing old backup: {backup_file.name} (from {file_mtime:%Y-%m-%d})") backup_file.unlink() removed_count += 1 removed_size += file_size if removed_count > 0: removed_size_mb = removed_size / (1024 * 1024) logger.info(f"Removed {removed_count} old backups, freed {removed_size_mb:.2f}MB") else: logger.info("No old backups to remove") async def execute(config: dict, settings: Settings) -> str: """Run the backup and report its own outcome to check_history (D-33, T-69). The report wraps the work rather than living inside it, so the failure path cannot be forgotten: an exception is reported as critical and then re-raised, leaving the task's own status untouched. Reporting only success would reproduce exactly the blind spot this replaces — a monitor that cannot tell a failed backup from one that has not run. """ try: output = await _run(config, settings) except Exception as exc: health_report.report( settings, domain="backup", status=health_report.CRITICAL, source="scheduler/config_backup_executor", metrics={"job": "scheduler/config_backup_executor", "error": str(exc)[:400]}, ) raise health_report.report( settings, domain="backup", status=health_report.OK, source="scheduler/config_backup_executor", metrics={"job": "scheduler/config_backup_executor", "summary": output[:400]}, ) return output