diff --git a/backend/app/tasks/backup.py b/backend/app/tasks/backup.py index 3c40afe..4f374b2 100644 --- a/backend/app/tasks/backup.py +++ b/backend/app/tasks/backup.py @@ -127,3 +127,96 @@ def backup_images_task(self, *, tag: str | None = None, } session.commit() return {"backup_run_id": run_id} + + +@celery.task( + name="backend.app.tasks.backup.restore_db_task", + bind=True, + max_retries=0, # NEVER auto-retry a half-applied restore. + soft_time_limit=1200, time_limit=1800, +) +def restore_db_task(self, *, source_backup_run_id: int) -> dict: + """Restore from a previous DB backup. Inserts a NEW BackupRun row + (kind='db', status='restoring') linked to the source via + restored_from_id; flips to 'restored' on success or 'error' on + failure. Operator sees the restore as a row in the dashboard.""" + SessionLocal = _sync_session_factory() + cfg = get_config() + now = datetime.now(UTC) + with SessionLocal() as session: + src = session.get(BackupRun, source_backup_run_id) + if src is None or src.kind != "db" or not src.sql_path: + raise ValueError( + f"BackupRun id={source_backup_run_id} is not a valid DB backup" + ) + marker = BackupRun( + kind="db", status="restoring", + triggered_by="restore", started_at=now, + restored_from_id=src.id, + manifest={"source_sql_path": src.sql_path}, + ) + session.add(marker); session.commit(); session.refresh(marker) + marker_id = marker.id + sql_path = src.sql_path + + try: + backup_service.restore_db( + db_url=cfg.database_url_sync, sql_path=Path(sql_path), + ) + except (SoftTimeLimitExceeded, Exception) as exc: + with SessionLocal() as session: + row = session.get(BackupRun, marker_id) + if row is not None: + _mark_failed(session, row, exc) + raise + + with SessionLocal() as session: + row = session.get(BackupRun, marker_id) + row.status = "restored" + row.finished_at = datetime.now(UTC) + session.commit() + return {"backup_run_id": marker_id} + + +@celery.task( + name="backend.app.tasks.backup.restore_images_task", + bind=True, max_retries=0, + soft_time_limit=21600, time_limit=23400, +) +def restore_images_task(self, *, source_backup_run_id: int) -> dict: + """Mirrors restore_db_task; uses backup_service.restore_images.""" + SessionLocal = _sync_session_factory() + now = datetime.now(UTC) + with SessionLocal() as session: + src = session.get(BackupRun, source_backup_run_id) + if src is None or src.kind != "images" or not src.tar_path: + raise ValueError( + f"BackupRun id={source_backup_run_id} is not a valid images backup" + ) + marker = BackupRun( + kind="images", status="restoring", + triggered_by="restore", started_at=now, + restored_from_id=src.id, + manifest={"source_tar_path": src.tar_path}, + ) + session.add(marker); session.commit(); session.refresh(marker) + marker_id = marker.id + tar_path = src.tar_path + + try: + backup_service.restore_images( + images_root=IMAGES_ROOT, tar_path=Path(tar_path), + ) + except (SoftTimeLimitExceeded, Exception) as exc: + with SessionLocal() as session: + row = session.get(BackupRun, marker_id) + if row is not None: + _mark_failed(session, row, exc) + raise + + with SessionLocal() as session: + row = session.get(BackupRun, marker_id) + row.status = "restored" + row.finished_at = datetime.now(UTC) + session.commit() + return {"backup_run_id": marker_id}