feature/generador-instaladores-linux-windows

This commit is contained in:
2026-07-30 07:34:17 -06:00
parent cafe3f1b87
commit c3f1d70e23
34 changed files with 3498 additions and 252 deletions

View File

@@ -2,18 +2,21 @@
import platform
import socket
from threading import Thread
from typing import Optional
from pathlib import Path
from PySide6.QtCore import QObject, Signal, QThreadPool
from .file_watcher import FileWatcher, FileStabilityChecker, calculate_file_hash
from .maintenance_scheduler import DailyMaintenanceScheduler
from .restore_worker import RestoreWorker
from .retention import RetentionCleaner
from .. import __version__
from ..db.job_repository import JobRepository
from ..db.event_repository import EventRepository
from ..db.config_repository import ConfigRepository
from ..config.env_loader import apply_env_overrides, load_env_file
from ..constants import JobStatus, DEFAULT_CONFIG
from ..constants import APP_ARCH, APP_DIR, APP_PLATFORM, JobStatus, DEFAULT_CONFIG
from ..panel import panel_client
from ..utils.logger import app_logger
@@ -39,7 +42,10 @@ class RestoreEngine(QObject):
# File watcher
self._file_watcher: Optional[FileWatcher] = None
# Mantenimiento diario (retención de respaldos obsoletos)
self._maintenance: Optional[DailyMaintenanceScheduler] = None
# Estado
self._running = False
self._paused = False
@@ -75,9 +81,10 @@ class RestoreEngine(QObject):
app_logger.info("Configuración guardada")
self._report_instance_config_to_panel()
# Reconfigurar file watcher si está corriendo
# Reconfigurar file watcher y mantenimiento si está corriendo
if self._running:
self._restart_file_watcher()
self._restart_maintenance()
def get_config(self) -> dict:
"""Obtiene la configuración actual."""
@@ -93,7 +100,12 @@ class RestoreEngine(QObject):
if not self._validate_config():
app_logger.error("Configuración inválida, no se puede iniciar el motor")
return
# Barrido de Temp: al arrancar no hay jobs corriendo, así que cualquier subcarpeta en
# extract_folder es un remanente huérfano de una corrida previa (los caminos de
# fallo/diferido no siempre limpiaban). Se elimina para que Temp no se acumule.
self._purge_stale_temp()
# Configurar thread pool
extract_workers = self._config["concurrency"]["extract_workers"]
restore_workers = self._config["concurrency"]["restore_workers"]
@@ -108,7 +120,10 @@ class RestoreEngine(QObject):
# Iniciar file watcher
if self._config["features"]["auto_scan_enabled"]:
self._start_file_watcher()
# Iniciar el mantenimiento diario (limpieza de respaldos obsoletos)
self._start_maintenance()
self._running = True
self._paused = False
self._report_instance_config_to_panel()
@@ -116,6 +131,28 @@ class RestoreEngine(QObject):
EventRepository.create("INFO", "Motor iniciado")
app_logger.info("Motor iniciado")
def _purge_stale_temp(self):
"""Elimina subcarpetas huérfanas en la carpeta temporal de extracción (al arrancar)."""
import shutil
extract_folder = (self._config.get("paths") or {}).get("extract_folder")
if not extract_folder:
return
base = Path(extract_folder)
if not base.is_dir():
return
removed = 0
for child in base.iterdir():
if not child.is_dir():
continue
try:
shutil.rmtree(child, ignore_errors=True)
removed += 1
except Exception as e:
app_logger.warning(f"No se pudo limpiar Temp huérfano {child}: {e}")
if removed:
app_logger.info(f"Temp: {removed} carpeta(s) huérfana(s) eliminada(s) al iniciar")
def stop(self):
"""Detiene el motor."""
if not self._running:
@@ -125,7 +162,10 @@ class RestoreEngine(QObject):
if self._file_watcher:
self._file_watcher.stop()
self._file_watcher = None
# Detener el mantenimiento diario
self._stop_maintenance()
# Esperar a que terminen los workers
self._thread_pool.waitForDone(msecs=30000) # 30s timeout
@@ -207,28 +247,45 @@ class RestoreEngine(QObject):
if not api_url or not api_token:
return
input_folder = (self._config.get("paths") or {}).get("input_folder") or ""
paths = self._config.get("paths") or {}
input_folder = paths.get("input_folder") or ""
processed_folder = paths.get("processed_folder") or ""
try:
host_name = socket.gethostname() or platform.node()
except Exception:
host_name = platform.node()
instance_key = (panel_cfg.get("instance_key") or "").strip() or None
# platform/arch le dicen al PANEL qué artefacto le toca a este servidor cuando
# instala o actualiza (a24c.cras_releases se llavea por version+platform+arch).
panel_client.report_instance_config(
api_url=api_url,
api_token=api_token,
input_folder=input_folder,
processed_folder=processed_folder,
host_name=host_name,
app_version=__version__,
instance_key=instance_key,
platform_name=APP_PLATFORM,
arch=APP_ARCH,
# Dónde vive el ejecutable. El PANEL actualiza en esta ruta; si instalara en la
# default crearía una segunda instalación y dejaría huérfano este config/.env.
install_path=str(APP_DIR),
)
def _validate_config(self) -> bool:
"""Valida que la configuración sea correcta."""
paths = self._config["paths"]
# Validar carpetas requeridas
required_paths = ["input_folder", "extract_folder", "data_sql_folder"]
# Validar carpetas requeridas. processed_folder/failed_folder se incluyen para que la
# reubicación de ZIP (éxito/fallo) nunca falle por carpeta inexistente.
required_paths = [
"input_folder",
"extract_folder",
"data_sql_folder",
"processed_folder",
"failed_folder",
]
for key in required_paths:
if not paths.get(key):
app_logger.error(f"Falta configurar: {key}")
@@ -280,6 +337,52 @@ class RestoreEngine(QObject):
"""Reinicia el file watcher con nueva configuración."""
if self._running and not self._paused:
self._start_file_watcher()
def _start_maintenance(self):
"""Inicia el mantenimiento diario si la retención está habilitada."""
retention_cfg = self._config.get("retention", {})
if not retention_cfg.get("enabled", True):
app_logger.info("Retención deshabilitada; no se inicia el mantenimiento diario")
return
self._maintenance = DailyMaintenanceScheduler(
task=self._run_retention,
check_interval_seconds=retention_cfg.get("check_interval_seconds", 3600),
run_at_hour=retention_cfg.get("run_at_hour", 3),
)
self._maintenance.start()
def _stop_maintenance(self):
"""Detiene el mantenimiento diario."""
if self._maintenance:
self._maintenance.stop()
self._maintenance = None
def _restart_maintenance(self):
"""Reinicia el mantenimiento diario con nueva configuración."""
if self._running:
self._stop_maintenance()
self._start_maintenance()
def _run_retention(self):
"""Ejecuta una corrida de retención (tarea del mantenimiento diario)."""
try:
RetentionCleaner(self._config).run()
except Exception as e:
app_logger.error(f"Error en la retención: {e}", exc_info=True)
EventRepository.create("ERROR", f"Error en la retención: {e}")
def trigger_maintenance_now(self):
"""Corre la retención de inmediato en segundo plano (acción manual de la UI).
Respeta el dry_run de la configuración y no consume el turno del día calendario.
"""
def _run():
if self._maintenance:
self._maintenance.trigger_now()
else:
self._run_retention()
Thread(target=_run, daemon=True).start()
def _on_file_ready(self, file_path: str):
"""
@@ -296,10 +399,18 @@ class RestoreEngine(QObject):
# Calcular hash para evitar duplicados
file_hash = calculate_file_hash(file_path)
if JobRepository.exists_by_hash(file_hash):
app_logger.warning(f"Archivo ya procesado (hash duplicado): {file_path}")
if JobRepository.has_blocking_job_by_hash(file_hash):
app_logger.warning(f"Archivo ya procesado o en curso (hash duplicado): {file_path}")
return
# Limpia intentos FALLIDOS previos con este hash para permitir un reintento fresco
# (antes un FAILED transitorio bloqueaba el reproceso de forma permanente).
removed = JobRepository.delete_failed_by_hash(file_hash)
if removed:
app_logger.info(
f"Reintento de {Path(file_path).name}: {removed} job(s) fallido(s) previo(s) eliminado(s)"
)
# Crear job
file_name = Path(file_path).name
job_id = JobRepository.create(file_path, file_name, file_hash)