feature/generador-instaladores-linux-windows
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user