Initial commit: CloudRestoreAS v1.0.0 - Aplicación completa de restauración automática SQL Server
This commit is contained in:
324
app/engine/restore_worker.py
Normal file
324
app/engine/restore_worker.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""Worker para procesar jobs de restauración."""
|
||||
|
||||
import time
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from PySide6.QtCore import QObject, Signal, QRunnable
|
||||
|
||||
from ..constants import JobStatus, StepType
|
||||
from ..db.job_repository import JobRepository
|
||||
from ..db.job_step_repository import JobStepRepository
|
||||
from ..db.event_repository import EventRepository
|
||||
from ..db.node_repository import NodeRepository
|
||||
from ..extract.seven_zip import SevenZipExtractor
|
||||
from ..sql.sql_manager import SQLServerManager
|
||||
from ..utils.logger import app_logger
|
||||
|
||||
|
||||
class RestoreWorkerSignals(QObject):
|
||||
"""Señales para comunicación con la UI."""
|
||||
job_started = Signal(str) # job_id
|
||||
job_progress = Signal(str, str) # job_id, status
|
||||
job_completed = Signal(str, bool) # job_id, success
|
||||
error_occurred = Signal(str, str) # job_id, error
|
||||
|
||||
|
||||
class RestoreWorker(QRunnable):
|
||||
"""Worker que procesa un job de restauración."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
job_id: str,
|
||||
config: dict,
|
||||
dry_run: bool = False
|
||||
):
|
||||
"""
|
||||
Inicializa el worker.
|
||||
|
||||
Args:
|
||||
job_id: ID del job a procesar
|
||||
config: Configuración de la aplicación
|
||||
dry_run: Modo dry run (no ejecuta RESTORE)
|
||||
"""
|
||||
super().__init__()
|
||||
self.job_id = job_id
|
||||
self.config = config
|
||||
self.dry_run = dry_run
|
||||
self.signals = RestoreWorkerSignals()
|
||||
|
||||
self._extract_dir: Optional[str] = None
|
||||
self._bak_path: Optional[str] = None
|
||||
|
||||
def run(self):
|
||||
"""Ejecuta el procesamiento del job."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
app_logger.info(f"Iniciando procesamiento de job {self.job_id}")
|
||||
self.signals.job_started.emit(self.job_id)
|
||||
|
||||
# Obtener job
|
||||
job = JobRepository.get(self.job_id)
|
||||
if not job:
|
||||
raise ValueError(f"Job {self.job_id} no encontrado")
|
||||
|
||||
# Pipeline de procesamiento
|
||||
self._process_node_mapping(job)
|
||||
self._extract_backup(job)
|
||||
self._restore_database(job)
|
||||
self._cleanup(job)
|
||||
|
||||
# Actualizar tiempos
|
||||
total_ms = int((time.time() - start_time) * 1000)
|
||||
JobRepository.update_timing(self.job_id, total_ms=total_ms)
|
||||
|
||||
# Marcar como completado
|
||||
JobRepository.update_status(self.job_id, JobStatus.COMPLETED)
|
||||
|
||||
app_logger.info(
|
||||
f"Job {self.job_id} completado exitosamente en {total_ms}ms"
|
||||
)
|
||||
self.signals.job_completed.emit(self.job_id, True)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
app_logger.error(f"Error procesando job {self.job_id}: {error_msg}", exc_info=True)
|
||||
|
||||
JobRepository.update_status(
|
||||
self.job_id,
|
||||
JobStatus.FAILED,
|
||||
error=error_msg,
|
||||
increment_attempts=True
|
||||
)
|
||||
|
||||
EventRepository.create("ERROR", f"Job falló: {error_msg}", self.job_id)
|
||||
|
||||
self.signals.error_occurred.emit(self.job_id, error_msg)
|
||||
self.signals.job_completed.emit(self.job_id, False)
|
||||
|
||||
def _process_node_mapping(self, job):
|
||||
"""Procesa el mapeo de nodo a base de datos."""
|
||||
step_id = JobStepRepository.create(self.job_id, StepType.NODE_MAPPING)
|
||||
|
||||
try:
|
||||
# Obtener node name del archivo (con extensión, uppercase)
|
||||
node_name = Path(job.source_name).name.upper()
|
||||
|
||||
# Buscar mapeo en la tabla nodes
|
||||
db_name = NodeRepository.get_db_for_node(node_name)
|
||||
|
||||
if not db_name:
|
||||
raise ValueError(
|
||||
f"NODE_NOT_MAPPED: No existe mapeo activo para nodo '{node_name}'"
|
||||
)
|
||||
|
||||
# Actualizar job con node_name y db_name
|
||||
JobRepository.update_node_and_db(self.job_id, node_name, db_name)
|
||||
|
||||
app_logger.info(f"Node '{node_name}' mapeado a DB '{db_name}'")
|
||||
JobStepRepository.complete(step_id, exit_code=0, stdout=f"DB: {db_name}")
|
||||
|
||||
except Exception as e:
|
||||
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
|
||||
raise
|
||||
|
||||
def _extract_backup(self, job):
|
||||
"""Extrae el backup del archivo ZIP."""
|
||||
JobRepository.update_status(self.job_id, JobStatus.EXTRACTING)
|
||||
self.signals.job_progress.emit(self.job_id, JobStatus.EXTRACTING)
|
||||
|
||||
step_id = JobStepRepository.create(self.job_id, StepType.EXTRACT)
|
||||
extract_start = time.time()
|
||||
|
||||
try:
|
||||
# Configuración
|
||||
seven_zip_path = self.config["paths"]["seven_zip_exe"]
|
||||
extract_base = self.config["paths"]["extract_folder"]
|
||||
timeout_minutes = self.config["timeouts"]["extract_minutes"]
|
||||
|
||||
# Crear carpeta de extracción para este job
|
||||
self._extract_dir = str(Path(extract_base) / self.job_id)
|
||||
Path(self._extract_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Extraer
|
||||
extractor = SevenZipExtractor(seven_zip_path)
|
||||
|
||||
# Si es multipart, asegurar que usamos el primer archivo
|
||||
source_path = job.source_path
|
||||
if extractor.is_multipart(source_path):
|
||||
source_path = extractor.get_first_part(source_path)
|
||||
app_logger.info(f"Archivo multipart detectado, usando: {source_path}")
|
||||
|
||||
exit_code, stdout, stderr = extractor.extract(
|
||||
source_path,
|
||||
self._extract_dir,
|
||||
timeout_minutes
|
||||
)
|
||||
|
||||
extract_ms = int((time.time() - extract_start) * 1000)
|
||||
JobRepository.update_timing(self.job_id, extract_ms=extract_ms)
|
||||
|
||||
if exit_code != 0:
|
||||
raise RuntimeError(f"7-Zip falló con código {exit_code}: {stderr}")
|
||||
|
||||
JobStepRepository.complete(
|
||||
step_id,
|
||||
exit_code=exit_code,
|
||||
stdout=stdout[:1000] if stdout else None,
|
||||
stderr=stderr[:1000] if stderr else None
|
||||
)
|
||||
|
||||
# Buscar archivo .bak
|
||||
locate_step_id = JobStepRepository.create(self.job_id, StepType.LOCATE_BAK)
|
||||
|
||||
self._bak_path = SevenZipExtractor.find_bak_file(self._extract_dir)
|
||||
|
||||
if not self._bak_path:
|
||||
raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído")
|
||||
|
||||
JobStepRepository.complete(
|
||||
locate_step_id,
|
||||
exit_code=0,
|
||||
stdout=f"BAK: {Path(self._bak_path).name}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
|
||||
raise
|
||||
|
||||
def _restore_database(self, job):
|
||||
"""Restaura la base de datos desde el backup."""
|
||||
JobRepository.update_status(self.job_id, JobStatus.RESTORING)
|
||||
self.signals.job_progress.emit(self.job_id, JobStatus.RESTORING)
|
||||
|
||||
# Recargar job para obtener db_name actualizado
|
||||
job = JobRepository.get(self.job_id)
|
||||
|
||||
if not job.db_name:
|
||||
raise ValueError("DB name no está configurado en el job")
|
||||
|
||||
# Conectar a SQL
|
||||
connect_step_id = JobStepRepository.create(self.job_id, StepType.SQL_CONNECT)
|
||||
|
||||
try:
|
||||
sql_config = self.config["sql"]
|
||||
sql_manager = SQLServerManager(
|
||||
server=sql_config["server"],
|
||||
use_windows_auth=sql_config["use_windows_auth"],
|
||||
username=sql_config.get("username"),
|
||||
password=sql_config.get("password")
|
||||
)
|
||||
|
||||
# Test connection
|
||||
success, error = sql_manager.test_connection()
|
||||
if not success:
|
||||
raise RuntimeError(f"Conexión SQL falló: {error}")
|
||||
|
||||
JobStepRepository.complete(connect_step_id, exit_code=0)
|
||||
|
||||
except Exception as e:
|
||||
JobStepRepository.complete(connect_step_id, exit_code=1, error=str(e))
|
||||
raise
|
||||
|
||||
# Obtener FILELISTONLY
|
||||
filelist_step_id = JobStepRepository.create(self.job_id, StepType.FILELIST)
|
||||
filelist_start = time.time()
|
||||
|
||||
try:
|
||||
logical_files, error = sql_manager.get_filelist_from_backup(
|
||||
self._bak_path,
|
||||
timeout_minutes=self.config["timeouts"]["restore_minutes"]
|
||||
)
|
||||
|
||||
filelist_ms = int((time.time() - filelist_start) * 1000)
|
||||
JobRepository.update_timing(self.job_id, filelist_ms=filelist_ms)
|
||||
|
||||
if error:
|
||||
raise RuntimeError(error)
|
||||
|
||||
files_info = ", ".join([f"{lf.logical_name}({lf.type})" for lf in logical_files])
|
||||
JobStepRepository.complete(
|
||||
filelist_step_id,
|
||||
exit_code=0,
|
||||
stdout=files_info[:1000]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
JobStepRepository.complete(filelist_step_id, exit_code=1, error=str(e))
|
||||
raise
|
||||
|
||||
# RESTORE DATABASE
|
||||
restore_step_id = JobStepRepository.create(self.job_id, StepType.RESTORE)
|
||||
restore_start = time.time()
|
||||
|
||||
try:
|
||||
data_folder = self.config["paths"]["data_sql_folder"]
|
||||
|
||||
success, stdout, error = sql_manager.restore_database(
|
||||
db_name=job.db_name,
|
||||
backup_path=self._bak_path,
|
||||
data_folder=data_folder,
|
||||
logical_files=logical_files,
|
||||
timeout_minutes=self.config["timeouts"]["restore_minutes"],
|
||||
dry_run=self.dry_run
|
||||
)
|
||||
|
||||
restore_ms = int((time.time() - restore_start) * 1000)
|
||||
JobRepository.update_timing(self.job_id, restore_ms=restore_ms)
|
||||
|
||||
if not success:
|
||||
raise RuntimeError(error or "RESTORE falló sin mensaje de error")
|
||||
|
||||
JobStepRepository.complete(
|
||||
restore_step_id,
|
||||
exit_code=0,
|
||||
stdout=stdout[:1000] if stdout else None
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
JobStepRepository.complete(restore_step_id, exit_code=1, error=str(e))
|
||||
raise
|
||||
|
||||
def _cleanup(self, job):
|
||||
"""Limpia archivos temporales y mueve el ZIP."""
|
||||
JobRepository.update_status(self.job_id, JobStatus.CLEANING)
|
||||
self.signals.job_progress.emit(self.job_id, JobStatus.CLEANING)
|
||||
|
||||
step_id = JobStepRepository.create(self.job_id, StepType.CLEANUP)
|
||||
|
||||
try:
|
||||
# Eliminar carpeta de extracción
|
||||
if self._extract_dir and Path(self._extract_dir).exists():
|
||||
shutil.rmtree(self._extract_dir)
|
||||
app_logger.info(f"Carpeta de extracción eliminada: {self._extract_dir}")
|
||||
|
||||
# Mover ZIP a Processed
|
||||
processed_folder = Path(self.config["paths"]["processed_folder"])
|
||||
date_folder = processed_folder / datetime.now().strftime("%Y-%m-%d")
|
||||
date_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source_path = Path(job.source_path)
|
||||
dest_path = date_folder / source_path.name
|
||||
|
||||
# Si es multipart, mover todas las partes
|
||||
if SevenZipExtractor.is_multipart(str(source_path)):
|
||||
# Buscar todas las partes
|
||||
base_name = source_path.stem.split('.zip')[0]
|
||||
parts = list(source_path.parent.glob(f"{base_name}.zip.*"))
|
||||
|
||||
for part in parts:
|
||||
part_dest = date_folder / part.name
|
||||
shutil.move(str(part), str(part_dest))
|
||||
app_logger.info(f"Movido: {part.name} -> {part_dest}")
|
||||
else:
|
||||
shutil.move(str(source_path), str(dest_path))
|
||||
app_logger.info(f"Movido: {source_path.name} -> {dest_path}")
|
||||
|
||||
JobStepRepository.complete(step_id, exit_code=0)
|
||||
|
||||
except Exception as e:
|
||||
# No fallar el job por errores de limpieza
|
||||
app_logger.warning(f"Error en limpieza (no crítico): {e}")
|
||||
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
|
||||
Reference in New Issue
Block a user