feature/generador-instaladores-linux-windows
This commit is contained in:
@@ -146,11 +146,23 @@ class RestoreWorker(QRunnable):
|
||||
EventRepository.create("ERROR", f"Job falló: {error_msg}", self.job_id)
|
||||
|
||||
job = JobRepository.get(self.job_id)
|
||||
# Mover el ZIP fallido a Fallados/<fecha>/ para diagnóstico y descarga desde el panel.
|
||||
try:
|
||||
if job:
|
||||
self._move_zip_to_failed(job)
|
||||
except Exception as move_err:
|
||||
app_logger.warning(f"No se pudo mover ZIP a Fallados: {move_err}")
|
||||
|
||||
self._report_to_panel(job, "failed", error_message=error_msg)
|
||||
|
||||
self.signals.error_occurred.emit(self.job_id, error_msg)
|
||||
self.signals.job_completed.emit(self.job_id, False)
|
||||
|
||||
finally:
|
||||
# Garantiza la limpieza del Temp en TODOS los caminos (éxito, fallo, diferido,
|
||||
# forward). En éxito _cleanup ya lo eliminó; aquí es red de seguridad.
|
||||
self._purge_extract_dir()
|
||||
|
||||
def _resolve_route(self, job) -> dict:
|
||||
"""
|
||||
Enrutamiento automático vía panel: restore_local o forward según nodo/asignación.
|
||||
@@ -185,12 +197,41 @@ class RestoreWorker(QRunnable):
|
||||
return route
|
||||
|
||||
def _collect_zip_paths(self, source_path: str) -> list[str]:
|
||||
"""Rutas locales del ZIP (incluye todas las partes multipart)."""
|
||||
"""
|
||||
Rutas locales del ZIP (incluye todas las partes multipart).
|
||||
|
||||
La comparación es insensible a mayúsculas en TODOS los pasos, porque en este dominio
|
||||
los respaldos llegan como .ZIP con frecuencia. Antes se hacía
|
||||
`path.stem.split(".zip")[0]`, que con "EMPRESA.ZIP.001" dejaba base_name="EMPRESA.ZIP"
|
||||
y armaba el glob "EMPRESA.ZIP.zip.*": no encontraba nada y devolvía lista vacía. Como
|
||||
_move_zip_to_processed y _move_zip_to_failed iteran sobre este resultado, las partes
|
||||
nunca salían de Entrada y se acumulaban mezcladas con los pendientes.
|
||||
|
||||
Tampoco se usa glob(): en Linux distingue mayúsculas, así que un patrón en minúsculas
|
||||
seguiría sin encontrar las partes en MAYÚSCULAS. Se filtra iterdir() comparando en
|
||||
minúsculas, que funciona igual en Windows y en Linux.
|
||||
"""
|
||||
path = Path(source_path)
|
||||
if SevenZipExtractor.is_multipart(str(path)):
|
||||
base_name = path.stem.split(".zip")[0]
|
||||
parts = sorted(path.parent.glob(f"{base_name}.zip.*"))
|
||||
return [str(p) for p in parts]
|
||||
if not SevenZipExtractor.is_multipart(str(path)):
|
||||
return [str(path)]
|
||||
|
||||
# "EMPRESA.ZIP.001" -> stem "EMPRESA.ZIP" -> base "EMPRESA" (sin importar la caja).
|
||||
stem = path.stem
|
||||
base_name = stem[:-4] if stem.lower().endswith(".zip") else stem
|
||||
prefix = f"{base_name}.zip.".lower()
|
||||
|
||||
parts = sorted(
|
||||
(item for item in path.parent.iterdir() if item.name.lower().startswith(prefix)),
|
||||
key=lambda item: item.name.lower(),
|
||||
)
|
||||
if parts:
|
||||
return [str(item) for item in parts]
|
||||
|
||||
# Sin partes localizadas se devuelve el archivo original: es preferible mover solo esa
|
||||
# parte a no mover nada y dejarla atorada en Entrada para siempre.
|
||||
app_logger.warning(
|
||||
f"No se localizaron las partes multipart de {path.name}; se usa solo ese archivo"
|
||||
)
|
||||
return [str(path)]
|
||||
|
||||
def _move_zip_to_processed(self, job):
|
||||
@@ -205,15 +246,53 @@ class RestoreWorker(QRunnable):
|
||||
shutil.move(str(src), str(dest))
|
||||
app_logger.info(f"Movido: {src.name} -> {dest}")
|
||||
|
||||
def _move_zip_to_failed(self, job):
|
||||
"""Mueve el ZIP (y partes multipart) a Fallados/<fecha>/ para diagnóstico y descarga.
|
||||
|
||||
Antes los fallidos quedaban en Entrada (mezclados con pendientes y bloqueando el
|
||||
pickup por dedup). Al moverlos a Fallados, el panel puede listarlos/descargarlos por
|
||||
restaurador con la misma lógica relativa que Procesados.
|
||||
"""
|
||||
failed_folder = Path(self.config["paths"]["failed_folder"])
|
||||
date_folder = failed_folder / datetime.now().strftime("%Y-%m-%d")
|
||||
date_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for zip_path in self._collect_zip_paths(job.source_path):
|
||||
src = Path(zip_path)
|
||||
if not src.exists():
|
||||
continue
|
||||
dest = date_folder / src.name
|
||||
shutil.move(str(src), str(dest))
|
||||
app_logger.info(f"Movido a Fallados: {src.name} -> {dest}")
|
||||
|
||||
def _purge_extract_dir(self):
|
||||
"""Elimina la carpeta temporal de extracción si quedó (best-effort, cualquier salida).
|
||||
|
||||
_cleanup() solo corre en éxito; sin esto, los caminos de fallo/diferido dejan
|
||||
`Temp/<job_id>/` huérfano acumulándose. Se invoca en el `finally` de run().
|
||||
"""
|
||||
try:
|
||||
if self._extract_dir and Path(self._extract_dir).exists():
|
||||
shutil.rmtree(self._extract_dir, ignore_errors=True)
|
||||
app_logger.info(f"Temp de extracción purgado: {self._extract_dir}")
|
||||
except Exception as e:
|
||||
app_logger.warning(f"No se pudo purgar Temp {self._extract_dir}: {e}")
|
||||
|
||||
def _forward_zip(self, job, route: dict, start_time: float):
|
||||
"""Reenvía el ZIP al input_folder del servidor destino vía SFTP."""
|
||||
"""Reenvía el ZIP al input_folder del servidor destino vía SFTP.
|
||||
|
||||
La entrega exitosa por SFTP es el punto de no retorno: en cuanto la subida se confirma,
|
||||
el job se marca COMPLETED/forwarded ANTES de cualquier tarea de limpieza local. Así, un
|
||||
error POSTERIOR a la entrega (p.ej. mover el ZIP a Procesados) ya no degrada el job a
|
||||
fallido ni reporta 'failed' al panel — el respaldo sí llegó al destino.
|
||||
"""
|
||||
target = route["target"]
|
||||
self._target = target
|
||||
remote_folder = target["input_folder"]
|
||||
zip_paths = self._collect_zip_paths(job.source_path)
|
||||
|
||||
step_id = JobStepRepository.create(self.job_id, StepType.FORWARD_ZIP)
|
||||
try:
|
||||
zip_paths = self._collect_zip_paths(job.source_path)
|
||||
remote_folder = target["input_folder"]
|
||||
uploaded = sftp_copy.upload_zip_parts(zip_paths, target, remote_folder)
|
||||
JobStepRepository.complete(
|
||||
step_id,
|
||||
@@ -221,15 +300,18 @@ class RestoreWorker(QRunnable):
|
||||
stdout=f"Destino: {target.get('name')} ({len(uploaded)} archivo(s))",
|
||||
)
|
||||
except Exception as e:
|
||||
# Envío parcial: limpia best-effort las partes ya subidas para no dejar una
|
||||
# restauración a medias en el destino, marca el step fallido y re-lanza.
|
||||
partial = getattr(e, "uploaded", None)
|
||||
if partial:
|
||||
self._cleanup_partial_forward(target, partial)
|
||||
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
|
||||
raise
|
||||
|
||||
self._move_zip_to_processed(job)
|
||||
|
||||
# --- Entrega confirmada: commit del éxito ANTES de cualquier limpieza local ---
|
||||
total_ms = int((time.time() - start_time) * 1000)
|
||||
JobRepository.update_timing(self.job_id, total_ms=total_ms)
|
||||
JobRepository.update_status(self.job_id, JobStatus.COMPLETED)
|
||||
|
||||
app_logger.info(
|
||||
f"Job {self.job_id} reenviado a '{target.get('name')}' en {total_ms}ms"
|
||||
)
|
||||
@@ -239,8 +321,33 @@ class RestoreWorker(QRunnable):
|
||||
self.job_id,
|
||||
)
|
||||
self._report_to_panel(job, "forwarded", duration_ms=total_ms)
|
||||
|
||||
# Housekeeping best-effort: si el move falla, el job SIGUE siendo forwarded y el ZIP
|
||||
# queda en Entrada (bloqueado por dedup de hash 'completed', no se reenvía en bucle).
|
||||
try:
|
||||
self._move_zip_to_processed(job)
|
||||
except Exception as e:
|
||||
app_logger.warning(
|
||||
f"Reenvío OK pero no se pudo mover el ZIP a Procesados: {e}"
|
||||
)
|
||||
EventRepository.create(
|
||||
"WARNING",
|
||||
f"Reenvío exitoso; el ZIP quedó en Entrada (no se pudo mover a Procesados): {e}",
|
||||
self.job_id,
|
||||
)
|
||||
|
||||
self.signals.job_completed.emit(self.job_id, True)
|
||||
|
||||
def _cleanup_partial_forward(self, target: dict, uploaded: list) -> None:
|
||||
"""Borra best-effort del destino las partes ya subidas tras un fallo de reenvío."""
|
||||
for remote_path in uploaded:
|
||||
try:
|
||||
sftp_copy.cleanup_remote(target, remote_path)
|
||||
except Exception as e:
|
||||
app_logger.warning(
|
||||
f"No se pudo limpiar la parte remota {remote_path}: {e}"
|
||||
)
|
||||
|
||||
def _report_to_panel(
|
||||
self,
|
||||
job,
|
||||
@@ -479,28 +586,9 @@ class RestoreWorker(QRunnable):
|
||||
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}")
|
||||
|
||||
# Mover ZIP (y partes multipart) a Processed y registrar rel_path/tamaño.
|
||||
self._move_zip_to_processed(job)
|
||||
|
||||
JobStepRepository.complete(step_id, exit_code=0)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user