import os import hashlib import shutil from datetime import datetime, timezone from pathlib import Path from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.models import Job, JobExecution, Backup from backup.adapters import get_adapter class BackupEngine: def __init__(self, db: AsyncSession): self.db = db async def execute_job(self, job_id: int, triggered_by: str = "manual") -> JobExecution: # Create execution record execution = JobExecution( job_id=job_id, status="pending", triggered_by=triggered_by ) self.db.add(execution) await self.db.commit() await self.db.refresh(execution) try: # Load job with source result = await self.db.execute( select(Job).where(Job.id == job_id) ) job = result.scalar_one() # Update status to running execution.status = "running" execution.started_at = datetime.now(timezone.utc) await self.db.commit() # Determine strategy strategy = job.strategy parent_backup_id = None if strategy == "incremental": # Find last successful full backup result = await self.db.execute( select(Backup) .join(JobExecution) .where( JobExecution.job_id == job_id, JobExecution.status == "success", Backup.type == "full" ) .order_by(Backup.created_at.desc()) ) last_full = result.scalar_one_or_none() if last_full: parent_backup_id = last_full.id else: # No full backup exists, do full instead strategy = "full" # Create backup directory timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S") backup_dir = Path(job.destination_path) / str(job_id) / f"{timestamp}_{strategy}" backup_dir.mkdir(parents=True, exist_ok=True) # Get adapter and connect adapter = get_adapter(job.source.type, job.source.config) await adapter.connect() try: # Copy files total_processed = 0 total_backed_up = 0 source_path = Path(job.source.config.get("path", ".")) for item in source_path.rglob("*"): if item.is_file(): rel_path = item.relative_to(source_path) dest_path = backup_dir / "data" / rel_path dest_path.parent.mkdir(parents=True, exist_ok=True) # Copy file shutil.copy2(item, dest_path) size = item.stat().st_size total_processed += size total_backed_up += size # Calculate checksum checksum = await self._calculate_checksum(backup_dir) # Create backup record backup = Backup( execution_id=execution.id, storage_path=str(backup_dir), size_bytes=total_backed_up, checksum=checksum, type=strategy, parent_backup_id=parent_backup_id ) self.db.add(backup) # Update execution execution.status = "success" execution.completed_at = datetime.now(timezone.utc) execution.bytes_processed = total_processed execution.bytes_backed_up = total_backed_up finally: await adapter.disconnect() except Exception as e: execution.status = "failed" execution.completed_at = datetime.now(timezone.utc) execution.error_message = str(e) await self.db.commit() return execution async def _calculate_checksum(self, path: Path) -> str: hasher = hashlib.sha256() for item in sorted(path.rglob("*")): if item.is_file(): with open(item, "rb") as f: while chunk := f.read(8192): hasher.update(chunk) return hasher.hexdigest()