feature/generador-instaladores-linux-windows
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
125
app/engine/maintenance_scheduler.py
Normal file
125
app/engine/maintenance_scheduler.py
Normal 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,
|
||||
},
|
||||
)
|
||||
@@ -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
253
app/engine/retention.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user