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

@@ -1,4 +1,8 @@
"""CloudRestoreAS - Aplicación de restauración automática de bases de datos SQL Server."""
__version__ = "1.0.0"
# Fuente ÚNICA de la versión. La leen: el spec de PyInstaller (metadatos del .exe),
# package-release.sh (nombres de artefacto y release.json) y el reporte al PANEL.
# Formato obligatorio: puntos y números, monotónico creciente — el PANEL compara
# versiones como tuplas de enteros para detectar si hay una más nueva.
__version__ = "1.1.0"
__author__ = "Aduanasoft"

View File

@@ -1,14 +1,18 @@
"""Creación automática de config/ y carpetas de trabajo."""
import hashlib
import shutil
import sys
from pathlib import Path
from typing import Optional
from .. import __version__
from ..constants import (
APP_DIR,
BUNDLE_DIR,
BUNDLED_SOURCE_7ZIP,
BUNDLED_SOURCE_ODBC,
BUNDLED_STAMP_PATH,
CONFIG_DIR,
DATA_DIR,
DIR_ENTRADA,
@@ -20,19 +24,100 @@ from ..constants import (
LOGS_DIR,
ODBC_DIR,
SEVEN_ZIP_DIR,
VERSION_PATH,
)
from ..db.database import DatabaseManager
from .env_loader import render_env_template
def _copy_tree_if_missing(src: Path, dest: Path) -> None:
def _bundled_resource(rel_path: str) -> Optional[Path]:
"""
Recurso empaquetado: primero dentro del onefile (BUNDLE_DIR), luego junto al código
en desarrollo. Mismo orden de preferencia que usa _write_env_if_missing.
"""
for base in (BUNDLE_DIR, APP_DIR):
candidate = base / rel_path
if candidate.is_file():
return candidate
return None
def _read_stamp(path: Path) -> str:
try:
return path.read_text(encoding="utf-8").strip()
except OSError:
return ""
def _write_stamp(path: Path, content: str) -> None:
"""Escribe un sello solo si cambió, para no tocar disco en cada arranque."""
value = content.strip()
if _read_stamp(path) == value:
return
try:
path.write_text(value, encoding="utf-8")
except OSError:
# Los sellos son informativos: si config/ no es escribible, el arranque sigue.
# La consecuencia es re-copiar las deps embebidas en el próximo arranque.
pass
def _bundled_deps_stamp() -> str:
"""
Huella de las dependencias embebidas en ESTE build: el sha256 de
packaging/bundled-versions.json, que es el archivo que fija las versiones de 7-Zip,
del driver ODBC y de unixODBC. Si no viene empaquetado, cae a la versión de la app.
"""
manifest = _bundled_resource("packaging/bundled-versions.json")
if manifest is not None:
try:
return "sha256:" + hashlib.sha256(manifest.read_bytes()).hexdigest()
except OSError:
pass
return f"app:{__version__}"
def _backup_before_refresh(dest: Path) -> None:
"""
Aparta el contenido actual a `<dest>.bak` antes de re-copiarlo.
El re-despliegue usa copytree(dirs_exist_ok=True), que SOBRESCRIBE los archivos: si el
operador editó a mano algo como odbcinst.ini, se perdería en la primera ejecución tras
actualizar y sin aviso. Esa edición no se puede reconstruir, así que se conserva.
Un solo respaldo rotatorio, sin fecha: estas carpetas viven en servidores que almacenan
respaldos de bases y no conviene acumular una copia del driver ODBC por cada actualización.
"""
backup = dest.with_name(f"{dest.name}.bak")
try:
if backup.exists():
shutil.rmtree(backup, ignore_errors=True)
shutil.copytree(dest, backup, dirs_exist_ok=True)
except OSError:
# El respaldo es una red de seguridad, no un requisito: si el disco no da o los
# permisos no alcanzan, el refresco debe seguir su curso.
pass
def _copy_bundled_tree(src: Path, dest: Path, refresh: bool) -> None:
"""
Despliega 7-Zip/ODBC del bundle a config/. Copia si el destino está vacío y RE-copia
cuando refresh es True, es decir cuando este build trae otras versiones embebidas:
una actualización con driver ODBC nuevo debe reemplazar el viejo, no conservarlo.
Antes de re-copiar sobre contenido existente se aparta una copia a `<dest>.bak`, porque
el copytree sobrescribe y las ediciones manuales del operador no son reconstruibles.
dest puede existir pero VACÍO: ensure_runtime_layout crea ODBC_DIR/SEVEN_ZIP_DIR
antes de llamar aquí, así que la condición mira el contenido, no la existencia.
"""
if not src.is_dir():
return
# dest puede existir pero VACÍO: ensure_runtime_layout crea ODBC_DIR/SEVEN_ZIP_DIR
# antes de llamar aquí. Solo saltar si ya tiene contenido (evita re-copiar en cada
# arranque). Antes se saltaba por dest.exists(), dejando 7-Zip/ODBC sin desplegar.
if dest.exists() and any(dest.iterdir()):
has_content = dest.exists() and any(dest.iterdir())
if has_content and not refresh:
return
if has_content and refresh:
_backup_before_refresh(dest)
dest.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dest, dirs_exist_ok=True)
@@ -92,9 +177,25 @@ def ensure_runtime_layout() -> None:
_write_env_if_missing()
_copy_tree_if_missing(BUNDLED_SOURCE_7ZIP, SEVEN_ZIP_DIR)
_copy_tree_if_missing(BUNDLED_SOURCE_ODBC, ODBC_DIR)
# Los sellos se ubican bajo el CONFIG_DIR vigente (no la ruta absoluta precalculada)
# para que respeten el monkeypatch de las pruebas y no escriban en el config/ real.
deps_stamp_path = CONFIG_DIR / BUNDLED_STAMP_PATH.name
version_stamp_path = CONFIG_DIR / VERSION_PATH.name
# Las deps embebidas se re-despliegan cuando el build trae otras versiones. El sello
# se escribe DESPUÉS de copiar: si la copia falla a medias, el próximo arranque lo
# reintenta en lugar de quedar marcado como al día.
deps_stamp = _bundled_deps_stamp()
refresh_deps = _read_stamp(deps_stamp_path) != deps_stamp
_copy_bundled_tree(BUNDLED_SOURCE_7ZIP, SEVEN_ZIP_DIR, refresh_deps)
_copy_bundled_tree(BUNDLED_SOURCE_ODBC, ODBC_DIR, refresh_deps)
_ensure_seven_zip_executable()
if refresh_deps:
_write_stamp(deps_stamp_path, deps_stamp)
# Sello de versión: lo lee el instalador remoto del PANEL por SFTP para verificar el
# despliegue (en Windows el .exe es console=False y no tiene stdout confiable).
_write_stamp(version_stamp_path, __version__)
from ..constants import DB_PATH

View File

@@ -15,6 +15,7 @@ from ..constants import (
ENV_PATH,
default_seven_zip_path,
)
from ..utils.logger import app_logger
def _env_bool(name: str, default: bool = False) -> bool:
@@ -24,6 +25,31 @@ def _env_bool(name: str, default: bool = False) -> bool:
return raw in ("1", "true", "yes", "on")
def _env_int(
name: str,
default: int,
*,
min_value: int | None = None,
max_value: int | None = None,
) -> int:
"""Lee un entero de entorno con validación; ante valor inválido loguea y usa el default."""
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
app_logger.warning(f"{name}='{raw}' no es un entero válido; se usa {default}")
return default
if min_value is not None and value < min_value:
app_logger.warning(f"{name}={value} < {min_value} (mínimo); se usa {default}")
return default
if max_value is not None and value > max_value:
app_logger.warning(f"{name}={value} > {max_value} (máximo); se usa {default}")
return default
return value
def load_env_file() -> bool:
"""Carga config/.env si existe."""
if ENV_PATH.is_file():
@@ -101,6 +127,33 @@ def apply_env_overrides(config: dict) -> dict:
if os.getenv("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH"):
sql["use_windows_auth"] = _env_bool("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH", False)
retention = config.setdefault("retention", {})
if os.getenv("CLOUDRESTORE_RETENTION_ENABLED"):
retention["enabled"] = _env_bool("CLOUDRESTORE_RETENTION_ENABLED", True)
if os.getenv("CLOUDRESTORE_RETENTION_DAYS"):
retention["days"] = _env_int(
"CLOUDRESTORE_RETENTION_DAYS", retention.get("days", 2), min_value=0
)
if os.getenv("CLOUDRESTORE_RETENTION_FAILED_DAYS"):
retention["failed_days"] = _env_int(
"CLOUDRESTORE_RETENTION_FAILED_DAYS", retention.get("failed_days", 7), min_value=0
)
if os.getenv("CLOUDRESTORE_RETENTION_RUN_AT_HOUR"):
retention["run_at_hour"] = _env_int(
"CLOUDRESTORE_RETENTION_RUN_AT_HOUR",
retention.get("run_at_hour", 3),
min_value=0,
max_value=23,
)
if os.getenv("CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS"):
retention["check_interval_seconds"] = _env_int(
"CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS",
retention.get("check_interval_seconds", 3600),
min_value=60,
)
if os.getenv("CLOUDRESTORE_RETENTION_DRY_RUN"):
retention["dry_run"] = _env_bool("CLOUDRESTORE_RETENTION_DRY_RUN", True)
return config
@@ -136,4 +189,12 @@ CLOUDRESTORE_EXTRACT_FOLDER={p("Temp")}
# CLOUDRESTORE_SQL_SERVER=localhost
# CLOUDRESTORE_SQL_USERNAME=
# CLOUDRESTORE_DATA_SQL_FOLDER=
# Retención (limpieza diaria de respaldos obsoletos para no saturar el disco)
# CLOUDRESTORE_RETENTION_ENABLED=true
# CLOUDRESTORE_RETENTION_DAYS=2 # Procesados: por nodo, respecto al más reciente
# CLOUDRESTORE_RETENTION_FAILED_DAYS=7 # Fallados: por antigüedad absoluta
# CLOUDRESTORE_RETENTION_RUN_AT_HOUR=3 # hora local de la corrida diaria (0-23)
# CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS=3600
# CLOUDRESTORE_RETENTION_DRY_RUN=true # true = solo simula; poner false para borrar
"""

View File

@@ -1,8 +1,36 @@
"""Constantes globales de la aplicación."""
import platform
import sys
from pathlib import Path
IS_WINDOWS = sys.platform == "win32"
# Plataforma y arquitectura de ESTE build, en el vocabulario que usa el PANEL para
# decidir qué artefacto le corresponde a cada servidor (a24c.cras_releases).
# Se reportan en POST /api/restore/instance-config junto con app_version.
APP_PLATFORM = "windows" if IS_WINDOWS else "linux"
# platform.machine() varía por SO para la misma arquitectura ("AMD64" en Windows,
# "x86_64" en Linux); se normaliza a un solo vocabulario.
_ARCH_ALIASES = {
"x86_64": "x86_64",
"amd64": "x86_64",
"x86": "x86",
"i386": "x86",
"i686": "x86",
"aarch64": "arm64",
"arm64": "arm64",
}
def _resolve_arch() -> str:
raw = (platform.machine() or "").strip().lower()
return _ARCH_ALIASES.get(raw, raw or "unknown")
APP_ARCH = _resolve_arch()
def _resolve_app_dir() -> Path:
"""Directorio donde vive el ejecutable (persistente)."""
@@ -15,19 +43,27 @@ def _resolve_bundle_dir() -> Path:
"""Recursos embebidos en el binario PyInstaller onefile."""
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS)
platform = "windows" if sys.platform == "win32" else "linux"
return _resolve_app_dir() / "packaging" / "bundled" / platform
return _resolve_app_dir() / "packaging" / "bundled" / APP_PLATFORM
APP_DIR = _resolve_app_dir()
CONFIG_DIR = APP_DIR / "config"
ENV_PATH = CONFIG_DIR / ".env"
# Sello con la versión que corrió por última vez en esta instalación. El bootstrap lo
# reescribe en cada arranque; el instalador remoto del PANEL lo lee por SFTP para
# verificar el despliegue sin depender de stdout (el .exe se compila con console=False).
VERSION_PATH = CONFIG_DIR / ".version"
# Sello con el hash de packaging/bundled-versions.json del build que desplegó 7zip/odbc.
# Si cambia, el bootstrap re-copia esas carpetas (ver _copy_bundled_tree).
BUNDLED_STAMP_PATH = CONFIG_DIR / ".bundled_deps"
DATA_DIR = CONFIG_DIR / "data"
LOGS_DIR = CONFIG_DIR / "logs"
ODBC_DIR = CONFIG_DIR / "odbc"
SEVEN_ZIP_DIR = CONFIG_DIR / "7zip"
IS_WINDOWS = sys.platform == "win32"
BUNDLE_DIR = _resolve_bundle_dir()
BUNDLED_SOURCE_7ZIP = BUNDLE_DIR / "bundled" / "7zip"
BUNDLED_SOURCE_ODBC = BUNDLE_DIR / "bundled" / "odbc"
@@ -136,4 +172,18 @@ DEFAULT_CONFIG = {
"instance_key": "",
"verify_ssl": False,
},
"retention": {
# Limpieza diaria de respaldos obsoletos para no saturar el disco del servidor.
"enabled": True,
# Procesados/: por nodo, borra los aplicados con finished_at < (ref_del_nodo - days).
"days": 2,
# Fallados/: por antigüedad absoluta, borra los más viejos que (hoy - failed_days).
"failed_days": 7,
# Hora local (0-23) a la que corre la limpieza diaria.
"run_at_hour": 3,
# Cada cuánto despierta el hilo para evaluar si toca correr.
"check_interval_seconds": 3600,
# Arranca en SECO: solo reporta qué borraría. El operador lo desactiva tras validar.
"dry_run": True,
},
}

View File

@@ -1,37 +1,54 @@
"""Esquema y gestión de la base de datos SQLite."""
import sqlite3
import threading
from pathlib import Path
from typing import Optional
from datetime import datetime
from ..constants import DB_PATH
# Espera máxima (s) por un lock de SQLite antes de fallar. Con WAL + busy_timeout los
# lectores (UI cada 5s) y escritores (workers + file_watcher) dejan de chocar con
# "database is locked": esperan en vez de fallar de inmediato.
_SQLITE_TIMEOUT_SECONDS = 30.0
_SQLITE_BUSY_TIMEOUT_MS = 5000
class DatabaseManager:
"""Gestor de la base de datos SQLite."""
def __init__(self, db_path: Optional[Path] = None):
"""
Inicializa el gestor de base de datos.
Args:
db_path: Ruta a la base de datos (usa DB_PATH por defecto)
"""
self.db_path = db_path or DB_PATH
self.db_path.parent.mkdir(parents=True, exist_ok=True)
# Serializa las escrituras del propio proceso (varios workers + file_watcher) para
# eliminar las colisiones write-write; WAL cubre la concurrencia lectura/escritura.
self._write_lock = threading.Lock()
self._initialize_schema()
def get_connection(self) -> sqlite3.Connection:
"""Obtiene una conexión a la base de datos."""
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
"""Obtiene una conexión a SQLite con protecciones de concurrencia (WAL + timeouts)."""
conn = sqlite3.connect(
str(self.db_path), check_same_thread=False, timeout=_SQLITE_TIMEOUT_SECONDS
)
conn.row_factory = sqlite3.Row
# WAL: lectores concurrentes con un escritor (persistente en el archivo, idempotente).
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def _initialize_schema(self):
"""Crea las tablas si no existen."""
with self.get_connection() as conn:
conn = self.get_connection()
try:
cursor = conn.cursor()
# Tabla de jobs
cursor.execute("""
CREATE TABLE IF NOT EXISTS jobs (
@@ -51,7 +68,8 @@ class DatabaseManager:
total_ms INTEGER,
extract_ms INTEGER,
restore_ms INTEGER,
filelist_ms INTEGER
filelist_ms INTEGER,
purged_at TEXT
)
""")
@@ -104,36 +122,62 @@ class DatabaseManager:
)
""")
# Migración idempotente: columna purged_at para marcar los respaldos que la
# retención ya borró del disco. Se conserva la fila (historial/stats/UI) y solo
# se anota que su archivo físico dejó de existir. Backward-compatible: columna
# nullable que las versiones anteriores de la app simplemente ignoran.
cursor.execute("PRAGMA table_info(jobs)")
job_columns = {row[1] for row in cursor.fetchall()}
if "purged_at" not in job_columns:
cursor.execute("ALTER TABLE jobs ADD COLUMN purged_at TEXT")
# Índices
cursor.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)")
# Soporta la retención por nodo (GROUP BY node_name + filtro por finished_at).
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_jobs_node_finished "
"ON jobs(node_name, finished_at)"
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_job ON events(job_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_job_steps_job ON job_steps(job_id)")
conn.commit()
finally:
conn.close()
def execute(self, query: str, params: tuple = ()) -> sqlite3.Cursor:
"""Ejecuta una consulta SQL."""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(query, params)
conn.commit()
return cursor
"""Ejecuta una escritura, serializada por lock de proceso y con conexión cerrada."""
with self._write_lock:
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute(query, params)
conn.commit()
return cursor
finally:
conn.close()
def fetchone(self, query: str, params: tuple = ()) -> Optional[sqlite3.Row]:
"""Ejecuta una consulta y devuelve una fila."""
with self.get_connection() as conn:
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute(query, params)
return cursor.fetchone()
finally:
conn.close()
def fetchall(self, query: str, params: tuple = ()) -> list[sqlite3.Row]:
"""Ejecuta una consulta y devuelve todas las filas."""
with self.get_connection() as conn:
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute(query, params)
return cursor.fetchall()
finally:
conn.close()
# Instancia global

View File

@@ -28,6 +28,8 @@ class Job:
extract_ms: Optional[int]
restore_ms: Optional[int]
filelist_ms: Optional[int]
# Fecha en que la retención borró del disco el archivo de este job (None = aún en disco).
purged_at: Optional[str] = None
class JobRepository:
@@ -185,14 +187,109 @@ class JobRepository:
"""
db.execute("DELETE FROM jobs WHERE job_id = ?", (job_id,))
@staticmethod
def get_obsolete_completed_by_node(days: int = 2) -> List[Job]:
"""Jobs 'completed' obsoletos por nodo, candidatos a que se borre su archivo del disco.
Para cada node_name (no NULL) toma su restauración más reciente (MAX(finished_at))
como referencia y devuelve los jobs completados de ese nodo con finished_at anterior a
(referencia days). Como la referencia es el máximo, la comparación estricta '<' NUNCA
incluye la restauración más reciente; los nodos con una sola restauración quedan fuera.
Se excluyen node_name NULL y los ya purgados (purged_at no nulo).
Args:
days: días de antigüedad respecto al más reciente de cada nodo.
Returns:
Lista de jobs obsoletos ordenada por nodo y fecha ascendente.
"""
modifier = f"-{int(days)} days"
rows = db.fetchall(
"""
WITH refs AS (
SELECT node_name, MAX(finished_at) AS ref_finished_at
FROM jobs
WHERE status = ? AND finished_at IS NOT NULL AND node_name IS NOT NULL
GROUP BY node_name
)
SELECT j.* FROM jobs j
JOIN refs r ON r.node_name = j.node_name
WHERE j.status = ?
AND j.finished_at IS NOT NULL
AND j.purged_at IS NULL
AND datetime(j.finished_at) < datetime(r.ref_finished_at, ?)
ORDER BY j.node_name, j.finished_at
""",
(JobStatus.COMPLETED, JobStatus.COMPLETED, modifier),
)
return [Job(**dict(row)) for row in rows]
@staticmethod
def get_latest_completed_per_node() -> dict:
"""node_name -> finished_at (MAX) de las restauraciones completadas de cada nodo.
Sirve como salvaguarda de la retención: la carpeta-fecha de esta referencia (la más
reciente de cada nodo) nunca debe tocarse.
"""
rows = db.fetchall(
"""
SELECT node_name, MAX(finished_at) AS ref_finished_at
FROM jobs
WHERE status = ? AND finished_at IS NOT NULL AND node_name IS NOT NULL
GROUP BY node_name
""",
(JobStatus.COMPLETED,),
)
return {row["node_name"]: row["ref_finished_at"] for row in rows}
@staticmethod
def mark_purged(job_id: str) -> None:
"""Marca que la retención ya borró del disco el archivo de este job.
No borra la fila: conserva el historial, las estadísticas y la vista de la UI; solo
evita que la retención vuelva a intentar borrar un archivo que ya no existe.
"""
now = datetime.utcnow().isoformat()
db.execute(
"UPDATE jobs SET purged_at = ?, updated_at = ? WHERE job_id = ?",
(now, now, job_id),
)
@staticmethod
def exists_by_hash(source_hash: str) -> bool:
"""Verifica si existe un job con el hash dado."""
"""Verifica si existe un job con el hash dado (cualquier estado)."""
row = db.fetchone(
"SELECT COUNT(*) as count FROM jobs WHERE source_hash = ?",
(source_hash,)
)
return row["count"] > 0
@staticmethod
def has_blocking_job_by_hash(source_hash: str) -> bool:
"""True si hay un job con ese hash EXITOSO o EN CURSO (dedup real).
Los estados terminales fallidos (failed/failed_restart/cancelled) NO bloquean: así,
un fallo transitorio (p.ej. 'database is locked') deja de impedir para siempre el
reproceso del archivo si reaparece.
"""
row = db.fetchone(
"SELECT COUNT(*) as count FROM jobs "
"WHERE source_hash = ? AND status NOT IN (?, ?, ?)",
(source_hash, JobStatus.FAILED, JobStatus.FAILED_RESTART, JobStatus.CANCELLED),
)
return row["count"] > 0
@staticmethod
def delete_failed_by_hash(source_hash: str) -> int:
"""Elimina jobs terminales fallidos/cancelados con ese hash (permite un reintento fresco)."""
cursor = db.execute(
"DELETE FROM jobs WHERE source_hash = ? AND status IN (?, ?, ?)",
(source_hash, JobStatus.FAILED, JobStatus.FAILED_RESTART, JobStatus.CANCELLED),
)
try:
return cursor.rowcount if cursor else 0
except Exception:
return 0
@staticmethod
def get_stats() -> dict:

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)

View File

@@ -0,0 +1,125 @@
"""Programador de mantenimiento diario in-process.
Corre una tarea (p.ej. la retención) UNA VEZ por día calendario, dentro del propio proceso de
CloudRestoreAS. No depende de cron/systemd externos (el despliegue es embedded-only) ni del
event loop de Qt: usa un hilo daemon con el mismo patrón que ``FileWatcher``.
La marca de la última corrida se persiste en la tabla ``config`` (vía ``ConfigRepository``), de
modo que:
- corre a lo más una vez por día calendario ("claim" de la fecha ANTES de ejecutar), y
- si el servicio estuvo caído se "pone al día" en el primer arranque de un día nuevo.
"""
from datetime import datetime
from threading import Event, Thread
from typing import Callable, Optional
from ..db.config_repository import ConfigRepository
from ..db.event_repository import EventRepository
from ..utils.logger import app_logger
class DailyMaintenanceScheduler:
"""Ejecuta ``task`` una vez al día en un hilo daemon."""
def __init__(
self,
task: Callable[[], None],
*,
check_interval_seconds: int = 3600,
run_at_hour: Optional[int] = 3,
state_key: str = "retention_last_run",
clock: Callable[[], datetime] = datetime.now,
) -> None:
self._task = task
self._check_interval = max(60, int(check_interval_seconds))
self._run_at_hour = run_at_hour
self._state_key = state_key
self._clock = clock
self._stop_event = Event()
self._thread: Optional[Thread] = None
def start(self) -> None:
"""Inicia el hilo de mantenimiento (hace un chequeo inmediato de 'catch-up')."""
if self._thread and self._thread.is_alive():
app_logger.warning("DailyMaintenanceScheduler ya está corriendo")
return
self._stop_event.clear()
self._thread = Thread(target=self._run, daemon=True)
self._thread.start()
app_logger.info(
f"Mantenimiento diario iniciado (hora={self._run_at_hour}, "
f"cada {self._check_interval}s)"
)
def stop(self, timeout: float = 30.0) -> None:
"""Detiene el hilo (timeout amplio: la limpieza puede tardar)."""
if self._thread:
self._stop_event.set()
self._thread.join(timeout=timeout)
app_logger.info("Mantenimiento diario detenido")
def trigger_now(self) -> None:
"""Corre la tarea de inmediato sin importar la fecha (uso manual/validación)."""
self._execute(force=True)
# -- Interno ---------------------------------------------------------------------
def _run(self) -> None:
while not self._stop_event.is_set():
try:
self._run_if_due()
except Exception as e:
app_logger.error(
f"Error en DailyMaintenanceScheduler: {e}", exc_info=True
)
self._stop_event.wait(self._check_interval)
def _run_if_due(self) -> None:
now = self._clock()
if self._is_due(now, self._load_state()):
self._execute(now=now)
def _is_due(self, now: datetime, state: dict) -> bool:
if state.get("last_run_date") == now.date().isoformat():
return False # ya corrió hoy
if self._run_at_hour is None:
return True # primera oportunidad de un día nuevo
return now.hour >= self._run_at_hour
def _execute(self, now: Optional[datetime] = None, force: bool = False) -> None:
now = now or self._clock()
today = now.date().isoformat()
# Claim al inicio: marca la fecha ANTES de correr para garantizar "máximo 1/día"
# aunque la corrida falle o el proceso muera a mitad (la tarea es idempotente).
if not force:
self._save_state(today, now, "running")
try:
self._task()
status = "ok"
except Exception as e:
app_logger.error(
f"Fallo en la tarea de mantenimiento diaria: {e}", exc_info=True
)
EventRepository.create("ERROR", f"Fallo en la limpieza diaria: {e}")
status = f"error: {e}"
if not force:
self._save_state(today, now, status)
def _load_state(self) -> dict:
state = ConfigRepository.get(self._state_key, {})
return state if isinstance(state, dict) else {}
def _save_state(self, run_date: str, now: datetime, status: str) -> None:
ConfigRepository.set(
self._state_key,
{
"last_run_date": run_date,
"last_run_at": now.isoformat(),
"last_status": status,
},
)

View File

@@ -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:

253
app/engine/retention.py Normal file
View File

@@ -0,0 +1,253 @@
"""Retención diaria de respaldos aplicados para no saturar el disco del servidor.
Dos limpiezas independientes sobre las carpetas locales de CloudRestoreAS:
- ``Procesados/``: por NODO. Para cada nodo se toma su restauración más reciente como
referencia y se conservan las de los últimos ``days``; se borran las anteriores (respaldos
ya aplicados y obsoletos). Nunca se borra la más reciente ni nodos con una sola restauración.
- ``Fallados/``: por ANTIGÜEDAD absoluta. Se borran los ZIP cuya carpeta-fecha sea anterior a
``hoy - failed_days`` (los fallos no tienen semántica de "última restauración exitosa por nodo").
Salvaguardas: solo ``unlink`` de archivos dentro de la carpeta configurada; nunca ``rmtree``;
no sigue symlinks; y jamás toca la carpeta-fecha de la restauración más reciente de un nodo.
El borrado físico se correlaciona con la tabla ``jobs`` (la BD no guarda la ruta destino), por
lo que la carpeta-fecha se deriva de ``finished_at`` con tolerancia de ±1 día por el desfase
UTC/local del momento del movimiento.
"""
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Callable, Optional
from ..db.event_repository import EventRepository
from ..db.job_repository import Job, JobRepository
from ..extract.seven_zip import SevenZipExtractor
from ..utils.logger import app_logger
@dataclass
class RetentionResult:
"""Resumen de una corrida de retención."""
dry_run: bool
deleted_files: int = 0
freed_bytes: int = 0
purged_jobs: int = 0
missing_files: int = 0
errors: int = 0
class RetentionCleaner:
"""Aplica la política de retención sobre las carpetas Procesados/ y Fallados/."""
def __init__(
self,
config: dict,
*,
dry_run: Optional[bool] = None,
clock: Callable[[], datetime] = datetime.now,
) -> None:
retention = config.get("retention", {})
paths = config.get("paths", {})
processed_raw = (paths.get("processed_folder") or "").strip()
failed_raw = (paths.get("failed_folder") or "").strip()
self._processed_folder = Path(processed_raw) if processed_raw else None
self._failed_folder = Path(failed_raw) if failed_raw else None
self._days = int(retention.get("days", 2))
self._failed_days = int(retention.get("failed_days", 7))
# El dry_run explícito (p.ej. botón manual) gana sobre la config.
self._dry_run = bool(retention.get("dry_run", True)) if dry_run is None else dry_run
self._clock = clock
# -- Orquestación ----------------------------------------------------------------
def run(self) -> RetentionResult:
"""Ejecuta ambas limpiezas y devuelve el resumen."""
result = RetentionResult(dry_run=self._dry_run)
self._clean_processed(result)
self._clean_failed(result)
mode = "SIMULACRO" if self._dry_run else "real"
freed_mb = result.freed_bytes / (1024 * 1024)
verb = "se borrarían" if self._dry_run else "borrados"
message = (
f"Retención ({mode}): {result.deleted_files} archivo(s) {verb} "
f"({freed_mb:.1f} MB), {result.purged_jobs} job(s) marcados, "
f"{result.missing_files} no hallado(s), {result.errors} error(es)"
)
app_logger.info(message)
EventRepository.create("INFO", message)
return result
# -- Procesados (por nodo) -------------------------------------------------------
def _clean_processed(self, result: RetentionResult) -> None:
if not self._processed_folder or not self._processed_folder.is_dir():
return
obsolete = JobRepository.get_obsolete_completed_by_node(self._days)
if not obsolete:
return
# Fecha-carpeta local de la restauración más reciente de cada nodo: intocable.
ref_dates = {
node: self._local_date_from_iso(finished_at)
for node, finished_at in JobRepository.get_latest_completed_per_node().items()
}
for job in obsolete:
local_date = self._local_date_from_iso(job.finished_at)
if local_date is None:
continue
ref_date = ref_dates.get(job.node_name)
parts, date_folder = self._resolve_processed_paths(job, local_date, ref_date)
if not parts:
# El archivo ya no está en disco (o el nombre no coincide): lo damos por
# purgado para no re-escanearlo indefinidamente.
result.missing_files += 1
self._mark_purged(job, result)
continue
self._delete_paths(parts, self._processed_folder, result)
self._mark_purged(job, result)
if not self._dry_run and date_folder is not None:
self._cleanup_empty_dir(date_folder)
def _resolve_processed_paths(
self, job: Job, local_date: date, ref_date: Optional[date]
) -> tuple[list[Path], Optional[Path]]:
"""Localiza el/los archivo(s) del job en Procesados/ derivando la carpeta-fecha.
Devuelve (partes_existentes, carpeta_fecha) o ([], None) si no se localizó. Nunca
considera la carpeta-fecha de la restauración más reciente del nodo (``ref_date``).
"""
for candidate in self._candidate_dates(local_date):
if ref_date is not None and candidate == ref_date:
continue
date_folder = self._processed_folder / candidate.isoformat()
if not date_folder.is_dir():
continue
parts = self._collect_parts_in_folder(date_folder, job.source_name)
if parts:
return parts, date_folder
return [], None
# -- Fallados (por antigüedad absoluta) ------------------------------------------
def _clean_failed(self, result: RetentionResult) -> None:
if not self._failed_folder or not self._failed_folder.is_dir():
return
cutoff = self._clock().date() - timedelta(days=self._failed_days)
for date_folder in sorted(self._failed_folder.iterdir()):
if not date_folder.is_dir():
continue
folder_date = self._parse_date_folder(date_folder.name)
if folder_date is None:
# Carpeta con nombre que no es una fecha: no la tocamos.
continue
if folder_date >= cutoff:
continue
files = [p for p in date_folder.iterdir() if p.is_file()]
self._delete_paths(files, self._failed_folder, result)
if not self._dry_run:
self._cleanup_empty_dir(date_folder)
# -- Helpers de borrado ----------------------------------------------------------
def _delete_paths(
self, paths: list[Path], root: Path, result: RetentionResult
) -> None:
for path in paths:
try:
if path.is_symlink():
app_logger.warning(f"Retención: se omite symlink {path}")
continue
if not self._is_inside(path, root):
app_logger.warning(
f"Retención: se omite ruta fuera de {root}: {path}"
)
continue
if not path.is_file():
continue
size = path.stat().st_size
if self._dry_run:
app_logger.info(f"[SIMULACRO] Se borraría {path} ({size} bytes)")
else:
path.unlink()
app_logger.info(f"Retención: borrado {path} ({size} bytes)")
result.deleted_files += 1
result.freed_bytes += size
except FileNotFoundError:
result.missing_files += 1
except OSError as e:
app_logger.warning(f"Retención: no se pudo borrar {path}: {e}")
result.errors += 1
def _cleanup_empty_dir(self, folder: Path) -> None:
"""Borra la carpeta-fecha solo si quedó vacía (best-effort, nunca rmtree)."""
try:
if folder.is_dir() and not any(folder.iterdir()):
folder.rmdir()
app_logger.info(f"Retención: carpeta vacía eliminada {folder}")
except OSError as e:
app_logger.warning(f"Retención: no se pudo eliminar carpeta {folder}: {e}")
def _mark_purged(self, job: Job, result: RetentionResult) -> None:
if self._dry_run:
return
JobRepository.mark_purged(job.job_id)
result.purged_jobs += 1
# -- Helpers puros ---------------------------------------------------------------
@staticmethod
def _collect_parts_in_folder(date_folder: Path, source_name: str) -> list[Path]:
"""Rutas del archivo (y sus partes multipart) dentro de una carpeta-fecha."""
if SevenZipExtractor.is_multipart(source_name):
base_name = Path(source_name).stem.split(".zip")[0]
return sorted(date_folder.glob(f"{base_name}.zip.*"))
candidate = date_folder / source_name
return [candidate] if candidate.exists() else []
@staticmethod
def _candidate_dates(local_date: date) -> list[date]:
"""Fecha exacta y ±1 día, para absorber el desfase de medianoche/zona horaria."""
return [local_date, local_date - timedelta(days=1), local_date + timedelta(days=1)]
@staticmethod
def _local_date_from_iso(finished_at: Optional[str]) -> Optional[date]:
"""Convierte un finished_at (ISO UTC naive) a la fecha local del movimiento."""
if not finished_at:
return None
try:
dt = datetime.fromisoformat(finished_at)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone().date()
@staticmethod
def _parse_date_folder(name: str) -> Optional[date]:
try:
return datetime.strptime(name, "%Y-%m-%d").date()
except ValueError:
return None
@staticmethod
def _is_inside(path: Path, root: Path) -> bool:
"""True si `path` resuelve dentro de `root` (anti path-traversal)."""
try:
resolved = path.resolve()
root_resolved = root.resolve()
except OSError:
return False
return resolved == root_resolved or root_resolved in resolved.parents

View File

@@ -305,11 +305,24 @@ def report_instance_config(
host_name: Optional[str] = None,
app_version: Optional[str] = None,
instance_key: Optional[str] = None,
processed_folder: Optional[str] = None,
platform_name: Optional[str] = None,
arch: Optional[str] = None,
install_path: Optional[str] = None,
) -> bool:
"""
Reporta la carpeta de entrada vigente a POST /api/restore/instance-config (best-effort).
El panel solo la muestra; CloudRestoreAS es la única fuente de escritura.
platform_name y arch identifican el build de ESTA instalación ("windows"/"linux",
"x86_64"/"arm64"). El PANEL los usa para saber qué artefacto le toca a este servidor
cuando instala o actualiza; sin ellos cae al texto libre de restore_targets.os.
install_path es la carpeta donde vive el ejecutable (APP_DIR). Es un espacio de rutas
DISTINTO de input_folder: ahí abajo está config/.env con las rutas de trabajo que el
operador haya personalizado. El PANEL la necesita para actualizar en el lugar correcto
en vez de crear una segunda instalación con la configuración por omisión.
Returns:
True si el PANEL aceptó el reporte (200), False en cualquier otro caso.
"""
@@ -325,8 +338,12 @@ def report_instance_config(
key = (instance_key or "").strip() or None
payload = {
"input_folder": input_folder.strip(),
"processed_folder": (processed_folder or "").strip() or None,
"host_name": (host_name or "").strip() or None,
"app_version": (app_version or "").strip() or None,
"platform": (platform_name or "").strip() or None,
"arch": (arch or "").strip() or None,
"install_path": (install_path or "").strip() or None,
}
if key:
payload["instance_key"] = key

View File

@@ -12,6 +12,7 @@ producción conviene fijar/known_hosts las claves de cada servidor.
import ntpath
import posixpath
import time
from pathlib import Path
from typing import Optional
@@ -22,6 +23,12 @@ from ..utils.logger import app_logger
# Timeout de conexión SSH en segundos.
SSH_TIMEOUT = 30
# Verificación de la subida: tras escribir los bytes se confirma el tamaño remoto con
# reintentos. Tolera stats flaky (buffering/AV/locking, típico en OpenSSH/Windows) que de
# otro modo producirían un FALSO FALLO aunque el archivo sí se entregó.
VERIFY_ATTEMPTS = 3
VERIFY_DELAY_SECONDS = 1.0
class SFTPCopyError(Exception):
"""Error al transferir o limpiar el .bak en el servidor remoto vía SFTP."""
@@ -102,12 +109,17 @@ def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str
raise SFTPCopyError("La carpeta remota destino está vacía")
remote_sftp = _sftp_path(str(remote_folder).strip(), src.name)
expected_size = src.stat().st_size
client = _connect(cfg)
try:
sftp = client.open_sftp()
try:
app_logger.info(f"Subiendo archivo por SFTP a {cfg['ssh_host']}: {remote_sftp}")
sftp.put(str(src), remote_sftp)
# confirm=False: la verificación de paramiko hace un stat inmediato que en
# OpenSSH/Windows suele fallar por buffering aunque el archivo sí se escribió.
# Se verifica el tamaño aparte, con reintentos (ver _verify_remote_size).
sftp.put(str(src), remote_sftp, confirm=False)
_verify_remote_size(sftp, remote_sftp, expected_size)
finally:
sftp.close()
except SFTPCopyError:
@@ -120,16 +132,45 @@ def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str
return remote_sftp
def _verify_remote_size(sftp, remote_path: str, expected_size: int) -> None:
"""Confirma que el archivo remoto tiene el tamaño esperado, con reintentos.
Un stat transitoriamente fallido o con tamaño aún incompleto (flush/AV en curso) se
reintenta; solo tras agotar los intentos se considera fallo genuino de entrega.
"""
last_error: Optional[str] = None
for attempt in range(VERIFY_ATTEMPTS):
try:
remote_size = sftp.stat(remote_path).st_size
if remote_size == expected_size:
return
last_error = f"tamaño remoto {remote_size} != esperado {expected_size}"
except Exception as e: # noqa: BLE001 — se reintenta y, si persiste, se eleva abajo
last_error = str(e)
if attempt < VERIFY_ATTEMPTS - 1:
time.sleep(VERIFY_DELAY_SECONDS)
raise SFTPCopyError(
f"No se pudo verificar la subida de {remote_path}: {last_error}"
)
def upload_zip_parts(local_paths: list[str], cfg: dict, remote_folder: str) -> list[str]:
"""
Sube uno o más archivos ZIP (incl. multipart) al input_folder del destino.
Ante un fallo, adjunta a la excepción la lista de partes ya subidas (atributo
``uploaded``) para que el llamador pueda limpiar el envío parcial en el destino.
Returns:
Lista de rutas SFTP subidas.
"""
uploaded: list[str] = []
for local_path in local_paths:
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
try:
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
except SFTPCopyError as e:
e.uploaded = uploaded
raise
return uploaded

View File

@@ -13,6 +13,8 @@ from .nodes_tab import NodesTab
from .config_tab import ConfigTab
from .logs_tab import LogsTab
from .tray_assets import load_tray_icon
from .. import __version__
from ..constants import APP_ARCH, APP_PLATFORM
from ..engine.engine import RestoreEngine
from ..utils.logger import app_logger
@@ -128,6 +130,10 @@ class MainWindow(QMainWindow):
self.scan_action.triggered.connect(self._scan_now)
motor_menu.addAction(self.scan_action)
self.cleanup_action = QAction("🧹 Limpiar Respaldos Ahora", self)
self.cleanup_action.triggered.connect(self._run_cleanup_now)
motor_menu.addAction(self.cleanup_action)
help_menu = menubar.addMenu("Ayuda")
about_action = QAction("Acerca de", self)
@@ -282,6 +288,11 @@ class MainWindow(QMainWindow):
self.engine.scan_now()
app_logger.info("Escaneo manual solicitado desde UI")
def _run_cleanup_now(self):
"""Dispara la retención de respaldos obsoletos (respeta el dry_run de la config)."""
self.engine.trigger_maintenance_now()
app_logger.info("Limpieza de respaldos solicitada desde UI")
def _update_stats(self):
"""Actualiza las estadísticas."""
self._update_tray_status()
@@ -308,7 +319,7 @@ class MainWindow(QMainWindow):
QMessageBox.about(
self,
"Acerca de CloudRestoreAS",
"CloudRestoreAS v1.0.0\n\n"
f"CloudRestoreAS v{__version__} ({APP_PLATFORM}/{APP_ARCH})\n\n"
"Aplicación de restauración automática de bases de datos SQL Server.\n\n"
"Al cerrar la ventana, la aplicación permanece en la bandeja del sistema.\n"
"Use Archivo → Salir o la bandeja → Salir para cerrar por completo.\n\n"