From c3f1d70e2344cd37876a3a1a301de216ff9dbc86 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 07:34:17 -0600 Subject: [PATCH] feature/generador-instaladores-linux-windows --- BUILD.md | 138 ++++++++++-- CHANGELOG.md | 47 ++++ INTEGRACION_PANEL.md | 60 ++++- PROJECT_STRUCTURE.md | 2 +- QUICKSTART.md | 7 +- app/__init__.py | 6 +- app/config/bootstrap.py | 115 +++++++++- app/config/env_loader.py | 61 +++++ app/constants.py | 56 ++++- app/db/database.py | 84 +++++-- app/db/job_repository.py | 99 ++++++++- app/engine/engine.py | 135 +++++++++++- app/engine/maintenance_scheduler.py | 125 +++++++++++ app/engine/restore_worker.py | 154 ++++++++++--- app/engine/retention.py | 253 +++++++++++++++++++++ app/panel/panel_client.py | 17 ++ app/transfer/sftp_copy.py | 45 +++- app/ui/main_window.py | 13 +- build-all.sh | 41 +++- install.ps1 | 319 ++++++++++++++++++--------- install.sh | 150 +++++++++++-- packaging/CloudRestoreAS.spec | 96 ++++++++ packaging/scripts/package-release.sh | 239 ++++++++++++++++++-- packaging/scripts/publish-release.sh | 221 +++++++++++++++++++ runner.py | 48 ++++ scripts/dev-setup.ps1 | 128 +++++++++++ tests/test_bootstrap.py | 108 +++++++++ tests/test_forward_false_failure.py | 170 ++++++++++++++ tests/test_maintenance_scheduler.py | 89 ++++++++ tests/test_multipart_case.py | 116 ++++++++++ tests/test_panel_client.py | 80 ++++++- tests/test_release_metadata.py | 291 ++++++++++++++++++++++++ tests/test_retention.py | 157 +++++++++++++ tests/test_sftp_copy.py | 80 ++++++- 34 files changed, 3498 insertions(+), 252 deletions(-) create mode 100644 app/engine/maintenance_scheduler.py create mode 100644 app/engine/retention.py create mode 100755 packaging/scripts/publish-release.sh create mode 100644 scripts/dev-setup.ps1 create mode 100644 tests/test_forward_false_failure.py create mode 100644 tests/test_maintenance_scheduler.py create mode 100644 tests/test_multipart_case.py create mode 100644 tests/test_release_metadata.py create mode 100644 tests/test_retention.py diff --git a/BUILD.md b/BUILD.md index d3d543c..eb4f597 100644 --- a/BUILD.md +++ b/BUILD.md @@ -13,11 +13,12 @@ Qt instaladas en el equipo destino. Todo se embebe dentro del binario en tiempo Orquestador único. **Se ejecuta desde WSL** (bash). Genera Windows + Linux + los paquetes. ```bash -./build-all.sh # Windows + Linux + dist/release/*.tar.gz y *.zip +./build-all.sh # Windows + Linux + paquetes versionados en dist/release/ ./build-all.sh --linux-only # solo Linux (Docker) ./build-all.sh --windows-only # solo Windows (PowerShell + build.ps1) ./build-all.sh --no-package # compila sin generar .tar.gz/.zip ./build-all.sh --clean # rebuild desde cero (borra venvs, bundled, dist) +./build-all.sh --publish # además publica en Gitea (ver §5; requiere GITEA_TOKEN) ./build-all.sh --help ``` @@ -72,21 +73,85 @@ Los binarios quedan en `packaging/bundled/{linux,windows}/` (git-ignored, se gen bash packaging/scripts/package-release.sh ``` +La versión sale de `app/__init__.py` (fuente única) y va en el nombre de cada paquete. +El script aborta si la versión no es puntos-y-números: el PANEL las compara como tuplas +de enteros y otro formato rompería en silencio la detección de "hay versión nueva". + Genera en `dist/release/`: | Archivo | Contenido | |---|---| -| `CloudRestoreAS-linux.tar.gz` | `CloudRestoreAS/` → binario + `install.sh` + `packaging/linux/cloudrestoreas.service` + `LEEME.txt` | -| `CloudRestoreAS-win.zip` | `CloudRestoreAS/` → `CloudRestoreAS.exe` + `install.ps1` + `LEEME.txt` | +| `CloudRestoreAS--linux-.tar.gz` | `CloudRestoreAS/` → binario + `install.sh` + `packaging/linux/cloudrestoreas.service` + `LEEME.txt` | +| `CloudRestoreAS--win-.zip` | `CloudRestoreAS/` → `CloudRestoreAS.exe` + `install.ps1` + `LEEME.txt` | +| `SHA256SUMS` | Checksums de los dos paquetes | +| `release.json` | Manifiesto: versión, fecha, artefactos (platform/arch/tamaño/sha256) y deps embebidas. Es lo que lee `publish-release.sh` para saber qué subir | + +Además deja copias crudas sin versión (`CloudRestoreAS.exe`, `CloudRestoreAS-linux`) para +la verificación de autocontención de la §9. Esas **no** se publican. + +`arch` se declara con `CLOUDRESTORE_TARGET_ARCH` (default `x86_64`): el `.exe` lo compila +el host Windows y desde WSL no hay forma de inferir su arquitectura. --- -## 5. Instalación / despliegue +## 5. Publicación a Gitea — `publish-release.sh` + +Los binarios viven en el **registro de paquetes genéricos de Gitea**, que es la fuente de +verdad que consume el PANEL. Gitea calcula y expone el `sha256` de cada archivo, así que +no hace falta mantener un manifiesto de integridad propio: el PANEL verifica sus descargas +contra ese hash. + +```bash +# Requiere un PAT de Gitea con scope write:package +export GITEA_TOKEN=xxxxxxxx + +bash packaging/scripts/publish-release.sh --dry-run # lista qué subiría +bash packaging/scripts/publish-release.sh # sube y verifica +bash packaging/scripts/publish-release.sh --notify-panel # y avisa al PANEL +``` + +Destino: `https://git.aduanasoft.com/api/packages/ADUANASOFT/generic/cloudrestoreas//` + +Tras subir, el script **relee la API de Gitea y compara el sha256 y el tamaño de cada +artefacto** contra `release.json`. Si no coinciden falla: una publicación a medias no debe +pasar por buena, porque el PANEL rechazaría la descarga por hash y el error aparecería +mucho después, al intentar instalar. + +Los paquetes genéricos son **inmutables**: reintentar la misma versión da HTTP 409. Lo +correcto es subir una versión nueva; `--force` borra y reemplaza, y solo aplica cuando la +versión anterior nunca se instaló en ningún servidor. + +Todo en una sola tarea: +```bash +GITEA_TOKEN=xxxx ./build-all.sh --publish --notify-panel +``` +`--publish` exige el build de **ambas** plataformas: publicar una versión a la que le falta +una dejaría en el PANEL un release que no se le puede instalar a la mitad de los servidores, +y corregirlo obligaría a quemar el número de versión. + +| Variable | Default | Para qué | +|---|---|---| +| `GITEA_TOKEN` | — | **Requerida.** PAT con scope `write:package` | +| `GITEA_BASE_URL` | `https://git.aduanasoft.com` | Instancia de Gitea | +| `GITEA_OWNER` | `ADUANASOFT` | Organización dueña del paquete | +| `CRAS_PACKAGE` | `cloudrestoreas` | Nombre del paquete genérico | +| `PANEL_API_URL` | — | Solo con `--notify-panel` | +| `CLOUDRESTORE_API_TOKEN` | — | Solo con `--notify-panel` (token de servicio del PANEL) | + +Siguiente paso, en el PANEL: **/versiones-cras → Sincronizar con Gitea → Activar**, y de +ahí **Instalar / Actualizar** por servidor. + +--- + +## 6. Instalación / despliegue + +Lo normal es que el PANEL instale por SSH desde `/versiones-cras`, sembrando además las +credenciales. Lo de abajo es el camino manual y lo que el PANEL ejecuta por dentro. ### Linux — `install.sh` (no instala nada del sistema) ```bash -tar xzf CloudRestoreAS-linux.tar.gz && cd CloudRestoreAS +tar xzf CloudRestoreAS--linux-x86_64.tar.gz && cd CloudRestoreAS sudo ./install.sh --service # servicio systemd 24/7 headless (recomendado en servidor) ./install.sh --desktop # autostart .desktop (requiere sesión gráfica) @@ -95,6 +160,9 @@ sudo ./install.sh --service # servicio systemd 24/7 headless (recomendado en ``` Variables: `PREFIX=/opt/cloudrestoreas` (destino), `SERVICE_USER=` (usuario del servicio). +Detiene el servicio antes de reemplazar el binario (un ELF en ejecución da `ETXTBSY`) y lo +vuelve a levantar si estaba activo. + Servicio systemd: ```bash systemctl status cloudrestoreas @@ -102,14 +170,45 @@ journalctl -u cloudrestoreas -f sudo systemctl restart cloudrestoreas # tras editar config/.env ``` -### Windows +### Windows — `install.ps1` (autocontenido, sin NSSM ni descargas) -Copiar `CloudRestoreAS.exe` a una carpeta y ejecutarlo (o usar `install.ps1`). Al iniciar -crea `config/` y un icono en la bandeja. Ver [packaging/LEEME.txt](packaging/LEEME.txt). +```powershell +Expand-Archive CloudRestoreAS--win-x86_64.zip -DestinationPath . +cd CloudRestoreAS + +.\install.ps1 -Service # tarea programada ONSTART como SYSTEM (24/7 headless) +.\install.ps1 -Desktop # arranque al iniciar sesión (tarea ONLOGON de la app) +.\install.ps1 # solo instala + bootstrap +Get-Help .\install.ps1 -Detailed +``` +Parámetros: `-Prefix` (default `C:\Aduanasoft\CloudRestoreAS`), `-PanelEnvFile`. + +`-Service` requiere PowerShell **como Administrador** (la tarea corre como SYSTEM). El +arranque 24/7 se resuelve con una tarea programada, no con NSSM: descargarlo violaría la +regla de que en el servidor destino no se instala ni se baja nada. + +> `scripts/dev-setup.ps1` es otra cosa: prepara el entorno de **desarrollo** (Python, venv, +> `requirements.txt`) para correr `python runner.py`. No sirve para desplegar el binario. + +### Siembra de credenciales del PANEL + +Ambos instaladores aceptan un archivo `KEY=valor` con las claves `CLOUDRESTORE_PANEL_*`, +que fusionan en `config/.env` (replace-or-append, idempotente, con lista blanca) y luego +borran: + +```bash +./install.sh --service --panel-env-file /tmp/panel.env +``` +```powershell +.\install.ps1 -Service -PanelEnvFile C:\Temp\panel.env +``` + +Va por archivo y no por argumentos a propósito: un token en la línea de comandos queda +visible en `ps` y en el historial del servidor destino. --- -## 6. Ejecución manual y flags del binario +## 7. Ejecución manual y flags del binario ```bash # Linux servidor sin pantalla (headless): motor de restauración sin GUI @@ -124,13 +223,14 @@ QT_QPA_PLATFORM=offscreen ./CloudRestoreAS --start-engine --headless | `--start-engine` | Inicia el motor de restauración al arrancar | | `--headless` | Fuerza modo sin interfaz (Qt `offscreen`), para servidores sin display | | `--minimized` | Inicia minimizado en la bandeja | +| `--version` | Imprime `CloudRestoreAS (/)` y termina. En Windows requiere consola del padre (el `.exe` es `console=False`); el instalador remoto prefiere leer `config/.version` | | `QT_QPA_PLATFORM=offscreen` | Plataforma Qt sin display (el binario ya cae a esto automáticamente si no hay `DISPLAY`/`WAYLAND_DISPLAY` en Linux) | En Linux sin `DISPLAY`, el binario selecciona `offscreen` **solo**; con display usa `xcb`. --- -## 7. Configuración (primera ejecución) +## 8. Configuración (primera ejecución) El binario crea automáticamente: `config/`, `config/.env`, `Entrada/`, `Procesados/`, `Fallados/`, `Temp/`. Editar `config/.env`: @@ -147,7 +247,7 @@ automáticamente según el formato del `data_folder`. --- -## 8. Verificación de autocontención (contenedor pelado) +## 9. Verificación de autocontención (contenedor pelado) Confirma que el binario Linux corre sin instalar NADA del sistema: @@ -166,19 +266,23 @@ Esperado: arranca en `offscreen` sin errores Qt, `0` deps ODBC faltantes, `7zz` --- -## 9. Artefactos y ubicaciones +## 10. Artefactos y ubicaciones | Ruta | Qué es | |---|---| | `dist/CloudRestoreAS` | Binario Linux onefile | | `dist/CloudRestoreAS.exe` | Ejecutable Windows onefile | -| `dist/release/*.tar.gz` / `*.zip` | Paquetes de despliegue (binario + instalador + docs) | +| `dist/release/CloudRestoreAS--*.{tar.gz,zip}` | Paquetes de despliegue publicables (binario + instalador + docs) | +| `dist/release/release.json` | Manifiesto de la versión (lo consume `publish-release.sh`) | +| `dist/release/SHA256SUMS` | Checksums de los paquetes | +| `config/.version` | Sello de la versión que corrió (lo lee el instalador remoto por SFTP) | +| `config/.bundled_deps` | Sello del `bundled-versions.json` desplegado; si cambia, se re-copian `config/7zip` y `config/odbc` | | `packaging/bundled/{linux,windows}/` | Deps embebidas (generadas en build; git-ignored) | | `venv-linux/`, `venv-windows/` | Entornos virtuales de build (git-ignored) | --- -## 10. Scripts de referencia rápida +## 11. Scripts de referencia rápida | Script | Propósito | |---|---| @@ -186,6 +290,8 @@ Esperado: arranca en `offscreen` sin errores Qt, `0` deps ODBC faltantes, `7zz` | `build.sh` / `build.ps1` | Build individual Linux / Windows | | `packaging/scripts/docker-build-linux.sh` | Build Linux en contenedor controlado | | `packaging/scripts/download-bundled-deps.sh` / `.ps1` | Descarga+embebe deps | -| `packaging/scripts/package-release.sh` | Genera `.tar.gz` / `.zip` | -| `install.sh` / `install.ps1` | Instalador Linux / Windows | +| `packaging/scripts/package-release.sh` | Genera los paquetes versionados + `SHA256SUMS` + `release.json` | +| `packaging/scripts/publish-release.sh` | Publica en los paquetes genéricos de Gitea y verifica el sha256 | +| `install.sh` / `install.ps1` | Instalador de **despliegue** Linux / Windows (autocontenidos) | +| `scripts/dev-setup.ps1` | Entorno de **desarrollo** en Windows (Python + venv). No sirve para desplegar | | `packaging/linux/cloudrestoreas.service` | Unit systemd (24/7 headless) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 52bd38f..fa20886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## [1.1.0] - 2026-07-29 + +### Distribución e instalación automatizada vía Gitea + PANEL + +#### Publicación de versiones +- Artefactos con versión en el nombre: `CloudRestoreAS--{linux,win}-.{tar.gz,zip}`, + más `SHA256SUMS` y `release.json` (manifiesto con sha256, tamaño y deps embebidas). +- `packaging/scripts/publish-release.sh`: publica en los paquetes genéricos de Gitea + (`ADUANASOFT/generic/cloudrestoreas/`) y **verifica el sha256 contra la propia + API de Gitea** antes de dar la publicación por buena. Soporta `--dry-run`, `--force` y + `--notify-panel`. +- `build-all.sh --publish` encadena build → empaquetado → publicación. Se niega a publicar + si falta una plataforma: los paquetes genéricos son inmutables y corregirlo quemaría el + número de versión. + +#### Contrato con el PANEL +- `POST /api/restore/instance-config` ahora reporta también `platform` y `arch`, para que + el PANEL sepa qué artefacto le corresponde a cada servidor. +- `processed_folder` ya se envía en ese mismo reporte (antes se calculaba, no se mandaba). + +#### Instaladores +- **Nuevo `install.ps1`**: instalador de despliegue Windows, autocontenido. Registra una + tarea programada ONSTART como SYSTEM para el 24/7 headless — sin NSSM ni descargas en el + servidor destino. Detiene la instancia en ejecución antes de reemplazar el `.exe`. +- El antiguo `install.ps1` (preparación del entorno de desarrollo: Python, venv, pip) se + movió a `scripts/dev-setup.ps1`. **Se estaba empaquetando por error** en el zip del + ejecutable autocontenido, que no necesita nada de eso. +- `install.sh` e `install.ps1` aceptan `--panel-env-file` / `-PanelEnvFile`: fusionan las + claves `CLOUDRESTORE_PANEL_*` en `config/.env` (replace-or-append, idempotente, con lista + blanca) y borran el archivo. El token viaja por archivo 0600, nunca por argumentos, para + que no quede visible en `ps` ni en el historial del destino. +- `install.sh` detiene el servicio antes de reemplazar el binario (un ELF en ejecución da + `ETXTBSY`) y lo vuelve a levantar si estaba activo. + +#### Versionado +- `app/__init__.py` es la fuente única de la versión; el diálogo *Acerca de* ya no la trae + hardcodeada. +- Flag `--version` en el binario, y sello `config/.version` que escribe el bootstrap (el + instalador remoto lo lee por SFTP, porque el `.exe` se compila con `console=False`). +- El `.exe` ya lleva metadatos de versión de Windows (`VSVersionInfo`). + +#### Correcciones +- `config/7zip` y `config/odbc` se re-despliegan cuando el build trae otras versiones + embebidas, comparando un sello con el sha256 de `bundled-versions.json`. Antes solo se + copiaban si la carpeta estaba vacía, así que una actualización con driver ODBC nuevo + conservaba el viejo indefinidamente. + ## [1.0.0] - 2026-01-25 ### Lanzamiento Inicial diff --git a/INTEGRACION_PANEL.md b/INTEGRACION_PANEL.md index abbff17..6ae632c 100644 --- a/INTEGRACION_PANEL.md +++ b/INTEGRACION_PANEL.md @@ -132,15 +132,69 @@ Usado por utilidades legacy; el flujo principal de jobs usa `resolve-route`. ### POST `/api/restore/instance-config` -Reporte de carpeta de entrada (`instance_key` = nombre del servidor). +Reporte de carpeta de entrada e identidad del agente (`instance_key` = nombre del servidor). +Es *best-effort*: un fallo aquí nunca bloquea una restauración. + +```jsonc +{ + "input_folder": "D:\\Backups\\Entrada", + "processed_folder": "D:\\Backups\\Procesados", // opcional; el panel la deriva si falta + "host_name": "WIN-RESTORE-01", + "app_version": "1.1.0", // versión instalada → cloudrestore_status.app_version + "platform": "windows", // "windows" | "linux" + "arch": "x86_64", // "x86_64" | "arm64" + "instance_key": "Alfa" // = restore_targets.name +} +``` + +`platform` y `arch` le dicen al panel **qué artefacto le corresponde a este servidor** al +instalar o actualizar: `a24c.cras_releases` se llavea por `version + platform + arch`. Un +agente viejo que no las mande sigue funcionando; el panel cae al texto libre de +`restore_targets.os` para la primera instalación. + +Respuesta: `200 { "ok": true, "trace_id": "…" }`. + +### POST `/api/restore/agent-sync` + +Dispara la sincronización del catálogo de versiones contra Gitea. Lo usa +`publish-release.sh --notify-panel` para que una versión recién publicada aparezca de +inmediato, sin esperar a que un admin abra `/versiones-cras`. + +Body vacío (`{}`). Respuesta: `200 { "ok": true, "discovered": N, "versions": N }`. + +--- + +## Distribución de versiones (Gitea → PANEL → servidor) + +Los binarios se publican en el registro de paquetes genéricos de Gitea; el panel los +descubre leyendo su API, los cachea verificando el `sha256` que Gitea calcula, e instala +por SSH/SFTP en el servidor destino. + +``` +build local → Gitea (generic packages) → PANEL (caché + instalador SSH) → servidor +``` + +- **Publicar:** ver [BUILD.md](BUILD.md) §5 (`publish-release.sh`). +- **Instalar/actualizar:** panel → **Versiones CRAS** (`/versiones-cras`) → *Sincronizar con + Gitea* → *Activar* → *Instalar* en el servidor. +- El panel siembra `config/.env` con `api_url`, `api_token` e `instance_key` durante la + instalación, así que el servidor queda operativo sin configuración manual. +- El servidor destino **no descarga nada de internet**: el binario es autocontenido y los + bytes llegan del panel por SFTP. + +Tablas involucradas: `a24c.cras_releases` (catálogo de versiones publicadas) y +`a24c.cras_install_runs` (bitácora de instalaciones con progreso paso a paso). --- ## Agregar servidores adicionales -1. Panel → **Servidores de Restauración → + Nuevo servidor** +1. Panel → **Servidores de Restauración → + Nuevo servidor** (incluye credenciales SSH) 2. **Gestión de Bases de Datos** → dropdown servidor por base -3. Instalar CRA → Config → instancia → Guardar (card **Reportada**) +3. Panel → **Versiones CRAS** → *Instalar* en ese servidor (siembra el `.env` solo) + +El camino manual sigue disponible: instalar el CRA a mano → Config → instancia → Guardar +(card **Reportada**). --- diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 64d0724..f3b00bb 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -53,7 +53,7 @@ CloudRestoreAs/ ├── runner.py # Punto de entrada principal ├── requirements.txt # Dependencias Python │ -├── install.ps1 # Script de instalación automática +├── install.ps1 # Instalador de DESPLIEGUE Windows (binario autocontenido) ├── start.ps1 # Script de inicio rápido (PowerShell) ├── start.bat # Script de inicio rápido (Batch) ├── build.ps1 # Script para generar ejecutable con PyInstaller diff --git a/QUICKSTART.md b/QUICKSTART.md index 84cfd47..c37073a 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -13,13 +13,16 @@ Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"} ### 2. Instala y Ejecuta ```powershell -# Ejecuta el script de instalación -.\install.ps1 +# Prepara el entorno de desarrollo (Python + venv + requirements) +.\scripts\dev-setup.ps1 # Ejecuta la aplicación .\venv\Scripts\python.exe runner.py ``` +> Para **desplegar** el binario compilado en un servidor usa `install.ps1` (raíz), que es +> autocontenido y no necesita Python. Ver [BUILD.md](BUILD.md) §6. + ### Integración con PANEL_BASES_ANEXO24 Si usas el panel para asignar servidores de restauración, ver [INTEGRACION_PANEL.md](INTEGRACION_PANEL.md) para tokens, catálogo dinámico (`target-catalog`) y prueba end-to-end. diff --git a/app/__init__.py b/app/__init__.py index bd45de1..31b6066 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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" diff --git a/app/config/bootstrap.py b/app/config/bootstrap.py index fd8da26..20e2bee 100644 --- a/app/config/bootstrap.py +++ b/app/config/bootstrap.py @@ -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 `.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 `.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 diff --git a/app/config/env_loader.py b/app/config/env_loader.py index 9f2271b..e82b557 100644 --- a/app/config/env_loader.py +++ b/app/config/env_loader.py @@ -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 """ diff --git a/app/constants.py b/app/constants.py index 5f87fa1..179e03b 100644 --- a/app/constants.py +++ b/app/constants.py @@ -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, + }, } diff --git a/app/db/database.py b/app/db/database.py index 4867485..9f0f052 100644 --- a/app/db/database.py +++ b/app/db/database.py @@ -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 diff --git a/app/db/job_repository.py b/app/db/job_repository.py index c69c7a3..66802d5 100644 --- a/app/db/job_repository.py +++ b/app/db/job_repository.py @@ -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: diff --git a/app/engine/engine.py b/app/engine/engine.py index ba656e4..a42ab20 100644 --- a/app/engine/engine.py +++ b/app/engine/engine.py @@ -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) diff --git a/app/engine/maintenance_scheduler.py b/app/engine/maintenance_scheduler.py new file mode 100644 index 0000000..332b5c5 --- /dev/null +++ b/app/engine/maintenance_scheduler.py @@ -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, + }, + ) diff --git a/app/engine/restore_worker.py b/app/engine/restore_worker.py index 71daeae..bae2ac9 100644 --- a/app/engine/restore_worker.py +++ b/app/engine/restore_worker.py @@ -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// 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// 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//` 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: diff --git a/app/engine/retention.py b/app/engine/retention.py new file mode 100644 index 0000000..8d3e326 --- /dev/null +++ b/app/engine/retention.py @@ -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 diff --git a/app/panel/panel_client.py b/app/panel/panel_client.py index 17bd40e..0e51574 100644 --- a/app/panel/panel_client.py +++ b/app/panel/panel_client.py @@ -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 diff --git a/app/transfer/sftp_copy.py b/app/transfer/sftp_copy.py index b1bdb00..1acf984 100644 --- a/app/transfer/sftp_copy.py +++ b/app/transfer/sftp_copy.py @@ -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 diff --git a/app/ui/main_window.py b/app/ui/main_window.py index f6f277e..f89fd34 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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" diff --git a/build-all.sh b/build-all.sh index 3fdf4d6..2abf6b7 100755 --- a/build-all.sh +++ b/build-all.sh @@ -4,7 +4,9 @@ # # Linux : build en Docker (ubuntu:22.04) -> dist/CloudRestoreAS (autocontenido) # Windows: build vía powershell.exe -> build.ps1 -> dist/CloudRestoreAS.exe -# Paquete: dist/release/CloudRestoreAS-linux.tar.gz y CloudRestoreAS-win.zip +# Paquete: dist/release/CloudRestoreAS--{linux,win}-.{tar.gz,zip} +# + SHA256SUMS + release.json +# Publica: paquetes genéricos de Gitea (fuente de verdad que consume el PANEL) # # Uso: # ./build-all.sh # ambos + empaquetado @@ -12,6 +14,9 @@ # ./build-all.sh --windows-only # solo Windows # ./build-all.sh --no-package # sin generar .tar.gz/.zip # ./build-all.sh --clean # rebuild desde cero (borra venvs/bundled/dist) +# ./build-all.sh --publish # además publica en Gitea (requiere GITEA_TOKEN) +# ./build-all.sh --publish --notify-panel # y avisa al PANEL para que sincronice +# ./build-all.sh --publish --force-publish # reemplaza una versión ya publicada # ============================================================================= set -uo pipefail @@ -22,6 +27,8 @@ DO_LINUX=1 DO_WINDOWS=1 DO_PACKAGE=1 DO_CLEAN=0 +DO_PUBLISH=0 +PUBLISH_ARGS=() for arg in "$@"; do case "$arg" in @@ -29,6 +36,9 @@ for arg in "$@"; do --windows-only) DO_LINUX=0 ;; --no-package) DO_PACKAGE=0 ;; --clean) DO_CLEAN=1 ;; + --publish) DO_PUBLISH=1 ;; + --notify-panel) PUBLISH_ARGS+=(--notify-panel) ;; + --force-publish) PUBLISH_ARGS+=(--force) ;; -h|--help) grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "Opción desconocida: $arg" >&2; exit 2 ;; @@ -128,4 +138,33 @@ fi FAIL=0 [[ "$DO_LINUX" -eq 1 && "$LIN_OK" -ne 1 ]] && FAIL=1 [[ "$DO_WINDOWS" -eq 1 && "$WIN_OK" -ne 1 ]] && FAIL=1 + +# --- Publicación a Gitea ----------------------------------------------------- +# Solo con el build completo: publicar una versión a la que le falta una plataforma +# dejaría en el PANEL un release que no se le puede instalar a la mitad de los +# servidores. Los paquetes genéricos son inmutables, así que corregirlo obligaría a +# quemar el número de versión. +if [[ "$DO_PUBLISH" -eq 1 ]]; then + echo + if [[ "$FAIL" -eq 1 ]]; then + err "no se publica: el build no terminó bien" + elif [[ "$DO_PACKAGE" -eq 0 ]]; then + err "no se publica: --publish necesita el empaquetado (quita --no-package)" + FAIL=1 + elif [[ "$DO_LINUX" -eq 0 || "$DO_WINDOWS" -eq 0 ]]; then + err "no se publica: se requieren ambas plataformas (quita --linux-only/--windows-only)" + FAIL=1 + else + say "Publicando en Gitea" + stage_start + if bash "$ROOT/packaging/scripts/publish-release.sh" "${PUBLISH_ARGS[@]+"${PUBLISH_ARGS[@]}"}"; then + ok "publicado en Gitea" + else + err "falló la publicación en Gitea" + FAIL=1 + fi + stage_end "Publicación" + fi +fi + exit $FAIL diff --git a/install.ps1 b/install.ps1 index e24a55b..ce0b9dd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,124 +1,227 @@ -# Script de instalación rápida +<# +.SYNOPSIS + Instalador Windows de CloudRestoreAS. -Write-Host "===============================================" -ForegroundColor Cyan -Write-Host "CloudRestoreAS - Instalación Rápida" -ForegroundColor Cyan -Write-Host "===============================================" -ForegroundColor Cyan -Write-Host "" +.DESCRIPTION + NO instala ni descarga NADA en el sistema: el binario es 100% autocontenido (Qt, + driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo coloca el + .exe, hace el bootstrap de config\, opcionalmente siembra las credenciales del PANEL + y registra el arranque automático. -# Verificar Python -Write-Host "1. Verificando Python..." -ForegroundColor Yellow -$pythonVersion = python --version 2>$null -if (-not $pythonVersion) { - Write-Host "❌ Python no está instalado o no está en el PATH" -ForegroundColor Red - Write-Host " Descarga Python 3.11+ desde: https://www.python.org/downloads/" -ForegroundColor Yellow - Read-Host "Presiona Enter para salir..." - exit 1 -} -Write-Host "✅ $pythonVersion" -ForegroundColor Green + Por eso tampoco se usa NSSM ni ningún envoltorio de servicio descargado: el arranque + 24/7 se resuelve con una tarea programada ONSTART, que ya viene en el SO. -# Verificar versión de Python -$versionString = $pythonVersion -replace "Python ", "" -$version = [version]($versionString.Split()[0]) -if ($version -lt [version]"3.11") { - Write-Host "❌ Python $version es demasiado antiguo. Se requiere 3.11+" -ForegroundColor Red - Read-Host "Presiona Enter para salir..." - exit 1 -} +.PARAMETER Service + Arranque 24/7 sin sesión: tarea programada ONSTART como SYSTEM (recomendado en servidor). -# Verificar 7-Zip -Write-Host "" -Write-Host "2. Verificando 7-Zip..." -ForegroundColor Yellow -$sevenZipPaths = @( - "C:\Program Files\7-Zip\7z.exe", - "D:\Program Files\7-Zip\7z.exe", - "C:\Program Files (x86)\7-Zip\7z.exe" +.PARAMETER Desktop + Arranque al iniciar sesión. La app registra su propia tarea ONLOGON al ejecutarse. + +.PARAMETER Prefix + Carpeta destino. Default C:\Aduanasoft\CloudRestoreAS. + +.PARAMETER PanelEnvFile + Archivo KEY=valor con CLOUDRESTORE_PANEL_API_URL / _API_TOKEN / _INSTANCE_KEY que se + fusiona en config\.env tras el bootstrap y luego se borra. Lo usa el instalador remoto + del PANEL para dejar el servidor configurado sin intervención. + +.EXAMPLE + .\install.ps1 -Service +.EXAMPLE + .\install.ps1 -Desktop -Prefix 'D:\CloudRestoreAS' +#> +[CmdletBinding()] +param( + [switch]$Service, + [switch]$Desktop, + [string]$Prefix = 'C:\Aduanasoft\CloudRestoreAS', + [string]$PanelEnvFile = '' ) -$sevenZipFound = $false -foreach ($path in $sevenZipPaths) { - if (Test-Path $path) { - Write-Host "✅ 7-Zip encontrado en: $path" -ForegroundColor Green - $sevenZipFound = $true - break + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$BinName = 'CloudRestoreAS.exe' +$TaskName = 'CloudRestoreAS' +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan } +function Write-Ok($msg) { Write-Host "OK: $msg" -ForegroundColor Green } +function Write-Warn($msg) { Write-Warning $msg } + +if ($Service -and $Desktop) { + throw 'Elige -Service o -Desktop, no ambos.' +} +$Mode = if ($Service) { 'service' } elseif ($Desktop) { 'desktop' } else { 'none' } + +# --- Localizar el binario (mismas rutas candidatas que install.sh) ------------------- +$candidates = @( + (Join-Path $ScriptDir "dist\$BinName"), + (Join-Path $ScriptDir $BinName) +) +$src = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 +if (-not $src) { + throw "No se encontró el binario ($BinName). Ejecuta .\build.ps1 primero." +} + +# -Service crea una tarea como SYSTEM: requiere elevación. +$isAdmin = ([Security.Principal.WindowsPrincipal] ` + [Security.Principal.WindowsIdentity]::GetCurrent() +).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if ($Mode -eq 'service' -and -not $isAdmin) { + throw 'Se requiere PowerShell como Administrador para -Service (tarea ONSTART como SYSTEM).' +} + +Write-Host '===============================================' -ForegroundColor Cyan +Write-Host 'CloudRestoreAS - Instalación Windows' +Write-Host " Binario : $src" +Write-Host " Destino : $Prefix" +Write-Host " Modo : $Mode" +Write-Host '===============================================' -ForegroundColor Cyan + +# --- Colocar el binario ------------------------------------------------------------- +Write-Step 'Instalando binario' +New-Item -ItemType Directory -Path $Prefix -Force | Out-Null +$dest = Join-Path $Prefix $BinName + +# Si hay una instancia corriendo, el .exe queda bloqueado y Copy-Item falla. Se detiene +# la tarea y se esperan los procesos antes de reemplazar (caso actualización). +if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { + Write-Step 'Deteniendo tarea programada existente' + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +} +Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | ForEach-Object { + $_ | Stop-Process -Force -ErrorAction SilentlyContinue +} +# Espera acotada a que el SO libere el archivo; sin esto el Copy-Item puede fallar. +for ($i = 0; $i -lt 10; $i++) { + if (-not (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue)) { break } + Start-Sleep -Milliseconds 500 +} + +Copy-Item -LiteralPath $src -Destination $dest -Force +Write-Ok "Binario instalado en $dest" + +# --- Bootstrap de config\ ----------------------------------------------------------- +# El propio binario crea config\, .env y las carpetas de trabajo al arrancar. Se corre +# una vez acotado por timeout para que el operador ya pueda editar config\.env. +Write-Step 'Inicializando config\ (bootstrap)' +Push-Location $Prefix +try { + $env:QT_QPA_PLATFORM = 'offscreen' + $proc = Start-Process -FilePath $dest -ArgumentList '--headless' -PassThru -WindowStyle Hidden + if (-not $proc.WaitForExit(20000)) { + $proc | Stop-Process -Force -ErrorAction SilentlyContinue } -} -if (-not $sevenZipFound) { - Write-Host "⚠️ 7-Zip no encontrado en ubicaciones estándar" -ForegroundColor Yellow - Write-Host " Descarga 7-Zip desde: https://www.7-zip.org/" -ForegroundColor Yellow - Write-Host " (Puedes configurar la ruta manualmente en la aplicación)" -ForegroundColor Cyan +} finally { + Remove-Item Env:\QT_QPA_PLATFORM -ErrorAction SilentlyContinue + Pop-Location } -# Verificar ODBC Driver -Write-Host "" -Write-Host "3. Verificando ODBC Driver for SQL Server..." -ForegroundColor Yellow -$odbcDrivers = Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"} -if ($odbcDrivers) { - Write-Host "✅ ODBC Driver encontrado:" -ForegroundColor Green - $odbcDrivers | ForEach-Object { Write-Host " - $($_.Name)" -ForegroundColor Gray } +$envPath = Join-Path $Prefix 'config\.env' +if (Test-Path -LiteralPath $envPath) { + Write-Ok 'config\.env creado.' } else { - Write-Host "⚠️ ODBC Driver for SQL Server no encontrado" -ForegroundColor Yellow - Write-Host " Descarga desde: https://aka.ms/downloadmsodbcsql" -ForegroundColor Yellow - Write-Host " (Requerido para conectar con SQL Server)" -ForegroundColor Cyan + Write-Warn 'config\.env se creará en la primera ejecución.' } -# Crear entorno virtual -Write-Host "" -Write-Host "4. Creando entorno virtual..." -ForegroundColor Yellow -if (Test-Path "venv") { - Write-Host " El entorno virtual ya existe, omitiendo..." -ForegroundColor Gray -} else { - python -m venv venv - if ($LASTEXITCODE -eq 0) { - Write-Host "✅ Entorno virtual creado" -ForegroundColor Green +# --- Siembra de credenciales del PANEL ---------------------------------------------- +# Fusión replace-or-append: respeta el resto de config\.env y es idempotente, así que +# reinstalar no duplica claves ni pierde ajustes locales. +function Merge-EnvFile { + param([string]$Target, [hashtable]$Values) + + $lines = if (Test-Path -LiteralPath $Target) { + @(Get-Content -LiteralPath $Target -Encoding UTF8) + } else { @() } + + foreach ($key in $Values.Keys) { + $line = "$key=$($Values[$key])" + $idx = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match "^\s*$([regex]::Escape($key))\s*=") { $idx = $i; break } + } + if ($idx -ge 0) { $lines[$idx] = $line } else { $lines += $line } + } + + # Se escribe SIN BOM a propósito. `Set-Content -Encoding UTF8` en PowerShell 5.1 agrega BOM + # (EF BB BF), y python-dotenv abre el archivo con encoding utf-8 (no utf-8-sig), así que el + # BOM se pega a la primera línea. Si esa primera línea es una clave —lo que pasa cuando el + # bootstrap no alcanzó a crear config\.env y este archivo se genera desde cero— la clave + # queda ilegible para el agente: se instalaría sin conectarse al panel, con toda la + # apariencia de un error de captura. + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllLines($Target, [string[]]$lines, $utf8NoBom) +} + +if ($PanelEnvFile) { + if (-not (Test-Path -LiteralPath $PanelEnvFile)) { + throw "No existe el archivo indicado en -PanelEnvFile: $PanelEnvFile" + } + Write-Step 'Sembrando credenciales del PANEL en config\.env' + $allowed = @( + 'CLOUDRESTORE_PANEL_API_URL', + 'CLOUDRESTORE_PANEL_API_TOKEN', + 'CLOUDRESTORE_PANEL_INSTANCE_KEY', + 'CLOUDRESTORE_PANEL_VERIFY_SSL' + ) + $values = @{} + foreach ($raw in Get-Content -LiteralPath $PanelEnvFile -Encoding UTF8) { + $line = $raw.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + $eq = $line.IndexOf('=') + if ($eq -lt 1) { continue } + $key = $line.Substring(0, $eq).Trim() + # Lista blanca: el archivo viene de la red, no debe poder inyectar otras claves. + if ($allowed -contains $key) { + $values[$key] = $line.Substring($eq + 1).Trim() + } + } + if ($values.Count -gt 0) { + Merge-EnvFile -Target $envPath -Values $values + Write-Ok "$($values.Count) clave(s) del PANEL escritas en config\.env" } else { - Write-Host "❌ Error creando entorno virtual" -ForegroundColor Red - Read-Host "Presiona Enter para salir..." - exit 1 + Write-Warn 'El archivo -PanelEnvFile no traía claves CLOUDRESTORE_PANEL_* válidas.' + } + # El archivo trae el token en claro: se borra en cuanto se consumió. + Remove-Item -LiteralPath $PanelEnvFile -Force -ErrorAction SilentlyContinue +} + +# --- Arranque automático ------------------------------------------------------------ +switch ($Mode) { + 'service' { + Write-Step 'Registrando tarea programada ONSTART (SYSTEM)' + # Sin NSSM: una tarea ONSTART como SYSTEM cubre el 24/7 headless con lo que ya + # trae el SO. QT_QPA_PLATFORM=offscreen porque SYSTEM no tiene sesión gráfica. + $action = New-ScheduledTaskAction -Execute $dest ` + -Argument '--start-engine --headless' -WorkingDirectory $Prefix + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount ` + -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit ([TimeSpan]::Zero) + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` + -Principal $principal -Settings $settings -Force | Out-Null + Start-ScheduledTask -TaskName $TaskName + Write-Ok "Tarea '$TaskName' registrada y arrancada." + Write-Host " Estado : Get-ScheduledTask -TaskName $TaskName" + Write-Host " Logs : $Prefix\config\logs" + } + 'desktop' { + Write-Host 'Modo escritorio: la app registra su autostart ONLOGON al iniciarse.' + Write-Host "Ejecuta '$dest' en tu sesión." + } + 'none' { + Write-Host 'Instalación sin arranque automático.' + Write-Host "Ejecuta: `"$dest`" --start-engine --headless" } } -# Activar entorno virtual -Write-Host "" -Write-Host "5. Instalando dependencias..." -ForegroundColor Yellow -& ".\venv\Scripts\python.exe" -m pip install --upgrade pip -& ".\venv\Scripts\pip.exe" install -r requirements.txt - -if ($LASTEXITCODE -eq 0) { - Write-Host "✅ Dependencias instaladas" -ForegroundColor Green -} else { - Write-Host "❌ Error instalando dependencias" -ForegroundColor Red - Read-Host "Presiona Enter para salir..." - exit 1 +Write-Host '' +Write-Host "Siguiente paso: revisa $envPath (CLOUDRESTORE_PANEL_*) y reinicia." +if ($Mode -eq 'service') { + Write-Host " Tras editar: Stop-ScheduledTask -TaskName $TaskName; Start-ScheduledTask -TaskName $TaskName" } - -# Crear directorios necesarios -Write-Host "" -Write-Host "6. Creando directorios..." -ForegroundColor Yellow -$dirs = @("data", "logs") -foreach ($dir in $dirs) { - if (-not (Test-Path $dir)) { - New-Item -ItemType Directory -Path $dir | Out-Null - Write-Host "✅ Creado: $dir" -ForegroundColor Green - } else { - Write-Host " $dir ya existe" -ForegroundColor Gray - } -} - -# Resumen -Write-Host "" -Write-Host "===============================================" -ForegroundColor Green -Write-Host "Instalación Completada" -ForegroundColor Green -Write-Host "===============================================" -ForegroundColor Green -Write-Host "" -Write-Host "Para ejecutar la aplicación:" -ForegroundColor Cyan -Write-Host " 1. Activa el entorno virtual:" -ForegroundColor White -Write-Host " .\venv\Scripts\Activate.ps1" -ForegroundColor Yellow -Write-Host " 2. Ejecuta la aplicación:" -ForegroundColor White -Write-Host " python runner.py" -ForegroundColor Yellow -Write-Host "" -Write-Host "O ejecuta directamente:" -ForegroundColor Cyan -Write-Host " .\venv\Scripts\python.exe runner.py" -ForegroundColor Yellow -Write-Host "" -Write-Host "Lee el README.md para configuración completa." -ForegroundColor Cyan -Write-Host "" - -Read-Host "Presiona Enter para salir..." +Write-Host 'Listo.' diff --git a/install.sh b/install.sh index e8d320b..f89b0c9 100755 --- a/install.sh +++ b/install.sh @@ -1,35 +1,49 @@ #!/usr/bin/env bash # Instalador Linux de CloudRestoreAS. # -# NO instala ni descarga NADA del sistema: el binario es 100% autocontenido -# (Qt/xcb, driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo -# coloca el binario, marca permisos, hace el bootstrap de config/, y opcionalmente -# registra el arranque automático (servicio systemd headless o autostart de escritorio). +# NO instala ni descarga NADA del sistema: el binario es 100% autocontenido (Qt/xcb, +# driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo coloca el +# binario, marca permisos, hace el bootstrap de config/, opcionalmente siembra las +# credenciales del PANEL, y registra el arranque automático (servicio systemd headless +# o autostart de escritorio). # # Uso: # sudo ./install.sh --service # 24/7 headless vía systemd (recomendado en servidor) # ./install.sh --desktop # autostart de escritorio (requiere sesión gráfica) # ./install.sh # solo instala; sin arranque automático # +# Opciones: +# --panel-env-file # archivo KEY=valor con CLOUDRESTORE_PANEL_* que se +# # fusiona en config/.env y se borra al terminar. +# # Lo usa el instalador remoto del PANEL para dejar +# # el servidor configurado sin intervención. +# # Variables: # PREFIX=/opt/cloudrestoreas # carpeta destino (default) # SERVICE_USER= # usuario del servicio systemd (default: quien invoca) set -euo pipefail MODE="none" -for arg in "$@"; do - case "$arg" in - --service) MODE="service" ;; - --desktop) MODE="desktop" ;; +PANEL_ENV_FILE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --service) MODE="service"; shift ;; + --desktop) MODE="desktop"; shift ;; + --panel-env-file) + [[ $# -ge 2 ]] || { echo "ERROR: --panel-env-file requiere una ruta" >&2; exit 2; } + PANEL_ENV_FILE="$2"; shift 2 ;; + --panel-env-file=*) PANEL_ENV_FILE="${1#*=}"; shift ;; -h|--help) grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;; - *) echo "Opción desconocida: $arg" >&2; exit 2 ;; + *) echo "Opción desconocida: $1" >&2; exit 2 ;; esac done SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PREFIX="${PREFIX:-/opt/cloudrestoreas}" BIN_NAME="CloudRestoreAS" +UNIT_NAME="cloudrestoreas.service" # Localizar el binario: dist/CloudRestoreAS, dist/CloudRestoreAS-linux, o junto al script. SRC="" @@ -52,8 +66,27 @@ echo " Destino : $PREFIX" echo " Modo : $MODE" echo "===============================================" +# --- Detener el servicio si está corriendo ------------------------------------ +# Un ELF en ejecución no se puede sobrescribir (ETXTBSY), así que una actualización +# tiene que detenerlo antes de reemplazar el binario. Se recuerda si estaba activo +# para volver a levantarlo al final incluso en los modos que no tocan systemd. +WAS_ACTIVE=0 +if command -v systemctl >/dev/null 2>&1; then + if systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then + WAS_ACTIVE=1 + echo "Deteniendo $UNIT_NAME para reemplazar el binario..." + systemctl stop "$UNIT_NAME" >/dev/null 2>&1 || \ + echo "AVISO: no se pudo detener $UNIT_NAME (¿falta sudo?); la copia puede fallar." >&2 + fi +fi + mkdir -p "$PREFIX" -install -m 0755 "$SRC" "$PREFIX/$BIN_NAME" +if ! install -m 0755 "$SRC" "$PREFIX/$BIN_NAME"; then + echo "ERROR: no se pudo instalar el binario en $PREFIX/$BIN_NAME" >&2 + echo " Si el archivo está en uso, detén el proceso y reintenta:" >&2 + echo " sudo systemctl stop $UNIT_NAME # o mata el proceso de escritorio" >&2 + exit 1 +fi echo "Binario instalado en $PREFIX/$BIN_NAME" # Bootstrap de config/: el propio binario crea config/, .env y carpetas de trabajo @@ -68,11 +101,94 @@ else echo "NOTA: config/.env se creará en la primera ejecución." fi +# --- Siembra de credenciales del PANEL ---------------------------------------- +# Fusión replace-or-append: respeta el resto de config/.env y es idempotente, así que +# reinstalar no duplica claves ni pierde ajustes locales. Se escribe vía archivo y no +# por argumentos para que el token no quede visible en `ps` ni en el historial. +merge_env_keys() { + local target="$1"; shift + local -a pairs=("$@") + local tmp line key pair matched + + tmp="$(mktemp)" + if [[ -f "$target" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + matched="" + for pair in "${pairs[@]}"; do + key="${pair%%=*}" + if [[ "$line" =~ ^[[:space:]]*"$key"[[:space:]]*= ]]; then + matched="$pair" + break + fi + done + if [[ -n "$matched" ]]; then + printf '%s\n' "$matched" >> "$tmp" + else + printf '%s\n' "$line" >> "$tmp" + fi + done < "$target" + fi + + for pair in "${pairs[@]}"; do + key="${pair%%=*}" + if ! grep -qE "^[[:space:]]*${key}[[:space:]]*=" "$tmp" 2>/dev/null; then + printf '%s\n' "$pair" >> "$tmp" + fi + done + + # cat > preserva el inodo y los permisos 0600 del .env; mv los reemplazaría. + cat "$tmp" > "$target" + rm -f "$tmp" +} + +if [[ -n "$PANEL_ENV_FILE" ]]; then + if [[ ! -f "$PANEL_ENV_FILE" ]]; then + echo "ERROR: no existe el archivo indicado en --panel-env-file: $PANEL_ENV_FILE" >&2 + exit 1 + fi + + ENV_PATH="$PREFIX/config/.env" + touch "$ENV_PATH" + chmod 0600 "$ENV_PATH" 2>/dev/null || true + + # Lista blanca: el archivo llega por la red y no debe poder inyectar otras claves. + ALLOWED_KEYS="CLOUDRESTORE_PANEL_API_URL CLOUDRESTORE_PANEL_API_TOKEN" + ALLOWED_KEYS="$ALLOWED_KEYS CLOUDRESTORE_PANEL_INSTANCE_KEY CLOUDRESTORE_PANEL_VERIFY_SSL" + + PAIRS=() + while IFS= read -r raw || [[ -n "$raw" ]]; do + raw="${raw%$'\r'}" # tolera CRLF si vino de Windows + raw="${raw#"${raw%%[![:space:]]*}"}" # recorta espacios a la izquierda + if [[ -z "$raw" || "${raw:0:1}" == "#" || "$raw" != *=* ]]; then + continue + fi + key="${raw%%=*}" + key="${key//[[:space:]]/}" + value="${raw#*=}" + for allowed in $ALLOWED_KEYS; do + if [[ "$key" == "$allowed" ]]; then + PAIRS+=("${key}=${value}") + break + fi + done + done < "$PANEL_ENV_FILE" + + if [[ "${#PAIRS[@]}" -gt 0 ]]; then + merge_env_keys "$ENV_PATH" "${PAIRS[@]}" + echo "${#PAIRS[@]} clave(s) del PANEL escritas en config/.env" + else + echo "AVISO: --panel-env-file no traía claves CLOUDRESTORE_PANEL_* válidas." >&2 + fi + + # El archivo trae el token en claro: se borra en cuanto se consumió. + rm -f "$PANEL_ENV_FILE" +fi + case "$MODE" in service) SERVICE_USER="${SERVICE_USER:-${SUDO_USER:-$(id -un)}}" - UNIT_SRC="$SCRIPT_DIR/packaging/linux/cloudrestoreas.service" - UNIT_DST="/etc/systemd/system/cloudrestoreas.service" + UNIT_SRC="$SCRIPT_DIR/packaging/linux/$UNIT_NAME" + UNIT_DST="/etc/systemd/system/$UNIT_NAME" if [[ ! -w "$(dirname "$UNIT_DST")" ]]; then echo "ERROR: se requiere sudo para instalar el servicio systemd" >&2 exit 1 @@ -82,7 +198,7 @@ case "$MODE" in -e "s|__EXEC__|$PREFIX/$BIN_NAME|g" \ "$UNIT_SRC" > "$UNIT_DST" systemctl daemon-reload - systemctl enable --now cloudrestoreas.service + systemctl enable --now "$UNIT_NAME" echo "Servicio systemd instalado y arrancado (usuario: $SERVICE_USER)." echo " Estado : systemctl status cloudrestoreas" echo " Logs : journalctl -u cloudrestoreas -f" @@ -97,6 +213,14 @@ case "$MODE" in ;; esac +# Si se detuvo un servicio que estaba activo y el modo elegido no lo relevanta, se +# restaura: una actualización no debe dejar el restaurador apagado en silencio. +if [[ "$WAS_ACTIVE" -eq 1 && "$MODE" != "service" ]]; then + echo "Reiniciando $UNIT_NAME (estaba activo antes de la actualización)..." + systemctl start "$UNIT_NAME" >/dev/null 2>&1 || \ + echo "AVISO: no se pudo reiniciar $UNIT_NAME; hazlo a mano." >&2 +fi + echo "" echo "Siguiente paso: edita $PREFIX/config/.env (CLOUDRESTORE_PANEL_*) y reinicia." [[ "$MODE" == "service" ]] && echo " Tras editar: sudo systemctl restart cloudrestoreas" diff --git a/packaging/CloudRestoreAS.spec b/packaging/CloudRestoreAS.spec index 244fd80..3c0cba9 100644 --- a/packaging/CloudRestoreAS.spec +++ b/packaging/CloudRestoreAS.spec @@ -1,5 +1,6 @@ # -*- mode: python ; coding: utf-8 -*- """PyInstaller spec — onefile portable (ejecutar en Windows o Linux según destino).""" +import re import sys from pathlib import Path @@ -8,9 +9,27 @@ ROOT = Path(SPECPATH).resolve().parent platform = "windows" if sys.platform == "win32" else "linux" bundled = ROOT / "packaging" / "bundled" / platform + +def _read_app_version() -> str: + """ + Lee __version__ de app/__init__.py sin importar el paquete: el spec corre en el + intérprete de PyInstaller y no debe cargar las dependencias de la app. + """ + text = (ROOT / "app" / "__init__.py").read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE) + if not match: + raise SystemExit("No se pudo leer __version__ de app/__init__.py") + return match.group(1) + + +APP_VERSION = _read_app_version() + _assets = ROOT / "packaging" / "assets" datas = [ (str(ROOT / "packaging" / "templates" / "env.default"), "packaging/templates"), + # bundled-versions.json va embebido para que el bootstrap sepa qué versiones de + # 7-Zip/ODBC trae este build y re-despliegue config/ cuando cambien. + (str(ROOT / "packaging" / "bundled-versions.json"), "packaging"), ] if _assets.is_dir(): for _icon in _assets.iterdir(): @@ -89,6 +108,82 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) _icon_file = ROOT / "packaging" / "assets" / "tray-icon.ico" _icon = str(_icon_file) if _icon_file.is_file() else None + +def _version_quad(version: str) -> tuple: + """VSVersionInfo exige exactamente 4 enteros; se rellena o recorta lo que haga falta.""" + parts = [] + for chunk in version.split(".")[:4]: + digits = "".join(c for c in chunk if c.isdigit()) + parts.append(int(digits) if digits else 0) + while len(parts) < 4: + parts.append(0) + return tuple(parts) + + +def _version_info_dir(): + """ + Directorio donde escribir el recurso de versión. + + Se prefiere `workpath`, el directorio de trabajo que PyInstaller inyecta en el namespace + del spec, por dos razones concretas de este proyecto: + 1. build.ps1 lo redirige a %LOCALAPPDATA%\\Temp cuando el repo está en una ruta UNC + (\\\\wsl.localhost\\...), que es el caso al compilar Windows desde WSL. + 2. `build/` en la raíz del repo lo crea el build de Linux DENTRO DE DOCKER, así que + queda propiedad de root; el Python de Windows escribe por SMB como el usuario + normal y recibe PermissionError. + """ + candidate = globals().get("workpath") + if candidate: + return Path(candidate) + return ROOT / "build" + + +def _write_version_info(version: str) -> str: + """ + Genera el recurso de versión de Windows. Sin esto el .exe sale sin metadatos y las + Propiedades del archivo no muestran nada, lo que complica auditar qué versión está + instalada en un servidor. + """ + quad = _version_quad(version) + out = _version_info_dir() / "version_info.txt" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + f"""VSVersionInfo( + ffi=FixedFileInfo( + filevers={quad}, + prodvers={quad}, + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo([ + StringTable('080904B0', [ + StringStruct('CompanyName', 'Aduanasoft'), + StringStruct('FileDescription', 'CloudRestoreAS - Restauracion automatica SQL Server'), + StringStruct('FileVersion', '{version}'), + StringStruct('InternalName', 'CloudRestoreAS'), + StringStruct('LegalCopyright', 'Aduanasoft'), + StringStruct('OriginalFilename', 'CloudRestoreAS.exe'), + StringStruct('ProductName', 'CloudRestoreAS'), + StringStruct('ProductVersion', '{version}'), + ]) + ]), + VarFileInfo([VarStruct('Translation', [0x0809, 1200])]), + ], +) +""", + encoding="utf-8", + ) + return str(out) + + +# El recurso de versión solo existe en PE/Windows; en Linux PyInstaller lo ignoraría. +_version_info = _write_version_info(APP_VERSION) if platform == "windows" else None + exe = EXE( pyz, a.scripts, @@ -98,6 +193,7 @@ exe = EXE( [], name="CloudRestoreAS", icon=_icon, + version=_version_info, debug=False, bootloader_ignore_signals=False, strip=False, diff --git a/packaging/scripts/package-release.sh b/packaging/scripts/package-release.sh index 4857df6..1102d6b 100755 --- a/packaging/scripts/package-release.sh +++ b/packaging/scripts/package-release.sh @@ -1,47 +1,242 @@ #!/usr/bin/env bash -# Empaqueta artefactos en dist/release/ +# Empaqueta artefactos publicables en dist/release/. +# +# Salida: +# CloudRestoreAS--linux-x86_64.tar.gz binario + install.sh + unit + LEEME +# CloudRestoreAS--win-x86_64.zip .exe + install.ps1 + LEEME +# SHA256SUMS checksums de los paquetes +# release.json manifiesto que consume publish-release.sh +# +# Además deja copias crudas sin versión (CloudRestoreAS.exe, CloudRestoreAS-linux) para +# la verificación local de autocontención descrita en BUILD.md §8. Esas NO se publican: +# publish-release.sh solo sube lo que aparece en release.json. +# +# Variables: +# CLOUDRESTORE_TARGET_ARCH=x86_64 # arquitectura declarada en los nombres/manifiesto +# CLOUDRESTORE_MIN_BINARY_MB=25 # piso de tamaño para detectar un build truncado set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" RELEASE="$ROOT/dist/release" mkdir -p "$RELEASE" -[[ -f "$ROOT/dist/CloudRestoreAS.exe" ]] && cp -f "$ROOT/dist/CloudRestoreAS.exe" "$RELEASE/" -[[ -f "$ROOT/dist/CloudRestoreAS" ]] && cp -f "$ROOT/dist/CloudRestoreAS" "$RELEASE/CloudRestoreAS-linux" && chmod +x "$RELEASE/CloudRestoreAS-linux" +RED='\033[31m'; NC='\033[0m' +err() { echo -e "${RED}ERROR:${NC} $*" >&2; } + +# --- Versión: fuente única en app/__init__.py -------------------------------------- +VERSION="$( + python3 - "$ROOT/app/__init__.py" <<'PY' +import re, sys +text = open(sys.argv[1], encoding="utf-8").read() +m = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.M) +if not m: + sys.exit("No se pudo leer __version__ de app/__init__.py") +version = m.group(1) +# El PANEL ordena versiones comparándolas como tuplas de enteros; un formato distinto +# rompería la detección de "hay versión nueva". +if not re.fullmatch(r"\d+(\.\d+){1,3}", version): + sys.exit(f"__version__ inválida: '{version}' (se espera puntos y números, p.ej. 1.2.0)") +print(version) +PY +)" + +# Los builds del org corren en amd64 (Docker para Linux, host de desarrollo para +# Windows); no hay forma de inferir el arch del .exe desde aquí, así que se declara. +ARCH="${CLOUDRESTORE_TARGET_ARCH:-x86_64}" + +LINUX_PKG="CloudRestoreAS-${VERSION}-linux-${ARCH}.tar.gz" +WIN_PKG="CloudRestoreAS-${VERSION}-win-${ARCH}.zip" + +echo "Empaquetando CloudRestoreAS ${VERSION} (${ARCH})" + +# Los paquetes de esta versión se regeneran; los de otras versiones se conservan. +rm -f "$RELEASE/$LINUX_PKG" "$RELEASE/$WIN_PKG" "$RELEASE/SHA256SUMS" "$RELEASE/release.json" + +# Las copias crudas se borran SIEMPRE antes de recrearlas. Si no, una corrida anterior deja +# un binario en dist/release/ que se empaqueta como si fuera de esta versión aunque el build +# de ahora haya fallado: así se generó una vez un zip "1.1.0" con un .exe parcial de 4.9 MB, +# con su sha256 y su release.json perfectamente consistentes — el peor tipo de fallo, porque +# la verificación de integridad no lo detecta. +rm -f "$RELEASE/CloudRestoreAS.exe" "$RELEASE/CloudRestoreAS-linux" + +# --- Validación de los binarios recién compilados --------------------------------- +# Piso de tamaño: un onefile real lleva Qt + driver ODBC + 7-Zip embebidos, así que pesa +# cientos de MB. Cualquier cosa por debajo de esto es un build truncado, no un binario. +MIN_MB="${CLOUDRESTORE_MIN_BINARY_MB:-25}" +VERSION_FILE="$ROOT/app/__init__.py" + +check_binary() { + # $1 = ruta del binario, $2 = etiqueta para los mensajes + local path="$1" label="$2" + [[ -f "$path" ]] || return 1 + + local size_mb + size_mb=$(( $(stat -c %s "$path") / 1024 / 1024 )) + if [[ "$size_mb" -lt "$MIN_MB" ]]; then + err "el binario $label pesa ${size_mb} MB (mínimo esperado ${MIN_MB} MB): build truncado" + err " $path" + return 2 + fi + + # Un binario anterior al último cambio de __version__ pertenece a otra versión. Empaquetarlo + # con el nombre de la versión actual publicaría una mentira. + if [[ "$VERSION_FILE" -nt "$path" ]]; then + err "el binario $label es MÁS VIEJO que app/__init__.py: quedó de una versión anterior" + err " recompila esa plataforma antes de empaquetar, o borra $path" + return 2 + fi + return 0 +} + +HAVE_LINUX=0 +HAVE_WINDOWS=0 +FATAL=0 + +# El origen de verdad es dist/, que es donde escribe PyInstaller. Nunca dist/release/. +# El `|| rc=$?` es necesario: bajo `set -e`, una función que devuelve != 0 como comando +# suelto aborta el script de inmediato, sin llegar a evaluar FATAL ni a empaquetar la +# plataforma que sí compiló. +rc=0; check_binary "$ROOT/dist/CloudRestoreAS" "Linux" || rc=$? +case "$rc" in + 0) HAVE_LINUX=1 ;; + 2) FATAL=1 ;; +esac +rc=0; check_binary "$ROOT/dist/CloudRestoreAS.exe" "Windows" || rc=$? +case "$rc" in + 0) HAVE_WINDOWS=1 ;; + 2) FATAL=1 ;; +esac + +if [[ "$FATAL" -eq 1 ]]; then + err "empaquetado abortado para no publicar un artefacto que no corresponde a $VERSION" + exit 1 +fi + +# --- Copias crudas (verificación local, no se publican) --------------------------- +if [[ "$HAVE_WINDOWS" -eq 1 ]]; then + cp -f "$ROOT/dist/CloudRestoreAS.exe" "$RELEASE/" +fi +if [[ "$HAVE_LINUX" -eq 1 ]]; then + cp -f "$ROOT/dist/CloudRestoreAS" "$RELEASE/CloudRestoreAS-linux" + chmod +x "$RELEASE/CloudRestoreAS-linux" +fi cp -f "$ROOT/packaging/LEEME.txt" "$RELEASE/" -# Paquete Windows: carpeta raíz CloudRestoreAS/ con .exe + install.ps1 + LEEME -# (paridad con el .tar.gz de Linux). -if [[ -f "$RELEASE/CloudRestoreAS.exe" ]]; then - ROOT="$ROOT" RELEASE="$RELEASE" python3 <<'PY' +# --- Paquete Windows --------------------------------------------------------------- +# install.ps1 es el instalador de despliegue (autocontenido). El script de entorno de +# desarrollo vive en scripts/dev-setup.ps1 y NO va en el paquete: antes se empaquetaba +# por error, dejando en el zip un instalador que pedía Python y creaba un venv. +if [[ "$HAVE_WINDOWS" -eq 1 ]]; then + ROOT="$ROOT" RELEASE="$RELEASE" WIN_PKG="$WIN_PKG" python3 <<'PY' import os, zipfile from pathlib import Path -root = Path(os.environ["ROOT"]); release = Path(os.environ["RELEASE"]) -items = [(release / "CloudRestoreAS.exe", "CloudRestoreAS/CloudRestoreAS.exe"), - (root / "packaging" / "LEEME.txt", "CloudRestoreAS/LEEME.txt")] -if (root / "install.ps1").exists(): - items.append((root / "install.ps1", "CloudRestoreAS/install.ps1")) -with zipfile.ZipFile(release / "CloudRestoreAS-win.zip", "w", zipfile.ZIP_DEFLATED) as z: + +root = Path(os.environ["ROOT"]) +release = Path(os.environ["RELEASE"]) +# El .exe se toma de dist/, que es donde escribe PyInstaller. Tomarlo de dist/release/ +# permitía empaquetar la copia de una corrida anterior. +items = [ + (root / "dist" / "CloudRestoreAS.exe", "CloudRestoreAS/CloudRestoreAS.exe"), + (root / "install.ps1", "CloudRestoreAS/install.ps1"), + (root / "packaging" / "LEEME.txt", "CloudRestoreAS/LEEME.txt"), +] +missing = [str(src) for src, _ in items if not src.is_file()] +if missing: + raise SystemExit("Faltan archivos para el paquete Windows: " + ", ".join(missing)) + +out = release / os.environ["WIN_PKG"] +with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: for src, arc in items: z.write(src, arc) -print(" zip Windows:", release / "CloudRestoreAS-win.zip") +print(f" zip Windows: {out.name}") PY fi -if [[ -f "$RELEASE/CloudRestoreAS-linux" ]]; then - # Paquete de despliegue completo: binario + instalador + unit systemd + LEEME, - # bajo una carpeta raíz para que extraiga limpio. install.sh encuentra el binario +# --- Paquete Linux ----------------------------------------------------------------- +if [[ "$HAVE_LINUX" -eq 1 ]]; then + # Paquete de despliegue completo: binario + instalador + unit systemd + LEEME, bajo + # una carpeta raíz para que extraiga limpio. install.sh encuentra el binario # (CloudRestoreAS-linux) y la unit (packaging/linux/) relativos a su ubicación. - STAGE="$RELEASE/CloudRestoreAS-linux" PKG="$RELEASE/pkg-linux/CloudRestoreAS" rm -rf "$RELEASE/pkg-linux" mkdir -p "$PKG/packaging/linux" - cp -f "$STAGE" "$PKG/CloudRestoreAS-linux"; chmod +x "$PKG/CloudRestoreAS-linux" - cp -f "$ROOT/install.sh" "$PKG/install.sh"; chmod +x "$PKG/install.sh" + cp -f "$ROOT/dist/CloudRestoreAS" "$PKG/CloudRestoreAS-linux" + chmod +x "$PKG/CloudRestoreAS-linux" + cp -f "$ROOT/install.sh" "$PKG/install.sh" + chmod +x "$PKG/install.sh" cp -f "$ROOT/packaging/linux/cloudrestoreas.service" "$PKG/packaging/linux/" cp -f "$ROOT/packaging/LEEME.txt" "$PKG/LEEME.txt" - tar czf "$RELEASE/CloudRestoreAS-linux.tar.gz" -C "$RELEASE/pkg-linux" CloudRestoreAS + tar czf "$RELEASE/$LINUX_PKG" -C "$RELEASE/pkg-linux" CloudRestoreAS rm -rf "$RELEASE/pkg-linux" + echo " tar.gz Linux: $LINUX_PKG" fi -echo "Release en: $RELEASE" +# --- Checksums y manifiesto --------------------------------------------------------- +cd "$RELEASE" +PUBLISHABLE=() +[[ -f "$LINUX_PKG" ]] && PUBLISHABLE+=("$LINUX_PKG") +[[ -f "$WIN_PKG" ]] && PUBLISHABLE+=("$WIN_PKG") + +if [[ "${#PUBLISHABLE[@]}" -eq 0 ]]; then + echo "ERROR: no se generó ningún paquete; ¿corriste build.sh / build.ps1?" >&2 + exit 1 +fi + +sha256sum "${PUBLISHABLE[@]}" > SHA256SUMS +echo " SHA256SUMS: ${#PUBLISHABLE[@]} paquete(s)" + +VERSION="$VERSION" ARCH="$ARCH" ROOT="$ROOT" RELEASE="$RELEASE" \ +LINUX_PKG="$LINUX_PKG" WIN_PKG="$WIN_PKG" python3 <<'PY' +import hashlib, json, os +from datetime import datetime, timezone +from pathlib import Path + +release = Path(os.environ["RELEASE"]) +root = Path(os.environ["ROOT"]) +version = os.environ["VERSION"] +arch = os.environ["ARCH"] + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +artifacts = [] +for file_name, platform in ((os.environ["LINUX_PKG"], "linux"), (os.environ["WIN_PKG"], "windows")): + path = release / file_name + if not path.is_file(): + continue + artifacts.append( + { + "file_name": file_name, + "platform": platform, + "arch": arch, + "size": path.stat().st_size, + "sha256": sha256(path), + } + ) + +# Se registra qué versiones de 7-Zip/ODBC quedaron embebidas: es lo que distingue dos +# builds de la misma versión de app y lo que dispara el re-despliegue de config/. +bundled = {} +manifest_path = root / "packaging" / "bundled-versions.json" +if manifest_path.is_file(): + bundled = json.loads(manifest_path.read_text(encoding="utf-8")) + +payload = { + "product": "CloudRestoreAS", + "version": version, + "built_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "bundled": bundled, + "artifacts": artifacts, +} +(release / "release.json").write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +print(f" release.json: {len(artifacts)} artefacto(s) para publicar") +PY + +echo "" +echo "Release ${VERSION} en: $RELEASE" ls -lh "$RELEASE" diff --git a/packaging/scripts/publish-release.sh b/packaging/scripts/publish-release.sh new file mode 100755 index 0000000..afff058 --- /dev/null +++ b/packaging/scripts/publish-release.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# Publica los artefactos de dist/release/ en el registro de paquetes GENÉRICOS de Gitea. +# +# Gitea calcula y expone el sha256 de cada archivo, así que se convierte en la fuente de +# verdad de los binarios: el PANEL descubre las versiones leyendo su API y verifica la +# integridad contra ese hash. Aquí, tras subir, se compara lo que reporta Gitea contra +# release.json para no dejar una publicación a medias pasando por buena. +# +# Uso: +# GITEA_TOKEN=... ./packaging/scripts/publish-release.sh +# ... --dry-run # lista qué subiría, sin subir nada +# ... --force # reemplaza archivos ya publicados de esta versión +# ... --notify-panel # avisa al PANEL para que sincronice de inmediato +# +# Variables: +# GITEA_TOKEN (requerida) PAT de Gitea con scope write:package +# GITEA_BASE_URL default https://git.aduanasoft.com +# GITEA_OWNER default ADUANASOFT +# CRAS_PACKAGE default cloudrestoreas +# PANEL_API_URL (solo --notify-panel) URL base del PANEL +# CLOUDRESTORE_API_TOKEN (solo --notify-panel) token de servicio del PANEL +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +RELEASE="$ROOT/dist/release" +MANIFEST="$RELEASE/release.json" + +GITEA_BASE_URL="${GITEA_BASE_URL:-https://git.aduanasoft.com}" +GITEA_OWNER="${GITEA_OWNER:-ADUANASOFT}" +CRAS_PACKAGE="${CRAS_PACKAGE:-cloudrestoreas}" + +DRY_RUN=0 +FORCE=0 +NOTIFY_PANEL=0 + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --force) FORCE=1 ;; + --notify-panel) NOTIFY_PANEL=1 ;; + -h|--help) + grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Opción desconocida: $arg" >&2; exit 2 ;; + esac +done + +BLUE='\033[36m'; GREEN='\033[32m'; YELLOW='\033[33m'; RED='\033[31m'; NC='\033[0m' +say() { echo -e "${BLUE}==>${NC} $*"; } +ok() { echo -e "${GREEN}OK:${NC} $*"; } +warn() { echo -e "${YELLOW}AVISO:${NC} $*" >&2; } +err() { echo -e "${RED}ERROR:${NC} $*" >&2; } + +if [[ ! -f "$MANIFEST" ]]; then + err "no existe $MANIFEST; corre packaging/scripts/package-release.sh primero" + exit 1 +fi + +VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST")" + +# Los paquetes del manifiesto más los archivos de verificación, que se publican para +# poder auditar una versión sin depender del panel. +mapfile -t FILES < <( + python3 - "$MANIFEST" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +for artifact in data["artifacts"]: + print(artifact["file_name"]) +print("SHA256SUMS") +print("release.json") +PY +) + +if [[ "${#FILES[@]}" -le 2 ]]; then + err "release.json no lista artefactos; ¿falló el build de ambas plataformas?" + exit 1 +fi + +PKG_BASE="$GITEA_BASE_URL/api/packages/$GITEA_OWNER/generic/$CRAS_PACKAGE/$VERSION" +API_BASE="$GITEA_BASE_URL/api/v1/packages/$GITEA_OWNER/generic/$CRAS_PACKAGE/$VERSION" + +say "Publicando CloudRestoreAS $VERSION" +echo " Destino : $PKG_BASE" +echo " Archivos: ${#FILES[@]}" + +if [[ "$DRY_RUN" -eq 1 ]]; then + for f in "${FILES[@]}"; do + if [[ -f "$RELEASE/$f" ]]; then + printf ' [dry-run] PUT %s (%s)\n' "$f" "$(du -h "$RELEASE/$f" | cut -f1)" + else + warn "[dry-run] falta $f" + fi + done + ok "dry-run completado; no se subió nada" + exit 0 +fi + +if [[ -z "${GITEA_TOKEN:-}" ]]; then + err "falta GITEA_TOKEN (PAT de Gitea con scope write:package)" + echo " Ejemplo: GITEA_TOKEN=xxxx $0" >&2 + exit 1 +fi + +# --- Subida ------------------------------------------------------------------------ +for f in "${FILES[@]}"; do + path="$RELEASE/$f" + if [[ ! -f "$path" ]]; then + warn "no existe $f; se omite" + continue + fi + + if [[ "$FORCE" -eq 1 ]]; then + # Los paquetes genéricos son inmutables: para reemplazar hay que borrar primero. + curl -sS -o /dev/null -X DELETE \ + -H "Authorization: token $GITEA_TOKEN" \ + "$PKG_BASE/$f" || true + fi + + say "Subiendo $f ($(du -h "$path" | cut -f1))" + # -# manda la barra de progreso a stderr y -w el código HTTP a stdout, así se puede + # ver el avance de una subida de cientos de MB y a la vez capturar el resultado. + code="$( + curl -S -# -o /dev/null -w '%{http_code}' \ + --retry 2 --retry-delay 3 --retry-connrefused \ + -X PUT \ + -H "Authorization: token $GITEA_TOKEN" \ + --upload-file "$path" \ + "$PKG_BASE/$f" + )" + + case "$code" in + 201|200) ok "$f publicado" ;; + 409) + err "$f ya está publicado en la versión $VERSION" + echo " Los paquetes genéricos son inmutables. Sube una versión nueva (recomendado)" >&2 + echo " o reemplaza esta a propósito con --force." >&2 + exit 1 ;; + 401|403) + err "Gitea rechazó el token ($code); revisa GITEA_TOKEN y su scope write:package" + exit 1 ;; + *) + err "fallo subiendo $f (HTTP $code)" + exit 1 ;; + esac +done + +# --- Verificación contra la API que consume el PANEL --------------------------------- +say "Verificando en Gitea (misma API que lee el PANEL)" +files_json="$( + curl -sS -H "Authorization: token $GITEA_TOKEN" -H 'Accept: application/json' \ + "$API_BASE/files" +)" + +if ! MANIFEST="$MANIFEST" FILES_JSON="$files_json" python3 <<'PY' +import json, os, sys + +manifest = json.load(open(os.environ["MANIFEST"], encoding="utf-8")) +try: + remote = json.loads(os.environ["FILES_JSON"]) +except json.JSONDecodeError: + sys.exit("Gitea no devolvió JSON al listar los archivos del paquete") +if not isinstance(remote, list): + sys.exit(f"Respuesta inesperada de Gitea: {remote!r}") + +by_name = {entry.get("name"): entry for entry in remote} +problems = [] +for artifact in manifest["artifacts"]: + name = artifact["file_name"] + entry = by_name.get(name) + if entry is None: + problems.append(f"{name}: no aparece en Gitea") + continue + # Gitea calcula el sha256 del lado servidor: si coincide con el local, los bytes + # llegaron completos y el PANEL podrá verificar la descarga con ese mismo hash. + remote_sha = (entry.get("sha256") or "").lower() + if remote_sha != artifact["sha256"].lower(): + problems.append(f"{name}: sha256 local {artifact['sha256'][:12]}… != remoto {remote_sha[:12] or '(vacío)'}…") + elif int(entry.get("size") or -1) != artifact["size"]: + problems.append(f"{name}: tamaño local {artifact['size']} != remoto {entry.get('size')}") + else: + print(f" {name}: sha256 y tamaño coinciden") + +if problems: + print("\n".join(f" {p}" for p in problems), file=sys.stderr) + sys.exit(1) +PY +then + err "la verificación contra Gitea falló; la publicación quedó incompleta" + echo " Revisa el paquete y reintenta con --force." >&2 + exit 1 +fi +ok "verificación correcta" + +# --- Aviso al PANEL ------------------------------------------------------------------ +if [[ "$NOTIFY_PANEL" -eq 1 ]]; then + if [[ -z "${PANEL_API_URL:-}" || -z "${CLOUDRESTORE_API_TOKEN:-}" ]]; then + warn "--notify-panel requiere PANEL_API_URL y CLOUDRESTORE_API_TOKEN; se omite el aviso" + warn "El PANEL igual descubrirá la versión al sincronizar desde su UI." + else + say "Avisando al PANEL para que sincronice" + base="${PANEL_API_URL%/}" + code="$( + curl -sS -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $CLOUDRESTORE_API_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{}' \ + "$base/api/restore/agent-sync" + )" + if [[ "$code" == "200" ]]; then + ok "PANEL sincronizado" + else + # No es fatal: la publicación en Gitea ya está firme y el panel puede sincronizar + # después desde su UI. Solo se informa. + warn "el PANEL respondió $code al sincronizar; hazlo desde /versiones-cras" + fi + fi +fi + +echo "" +ok "CloudRestoreAS $VERSION publicado en Gitea" +echo " Paquete: $GITEA_BASE_URL/-/packages/$GITEA_OWNER/generic/$CRAS_PACKAGE/$VERSION" +echo " Siguiente: en el PANEL, /versiones-cras → Sincronizar con Gitea → Activar" diff --git a/runner.py b/runner.py index bed4803..0bf2926 100644 --- a/runner.py +++ b/runner.py @@ -28,9 +28,53 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="Fuerza el modo sin interfaz (Qt offscreen) para servidores sin display", ) + parser.add_argument( + "--version", + action="store_true", + help="Imprime la versión, plataforma y arquitectura, y termina", + ) return parser.parse_args(argv) +def _attach_windows_console() -> None: + """ + El .exe se compila con console=False (app de ventana), así que sin consola propia + stdout va al vacío. Cuando se invoca desde una terminal, se engancha a la consola + del proceso padre para que --version sea legible. En Linux no aplica. + """ + if sys.platform != "win32": + return + try: + import ctypes + + attach_parent_process = -1 + if not ctypes.windll.kernel32.AttachConsole(attach_parent_process): + return # sin consola del padre (doble clic): no hay dónde escribir + sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1) + sys.stderr = open("CONOUT$", "w", encoding="utf-8", buffering=1) + except (OSError, AttributeError): + # Sin consola disponible; el instalador remoto verifica por config/.version. + pass + + +def _print_version() -> int: + """ + Imprime la versión sin arrancar Qt ni el bootstrap: --version es una consulta. + El instalador remoto del PANEL prefiere leer config/.version (que el bootstrap + escribe), porque en Windows este stdout depende de haber consola del padre. + """ + from app import __version__ + from app.constants import APP_ARCH, APP_PLATFORM + + _attach_windows_console() + try: + sys.stdout.write(f"CloudRestoreAS {__version__} ({APP_PLATFORM}/{APP_ARCH})\n") + sys.stdout.flush() + except OSError: + pass + return 0 + + def _ensure_qt_platform(headless: bool = False) -> str: """ Selecciona el plugin de plataforma Qt en Linux. En un servidor headless (sin @@ -175,6 +219,10 @@ def _run() -> int: def main(): """Punto de entrada: ejecuta _run() y hace visible cualquier fallo de arranque.""" + # --version se atiende antes del bootstrap y de importar Qt/pyodbc: es una consulta + # barata que el instalador remoto usa para verificar el binario recién desplegado. + if "--version" in sys.argv[1:]: + sys.exit(_print_version()) try: sys.exit(_run()) except SystemExit: diff --git a/scripts/dev-setup.ps1 b/scripts/dev-setup.ps1 new file mode 100644 index 0000000..c861021 --- /dev/null +++ b/scripts/dev-setup.ps1 @@ -0,0 +1,128 @@ +# Preparación del ENTORNO DE DESARROLLO en Windows: verifica Python/7-Zip/ODBC del +# sistema, crea el venv e instala requirements.txt para correr `python runner.py`. +# +# NO es el instalador de despliegue. Para instalar el binario compilado en un servidor +# usa install.ps1 (raíz del repo), que es autocontenido y no necesita nada de esto. + +Write-Host "===============================================" -ForegroundColor Cyan +Write-Host "CloudRestoreAS - Entorno de desarrollo" -ForegroundColor Cyan +Write-Host "===============================================" -ForegroundColor Cyan +Write-Host "" + +# Verificar Python +Write-Host "1. Verificando Python..." -ForegroundColor Yellow +$pythonVersion = python --version 2>$null +if (-not $pythonVersion) { + Write-Host "❌ Python no está instalado o no está en el PATH" -ForegroundColor Red + Write-Host " Descarga Python 3.11+ desde: https://www.python.org/downloads/" -ForegroundColor Yellow + Read-Host "Presiona Enter para salir..." + exit 1 +} +Write-Host "✅ $pythonVersion" -ForegroundColor Green + +# Verificar versión de Python +$versionString = $pythonVersion -replace "Python ", "" +$version = [version]($versionString.Split()[0]) +if ($version -lt [version]"3.11") { + Write-Host "❌ Python $version es demasiado antiguo. Se requiere 3.11+" -ForegroundColor Red + Read-Host "Presiona Enter para salir..." + exit 1 +} + +# Verificar 7-Zip +Write-Host "" +Write-Host "2. Verificando 7-Zip..." -ForegroundColor Yellow +$sevenZipPaths = @( + "C:\Program Files\7-Zip\7z.exe", + "D:\Program Files\7-Zip\7z.exe", + "C:\Program Files (x86)\7-Zip\7z.exe" +) +$sevenZipFound = $false +foreach ($path in $sevenZipPaths) { + if (Test-Path $path) { + Write-Host "✅ 7-Zip encontrado en: $path" -ForegroundColor Green + $sevenZipFound = $true + break + } +} +if (-not $sevenZipFound) { + Write-Host "⚠️ 7-Zip no encontrado en ubicaciones estándar" -ForegroundColor Yellow + Write-Host " Descarga 7-Zip desde: https://www.7-zip.org/" -ForegroundColor Yellow + Write-Host " (Puedes configurar la ruta manualmente en la aplicación)" -ForegroundColor Cyan +} + +# Verificar ODBC Driver +Write-Host "" +Write-Host "3. Verificando ODBC Driver for SQL Server..." -ForegroundColor Yellow +$odbcDrivers = Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"} +if ($odbcDrivers) { + Write-Host "✅ ODBC Driver encontrado:" -ForegroundColor Green + $odbcDrivers | ForEach-Object { Write-Host " - $($_.Name)" -ForegroundColor Gray } +} else { + Write-Host "⚠️ ODBC Driver for SQL Server no encontrado" -ForegroundColor Yellow + Write-Host " Descarga desde: https://aka.ms/downloadmsodbcsql" -ForegroundColor Yellow + Write-Host " (Requerido para conectar con SQL Server)" -ForegroundColor Cyan +} + +# Crear entorno virtual +Write-Host "" +Write-Host "4. Creando entorno virtual..." -ForegroundColor Yellow +if (Test-Path "venv") { + Write-Host " El entorno virtual ya existe, omitiendo..." -ForegroundColor Gray +} else { + python -m venv venv + if ($LASTEXITCODE -eq 0) { + Write-Host "✅ Entorno virtual creado" -ForegroundColor Green + } else { + Write-Host "❌ Error creando entorno virtual" -ForegroundColor Red + Read-Host "Presiona Enter para salir..." + exit 1 + } +} + +# Activar entorno virtual +Write-Host "" +Write-Host "5. Instalando dependencias..." -ForegroundColor Yellow +& ".\venv\Scripts\python.exe" -m pip install --upgrade pip +& ".\venv\Scripts\pip.exe" install -r requirements.txt + +if ($LASTEXITCODE -eq 0) { + Write-Host "✅ Dependencias instaladas" -ForegroundColor Green +} else { + Write-Host "❌ Error instalando dependencias" -ForegroundColor Red + Read-Host "Presiona Enter para salir..." + exit 1 +} + +# Crear directorios necesarios +Write-Host "" +Write-Host "6. Creando directorios..." -ForegroundColor Yellow +$dirs = @("data", "logs") +foreach ($dir in $dirs) { + if (-not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir | Out-Null + Write-Host "✅ Creado: $dir" -ForegroundColor Green + } else { + Write-Host " $dir ya existe" -ForegroundColor Gray + } +} + +# Resumen +Write-Host "" +Write-Host "===============================================" -ForegroundColor Green +Write-Host "Instalación Completada" -ForegroundColor Green +Write-Host "===============================================" -ForegroundColor Green +Write-Host "" +Write-Host "Para ejecutar la aplicación:" -ForegroundColor Cyan +Write-Host " 1. Activa el entorno virtual:" -ForegroundColor White +Write-Host " .\venv\Scripts\Activate.ps1" -ForegroundColor Yellow +Write-Host " 2. Ejecuta la aplicación:" -ForegroundColor White +Write-Host " python runner.py" -ForegroundColor Yellow +Write-Host "" +Write-Host "O ejecuta directamente:" -ForegroundColor Cyan +Write-Host " .\venv\Scripts\python.exe runner.py" -ForegroundColor Yellow +Write-Host "" +Write-Host "Lee el README.md para configuración completa." -ForegroundColor Cyan +Write-Host "" + +Read-Host "Presiona Enter para salir..." diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 392659f..c53775f 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -77,3 +77,111 @@ def test_apply_env_overrides_paths(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("CLOUDRESTORE_INPUT_FOLDER", "/tmp/in") cfg = apply_env_overrides(DEFAULT_CONFIG.copy()) assert cfg["paths"]["input_folder"] == "/tmp/in" + + +def _redirect_bootstrap(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Reapunta los globales de bootstrap a un árbol temporal. Devuelve config/.""" + for name, rel in ( + ("APP_DIR", ""), + ("CONFIG_DIR", "config"), + ("DATA_DIR", "config/data"), + ("LOGS_DIR", "config/logs"), + ("ODBC_DIR", "config/odbc"), + ("SEVEN_ZIP_DIR", "config/7zip"), + ("ENV_PATH", "config/.env"), + ("DIR_ENTRADA", "Entrada"), + ("DIR_PROCESADOS", "Procesados"), + ("DIR_FALLADOS", "Fallados"), + ("DIR_TEMP", "Temp"), + ): + target = tmp_path / rel if rel else tmp_path + monkeypatch.setattr(f"app.config.bootstrap.{name}", target) + monkeypatch.setattr("app.constants.DB_PATH", tmp_path / "config" / "data" / "app.db") + return tmp_path / "config" + + +def test_bootstrap_escribe_sello_de_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """ + El instalador remoto del PANEL lee config/.version por SFTP para verificar el + despliegue: en Windows el .exe se compila con console=False y no hay stdout confiable. + """ + from app import __version__ + + config_dir = _redirect_bootstrap(tmp_path, monkeypatch) + ensure_runtime_layout() + assert (config_dir / ".version").read_text(encoding="utf-8").strip() == __version__ + + +def test_bootstrap_redespliega_deps_cuando_cambia_el_manifiesto( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """ + config/7zip y config/odbc se re-copian cuando el build trae otras versiones embebidas. + Antes solo se copiaban si la carpeta estaba vacía, así que una actualización con driver + ODBC nuevo conservaba el viejo indefinidamente. + """ + config_dir = _redirect_bootstrap(tmp_path, monkeypatch) + + src_7zip = tmp_path / "bundle" / "7zip" + src_odbc = tmp_path / "bundle" / "odbc" + src_7zip.mkdir(parents=True) + src_odbc.mkdir(parents=True) + (src_7zip / "7zz").write_text("7zz v26", encoding="utf-8") + (src_odbc / "odbcinst.ini").write_text("odbc 18.5", encoding="utf-8") + monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_7ZIP", src_7zip) + monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_ODBC", src_odbc) + + # bundled-versions.json se busca bajo BUNDLE_DIR/packaging (va embebido en el onefile). + bundle_root = tmp_path / "bundle_root" + (bundle_root / "packaging").mkdir(parents=True) + manifest = bundle_root / "packaging" / "bundled-versions.json" + manifest.write_text('{"seven_zip": "26.01"}', encoding="utf-8") + monkeypatch.setattr("app.config.bootstrap.BUNDLE_DIR", bundle_root) + + ensure_runtime_layout() + assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "7zz v26" + stamp_before = (config_dir / ".bundled_deps").read_text(encoding="utf-8").strip() + assert stamp_before.startswith("sha256:") + + # Mismo manifiesto: no debe re-copiar (no pisa ajustes locales en cada arranque). + (config_dir / "7zip" / "7zz").write_text("editado a mano", encoding="utf-8") + ensure_runtime_layout() + assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "editado a mano" + + # Manifiesto distinto (build con deps nuevas): debe re-copiar. + (src_7zip / "7zz").write_text("7zz v27", encoding="utf-8") + (src_odbc / "odbcinst.ini").write_text("odbc 19.0", encoding="utf-8") + manifest.write_text('{"seven_zip": "27.00"}', encoding="utf-8") + ensure_runtime_layout() + assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "7zz v27" + assert (config_dir / "odbc" / "odbcinst.ini").read_text(encoding="utf-8") == "odbc 19.0" + assert (config_dir / ".bundled_deps").read_text(encoding="utf-8").strip() != stamp_before + + # El re-despliegue SOBRESCRIBE, así que lo que el operador hubiera editado a mano debe + # quedar respaldado: no es reconstruible. + assert (config_dir / "7zip.bak" / "7zz").read_text(encoding="utf-8") == "editado a mano" + assert (config_dir / "odbc.bak" / "odbcinst.ini").read_text(encoding="utf-8") == "odbc 18.5" + + +def test_bootstrap_no_respalda_cuando_no_hay_refresco( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Sin cambio de manifiesto no se re-copia, así que tampoco debe crearse el .bak.""" + config_dir = _redirect_bootstrap(tmp_path, monkeypatch) + + src_7zip = tmp_path / "bundle" / "7zip" + src_7zip.mkdir(parents=True) + (src_7zip / "7zz").write_text("7zz v26", encoding="utf-8") + monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_7ZIP", src_7zip) + + bundle_root = tmp_path / "bundle_root" + (bundle_root / "packaging").mkdir(parents=True) + (bundle_root / "packaging" / "bundled-versions.json").write_text( + '{"seven_zip": "26.01"}', encoding="utf-8" + ) + monkeypatch.setattr("app.config.bootstrap.BUNDLE_DIR", bundle_root) + + ensure_runtime_layout() + ensure_runtime_layout() # segunda vez: mismo manifiesto, no debe refrescar + + assert not (config_dir / "7zip.bak").exists() diff --git a/tests/test_forward_false_failure.py b/tests/test_forward_false_failure.py new file mode 100644 index 0000000..8470690 --- /dev/null +++ b/tests/test_forward_false_failure.py @@ -0,0 +1,170 @@ +""" +Regresión del "falso fallo" en el reenvío por SFTP. + +Escenario reportado: el SFTP SÍ entrega el archivo, pero un error POSTERIOR a la entrega +(p.ej. al mover el ZIP a Procesados) degradaba el job a FAILED y lo reportaba como 'failed' +al panel, aunque el respaldo ya había llegado al destino. La corrección hace que la entrega +exitosa sea el punto de no retorno: el job queda COMPLETED/forwarded y el error posterior +solo se registra, sin caer a Fallados ni reportar 'failed'. +""" +import pytest + +from app.constants import JobStatus +from app.engine.restore_worker import RestoreWorker + + +FORWARD_ROUTE = { + "action": "forward", + "db_name": "DB1", + "node_key": "NODO", + "target": { + "id": 7, + "name": "Omega", + "ssh_host": "h", + "ssh_username": "u", + "ssh_password": "p", + "input_folder": "D:\\In", + }, +} + + +@pytest.fixture +def base_config(tmp_path): + return { + "paths": { + "input_folder": str(tmp_path / "in"), + "processed_folder": str(tmp_path / "processed"), + "failed_folder": str(tmp_path / "failed"), + "extract_folder": str(tmp_path / "extract"), + "data_sql_folder": str(tmp_path / "data"), + "seven_zip_exe": "C:\\Program Files\\7-Zip\\7z.exe", + }, + "sql": {"server": "localhost", "use_windows_auth": True}, + "timeouts": {"extract_minutes": 30, "restore_minutes": 60}, + "panel": { + "api_url": "http://panel:3000", + "api_token": "tok", + "instance_key": "Alfa", + }, + } + + +class FakeJob: + source_name = "NODO.ZIP" + source_path = "C:\\in\\NODO.ZIP" + db_name = "DB1" + node_name = "NODO" + + +def _wire_common(monkeypatch, statuses, reported): + """Mockea las dependencias del worker comunes a los dos escenarios.""" + monkeypatch.setattr( + "app.engine.restore_worker.JobRepository.get", lambda job_id: FakeJob() + ) + monkeypatch.setattr( + "app.engine.restore_worker.JobRepository.update_node_and_db", + lambda *a, **k: None, + ) + monkeypatch.setattr( + "app.engine.restore_worker.JobRepository.update_timing", lambda *a, **k: None + ) + monkeypatch.setattr( + "app.engine.restore_worker.JobRepository.update_status", + lambda job_id, status, **k: statuses.append(status), + ) + monkeypatch.setattr( + "app.engine.restore_worker.JobRepository.delete", lambda *a, **k: None + ) + monkeypatch.setattr( + "app.engine.restore_worker.JobStepRepository.create", lambda *a, **k: 1 + ) + monkeypatch.setattr( + "app.engine.restore_worker.JobStepRepository.complete", lambda *a, **k: None + ) + monkeypatch.setattr( + "app.engine.restore_worker.EventRepository.create", lambda *a, **k: None + ) + monkeypatch.setattr( + "app.engine.restore_worker.panel_client.resolve_route", + lambda *a, **k: FORWARD_ROUTE, + ) + monkeypatch.setattr( + "app.engine.restore_worker.panel_client.report_job_result", + lambda **k: reported.append(k.get("status")), + ) + + +def test_error_post_entrega_no_degrada_a_fallido(monkeypatch, base_config): + """Entrega OK + error al mover a Procesados => job forwarded, nunca failed.""" + statuses: list[str] = [] + reported: list[str] = [] + _wire_common(monkeypatch, statuses, reported) + + # El SFTP entrega con éxito. + monkeypatch.setattr( + "app.engine.restore_worker.sftp_copy.upload_zip_parts", + lambda *a, **k: ["D:/In/NODO.ZIP"], + ) + + moved_to_failed = {"called": False} + monkeypatch.setattr( + RestoreWorker, + "_move_zip_to_failed", + lambda self, job: moved_to_failed.__setitem__("called", True), + ) + + # Error POSTERIOR a la entrega: mover a Procesados falla. + def boom(self, job): + raise OSError("disco lleno al mover a Procesados") + + monkeypatch.setattr(RestoreWorker, "_move_zip_to_processed", boom) + + worker = RestoreWorker("job-1", base_config) + worker.run() + + assert JobStatus.COMPLETED in statuses + assert JobStatus.FAILED not in statuses + assert reported == ["forwarded"] + assert moved_to_failed["called"] is False + + +def test_fallo_real_de_sftp_va_a_fallados_y_limpia_parcial(monkeypatch, base_config): + """Fallo genuino de entrega => FAILED, ZIP a Fallados, y limpieza de partes subidas.""" + from app.transfer.sftp_copy import SFTPCopyError + + statuses: list[str] = [] + reported: list[str] = [] + _wire_common(monkeypatch, statuses, reported) + + # El SFTP falla en la parte 2, adjuntando lo ya subido (envío parcial). + err = SFTPCopyError("timeout en la parte 2") + err.uploaded = ["D:/In/NODO.ZIP.001"] + + def failing_upload(*a, **k): + raise err + + monkeypatch.setattr( + "app.engine.restore_worker.sftp_copy.upload_zip_parts", failing_upload + ) + + cleaned: list[str] = [] + monkeypatch.setattr( + "app.engine.restore_worker.sftp_copy.cleanup_remote", + lambda target, path: cleaned.append(path), + ) + + moved_to_failed = {"called": False} + monkeypatch.setattr( + RestoreWorker, + "_move_zip_to_failed", + lambda self, job: moved_to_failed.__setitem__("called", True), + ) + + worker = RestoreWorker("job-1", base_config) + worker.run() + + assert JobStatus.FAILED in statuses + assert JobStatus.COMPLETED not in statuses + assert reported == ["failed"] + assert moved_to_failed["called"] is True + assert cleaned == ["D:/In/NODO.ZIP.001"] diff --git a/tests/test_maintenance_scheduler.py b/tests/test_maintenance_scheduler.py new file mode 100644 index 0000000..e0f5396 --- /dev/null +++ b/tests/test_maintenance_scheduler.py @@ -0,0 +1,89 @@ +""" +Pruebas del programador de mantenimiento diario: gating "máximo 1/día", catch-up tras +reinicios y respeto de run_at_hour. Se mockea ConfigRepository con un dict en memoria. +""" +from datetime import datetime + +import pytest + +from app.engine import maintenance_scheduler as msched +from app.engine.maintenance_scheduler import DailyMaintenanceScheduler + + +@pytest.fixture +def state_store(monkeypatch): + store: dict = {} + monkeypatch.setattr( + msched.ConfigRepository, "get", staticmethod(lambda key, default=None: store.get(key, default)) + ) + monkeypatch.setattr( + msched.ConfigRepository, "set", staticmethod(lambda key, value: store.__setitem__(key, value)) + ) + monkeypatch.setattr(msched.EventRepository, "create", staticmethod(lambda *a, **k: None)) + return store + + +def _scheduler(runs, clock, run_at_hour=3): + return DailyMaintenanceScheduler( + task=lambda: runs.append(1), + run_at_hour=run_at_hour, + clock=clock, + ) + + +def test_corre_una_vez_por_dia(state_store): + runs: list[int] = [] + now = {"dt": datetime(2026, 7, 24, 5, 0, 0)} + sched = _scheduler(runs, lambda: now["dt"]) + + sched._run_if_due() # primer día: corre + sched._run_if_due() # mismo día: NO corre + assert len(runs) == 1 + + +def test_catch_up_al_cambiar_de_dia(state_store): + runs: list[int] = [] + now = {"dt": datetime(2026, 7, 24, 5, 0, 0)} + sched = _scheduler(runs, lambda: now["dt"]) + + sched._run_if_due() + assert len(runs) == 1 + + now["dt"] = datetime(2026, 7, 25, 5, 0, 0) # día nuevo + sched._run_if_due() + assert len(runs) == 2 + + +def test_respeta_run_at_hour(state_store): + runs: list[int] = [] + now = {"dt": datetime(2026, 7, 24, 1, 0, 0)} # antes de las 3 + sched = _scheduler(runs, lambda: now["dt"], run_at_hour=3) + + sched._run_if_due() # aún no es la hora + assert len(runs) == 0 + + now["dt"] = datetime(2026, 7, 24, 3, 30, 0) # ya pasó la hora + sched._run_if_due() + assert len(runs) == 1 + + +def test_run_at_hour_none_corre_al_primer_wake(state_store): + runs: list[int] = [] + now = {"dt": datetime(2026, 7, 24, 0, 5, 0)} + sched = _scheduler(runs, lambda: now["dt"], run_at_hour=None) + sched._run_if_due() + assert len(runs) == 1 + + +def test_claim_al_inicio_persiste_fecha_aunque_falle(state_store): + """Si la tarea falla, el turno del día igual se consume (claim al inicio).""" + now = {"dt": datetime(2026, 7, 24, 5, 0, 0)} + + def boom(): + raise RuntimeError("fallo de limpieza") + + sched = DailyMaintenanceScheduler(task=boom, run_at_hour=3, clock=lambda: now["dt"]) + sched._run_if_due() # no debe propagar la excepción + + assert state_store["retention_last_run"]["last_run_date"] == "2026-07-24" + assert state_store["retention_last_run"]["last_status"].startswith("error") diff --git a/tests/test_multipart_case.py b/tests/test_multipart_case.py new file mode 100644 index 0000000..4a9aa20 --- /dev/null +++ b/tests/test_multipart_case.py @@ -0,0 +1,116 @@ +""" +Recolección de partes de un ZIP multipart, sin importar la caja de la extensión. + +En este dominio los respaldos llegan con extensión en MAYÚSCULAS con frecuencia (los propios +tests del panel usan `GENERICA-TEST.ZIP`). El bug que esto fija: `_collect_zip_paths` hacía +`path.stem.split(".zip")[0]`, que con `EMPRESA.ZIP.001` dejaba `base_name="EMPRESA.ZIP"` y +armaba el glob `EMPRESA.ZIP.zip.*`, que no encuentra nada. Devolvía lista vacía, y como +`_move_zip_to_processed` y `_move_zip_to_failed` iteran sobre ese resultado, **las partes nunca +salían de Entrada**: se acumulaban ahí mezcladas con los pendientes. +""" + +from pathlib import Path + +import pytest + + +class _FakeExtractor: + """Espeja is_multipart de SevenZipExtractor: reconoce .zip.NNN sin importar la caja.""" + + @staticmethod + def is_multipart(zip_path: str) -> bool: + path = Path(zip_path) + # .zip.001 -> suffix ".001", stem "algo.zip" + return path.stem.lower().endswith(".zip") and len(path.suffix) == 4 + + +def _collect(source_path: str) -> list[str]: + """ + Copia de la lógica de RestoreWorker._collect_zip_paths, aislada para poder probarla sin + arrastrar PySide6 ni pyodbc. Si la implementación cambia, este test debe cambiar con ella. + """ + path = Path(source_path) + if not _FakeExtractor.is_multipart(str(path)): + return [str(path)] + + 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] + return [str(path)] + + +@pytest.mark.parametrize("ext", ["zip", "ZIP", "Zip"]) +def test_recolecta_todas_las_partes_sin_importar_la_caja(tmp_path: Path, ext: str): + """Las tres partes se recolectan igual con la extensión en minúsculas, MAYÚSCULAS o mixta.""" + names = [f"EMPRESA.{ext}.001", f"EMPRESA.{ext}.002", f"EMPRESA.{ext}.003"] + for name in names: + (tmp_path / name).write_bytes(b"x") + + collected = _collect(str(tmp_path / names[0])) + + assert [Path(p).name for p in collected] == names, ( + f"con extensión .{ext} se recolectaron {len(collected)} de {len(names)} partes" + ) + + +def test_orden_estable_entre_partes(tmp_path: Path): + """El orden importa: 7-Zip necesita la .001 primero para reensamblar.""" + for i in (3, 1, 10, 2): + (tmp_path / f"BASE.ZIP.{i:03d}").write_bytes(b"x") + + collected = [Path(p).name for p in _collect(str(tmp_path / "BASE.ZIP.001"))] + assert collected == ["BASE.ZIP.001", "BASE.ZIP.002", "BASE.ZIP.003", "BASE.ZIP.010"] + + +def test_no_mezcla_partes_de_otro_respaldo(tmp_path: Path): + """Dos multipart en la misma carpeta no deben contaminarse entre sí.""" + for name in ["ALFA.ZIP.001", "ALFA.ZIP.002", "OMEGA.ZIP.001", "OMEGA.zip.002"]: + (tmp_path / name).write_bytes(b"x") + + alfa = [Path(p).name for p in _collect(str(tmp_path / "ALFA.ZIP.001"))] + assert alfa == ["ALFA.ZIP.001", "ALFA.ZIP.002"] + + # OMEGA tiene sus dos partes con distinta caja: aun así deben salir las dos. + omega = [Path(p).name for p in _collect(str(tmp_path / "OMEGA.ZIP.001"))] + assert sorted(omega) == ["OMEGA.ZIP.001", "OMEGA.zip.002"] + + +def test_zip_simple_devuelve_solo_ese_archivo(tmp_path: Path): + simple = tmp_path / "UNICO.ZIP" + simple.write_bytes(b"x") + assert _collect(str(simple)) == [str(simple)] + + +def test_multipart_sin_partes_localizadas_devuelve_el_original(tmp_path: Path): + """ + Si no se localizan las partes, se devuelve el archivo original en lugar de lista vacía: + mover una sola parte es mejor que dejarla atorada en Entrada indefinidamente. + """ + huerfana = tmp_path / "SOLA.ZIP.007" + huerfana.write_bytes(b"x") + # Es multipart por el nombre, y su propia parte sí se encuentra. + assert _collect(str(huerfana)) == [str(huerfana)] + + +def test_la_implementacion_real_no_usa_glob_en_minusculas(): + """ + Tripwire sobre el código real: `glob` distingue mayúsculas en Linux, así que un patrón + en minúsculas nunca encontraría `.ZIP.001`. La implementación debe filtrar iterdir() + comparando en minúsculas. + """ + source = ( + Path(__file__).resolve().parent.parent / "app" / "engine" / "restore_worker.py" + ).read_text(encoding="utf-8") + start = source.index("def _collect_zip_paths") + body = source[start : start + 2000] + + assert 'glob(f"{base_name}.zip.*")' not in body, "glob en minúsculas: no halla .ZIP en Linux" + assert "iterdir()" in body + assert ".lower()" in body diff --git a/tests/test_panel_client.py b/tests/test_panel_client.py index dad29ab..6ded464 100644 --- a/tests/test_panel_client.py +++ b/tests/test_panel_client.py @@ -57,7 +57,9 @@ def test_target_db_vacio_devuelve_none(): def test_target_ok(monkeypatch): captured = {} - def fake_get(url, headers=None, timeout=None): + # **kwargs porque panel_client también pasa verify=; una firma rígida rompe la prueba + # cada vez que se agrega un kwarg al cliente. + def fake_get(url, headers=None, timeout=None, **kwargs): captured["url"] = url return FakeResponse(200, VALID_TARGET) @@ -116,7 +118,7 @@ def test_target_json_invalido_devuelve_none(monkeypatch): def test_report_job_result_201_true(monkeypatch): captured = {} - def fake_post(url, json=None, headers=None, timeout=None): + def fake_post(url, json=None, headers=None, timeout=None, **kwargs): captured["json"] = json return FakeResponse(201) @@ -166,7 +168,7 @@ def test_test_connection_url_invalida(): def test_report_instance_config_200_true(monkeypatch): captured = {} - def fake_post(url, json=None, headers=None, timeout=None): + def fake_post(url, json=None, headers=None, timeout=None, **kwargs): captured["url"] = url captured["json"] = json return FakeResponse(200) @@ -256,7 +258,7 @@ CATALOG_RESPONSE = { def test_list_restore_target_names_ok(monkeypatch): captured = {} - def fake_get(url, headers=None, timeout=None): + def fake_get(url, headers=None, timeout=None, **kwargs): captured["url"] = url return FakeResponse(200, CATALOG_RESPONSE) @@ -346,7 +348,7 @@ ROUTE_RESTORE_LOCAL = { def test_resolve_route_forward_ok(monkeypatch): captured = {} - def fake_get(url, headers=None, timeout=None): + def fake_get(url, headers=None, timeout=None, **kwargs): captured["url"] = url return FakeResponse(200, ROUTE_FORWARD) @@ -379,3 +381,71 @@ def test_resolve_route_forward_sin_input_folder_invalido(monkeypatch): panel_client.requests, "get", lambda *a, **k: FakeResponse(200, bad) ) assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None + + +# ============================================================================ +# platform / arch: el PANEL los usa para elegir qué artefacto le toca a este +# servidor al instalar o actualizar (a24c.cras_releases se llavea por +# version + platform + arch). +# ============================================================================ + + +def _capture_post(monkeypatch) -> dict: + captured = {} + + def fake_post(url, json=None, headers=None, timeout=None, **kwargs): + captured["url"] = url + captured["json"] = json + captured["headers"] = headers + return FakeResponse(200) + + monkeypatch.setattr(panel_client.requests, "post", fake_post) + return captured + + +def test_report_instance_config_envia_platform_y_arch(monkeypatch): + captured = _capture_post(monkeypatch) + ok = panel_client.report_instance_config( + URL, + TOKEN, + r"D:\Backups\Entrada", + processed_folder=r"D:\Backups\Procesados", + host_name="WIN-01", + app_version="1.1.0", + instance_key="Alfa", + platform_name="windows", + arch="x86_64", + ) + assert ok is True + assert captured["json"]["platform"] == "windows" + assert captured["json"]["arch"] == "x86_64" + assert captured["json"]["processed_folder"] == r"D:\Backups\Procesados" + + +def test_report_instance_config_sin_platform_manda_none(monkeypatch): + # Compatibilidad hacia atrás: un agente viejo no manda estas claves y el PANEL + # debe poder caer al texto libre de restore_targets.os. + captured = _capture_post(monkeypatch) + panel_client.report_instance_config(URL, TOKEN, r"D:\In") + assert captured["json"]["platform"] is None + assert captured["json"]["arch"] is None + + +@pytest.mark.parametrize("blank", ["", " ", None]) +def test_report_instance_config_platform_en_blanco_es_none(monkeypatch, blank): + captured = _capture_post(monkeypatch) + panel_client.report_instance_config( + URL, TOKEN, r"D:\In", platform_name=blank, arch=blank + ) + assert captured["json"]["platform"] is None + assert captured["json"]["arch"] is None + + +def test_constantes_platform_arch_son_del_vocabulario_del_panel(): + # El PANEL valida platform contra ('windows','linux'); si esto cambia hay que + # actualizar el CHECK de a24c.cras_releases y la validación del endpoint. + from app.constants import APP_ARCH, APP_PLATFORM + + assert APP_PLATFORM in ("windows", "linux") + assert APP_ARCH and APP_ARCH == APP_ARCH.strip() + assert " " not in APP_ARCH diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py new file mode 100644 index 0000000..2028c5b --- /dev/null +++ b/tests/test_release_metadata.py @@ -0,0 +1,291 @@ +""" +Pruebas del contrato de release: versión, artefactos y manifiesto. + +Lo que se protege aquí es la cadena que hace posible la distribución automatizada: +app/__init__.py es la fuente única de la versión, package-release.sh la usa para nombrar +los artefactos y armar release.json, y el PANEL compara versiones como tuplas de enteros +para saber si hay una más nueva. Un formato de versión distinto rompe esa comparación en +silencio, así que se valida el formato, no solo que exista. +""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent + +# Mismo patrón que valida package-release.sh y que el PANEL puede ordenar. +VERSION_RE = re.compile(r"^\d+(\.\d+){1,3}$") + + +def read_version_from_source() -> str: + """Lee __version__ del archivo, sin importar el paquete (igual que el spec).""" + text = (ROOT / "app" / "__init__.py").read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE) + assert match, "no se encontró __version__ en app/__init__.py" + return match.group(1) + + +def test_version_tiene_formato_comparable(): + assert VERSION_RE.match(read_version_from_source()) + + +def test_version_del_paquete_coincide_con_el_archivo(): + # El spec de PyInstaller y package-release.sh leen el archivo con regex; el resto de + # la app importa app.__version__. Ambos caminos deben dar lo mismo. + from app import __version__ + + assert __version__ == read_version_from_source() + + +def test_ui_no_hardcodea_la_version(): + # El diálogo "Acerca de" traía la versión literal y quedó desfasado del paquete. + text = (ROOT / "app" / "ui" / "main_window.py").read_text(encoding="utf-8") + assert "__version__" in text + assert not re.search(r"CloudRestoreAS v\d+\.\d+\.\d+", text) + + +def test_bundled_versions_es_json_valido(): + # El bootstrap calcula el sha256 de este archivo para decidir si re-despliega + # config/7zip y config/odbc; si no es JSON válido el build queda inconsistente. + manifest = ROOT / "packaging" / "bundled-versions.json" + data = json.loads(manifest.read_text(encoding="utf-8")) + assert data.get("seven_zip"), "falta la versión de 7-Zip" + + +def test_spec_inyecta_metadatos_de_version_en_windows(): + spec = (ROOT / "packaging" / "CloudRestoreAS.spec").read_text(encoding="utf-8") + assert "version=_version_info" in spec, "el EXE debe recibir el recurso de versión" + assert "bundled-versions.json" in spec, "bundled-versions.json debe ir embebido" + + +def test_instalador_de_despliegue_y_script_de_desarrollo_estan_separados(): + """ + scripts/dev-setup.ps1 prepara un venv de desarrollo y no tiene nada que hacer en el + zip del ejecutable autocontenido; install.ps1 es el instalador de despliegue. Antes + eran el mismo archivo y se empaquetaba el de desarrollo. + """ + script = (ROOT / "packaging" / "scripts" / "package-release.sh").read_text(encoding="utf-8") + assert '(root / "install.ps1", "CloudRestoreAS/install.ps1")' in script + assert "CloudRestoreAS/dev-setup.ps1" not in script + + dev_setup = ROOT / "scripts" / "dev-setup.ps1" + installer = ROOT / "install.ps1" + assert dev_setup.is_file() and installer.is_file() + # El de desarrollo crea venv; el de despliegue registra el arranque automático. + assert "venv" in dev_setup.read_text(encoding="utf-8") + assert "Register-ScheduledTask" in installer.read_text(encoding="utf-8") + + +def test_instaladores_aceptan_panel_env_file(): + """ + El instalador remoto del PANEL siembra las credenciales por archivo, no por argv, + para que el token no quede visible en `ps` ni en el historial del destino. + """ + sh = (ROOT / "install.sh").read_text(encoding="utf-8") + ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") + assert "--panel-env-file" in sh + assert "PanelEnvFile" in ps1 + # Lista blanca de claves en ambos: el archivo llega por la red. + for text in (sh, ps1): + assert "CLOUDRESTORE_PANEL_API_TOKEN" in text + assert "CLOUDRESTORE_PANEL_INSTANCE_KEY" in text + + +@pytest.mark.skipif(sys.platform == "win32", reason="usa bash y sha256sum") +def test_package_release_genera_manifiesto_consistente(tmp_path: Path): + """ + Corre package-release.sh contra un árbol mínimo con binarios simulados y verifica + que release.json y SHA256SUMS concuerden entre sí y con los archivos en disco. + """ + for rel in ( + "app/__init__.py", + "packaging/scripts/package-release.sh", + "packaging/LEEME.txt", + "packaging/bundled-versions.json", + "packaging/linux/cloudrestoreas.service", + "install.sh", + "install.ps1", + ): + dest = tmp_path / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes((ROOT / rel).read_bytes()) + + dist = tmp_path / "dist" + dist.mkdir() + (dist / "CloudRestoreAS").write_text("ELF simulado\n", encoding="utf-8") + (dist / "CloudRestoreAS.exe").write_text("PE simulado\n", encoding="utf-8") + + result = subprocess.run( + ["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")], + capture_output=True, + text=True, + # Los binarios simulados son de unos bytes; se desactiva el piso de tamaño, que se + # prueba aparte en test_package_release_rechaza_binario_truncado. + env={**os.environ, "CLOUDRESTORE_MIN_BINARY_MB": "0"}, + ) + assert result.returncode == 0, f"package-release.sh falló:\n{result.stderr}" + + release_dir = dist / "release" + manifest = json.loads((release_dir / "release.json").read_text(encoding="utf-8")) + version = read_version_from_source() + + assert manifest["version"] == version + assert manifest["product"] == "CloudRestoreAS" + assert manifest["bundled"]["seven_zip"], "el manifiesto debe registrar las deps embebidas" + + platforms = {a["platform"] for a in manifest["artifacts"]} + assert platforms == {"linux", "windows"} + + # Cada artefacto: existe, el nombre lleva versión y plataforma, y el sha256/tamaño + # del manifiesto coinciden con el archivo real. + import hashlib + + sums = dict( + reversed(line.split(maxsplit=1)) + for line in (release_dir / "SHA256SUMS").read_text(encoding="utf-8").splitlines() + if line.strip() + ) + for artifact in manifest["artifacts"]: + path = release_dir / artifact["file_name"] + assert path.is_file(), f"falta el artefacto {artifact['file_name']}" + assert version in artifact["file_name"] + assert artifact["arch"] in artifact["file_name"] + assert path.stat().st_size == artifact["size"] + assert hashlib.sha256(path.read_bytes()).hexdigest() == artifact["sha256"] + # SHA256SUMS y release.json no deben poder divergir. + assert sums[path.name.strip()].strip() == artifact["sha256"] + + # Contenido de los paquetes: el instalador de despliegue va dentro, el script de + # desarrollo NO (antes se empaquetaba dev-setup.ps1 junto al .exe autocontenido). + import tarfile + import zipfile + + win_pkg = next(a for a in manifest["artifacts"] if a["platform"] == "windows") + with zipfile.ZipFile(release_dir / win_pkg["file_name"]) as zf: + names = set(zf.namelist()) + assert "CloudRestoreAS/CloudRestoreAS.exe" in names + assert "CloudRestoreAS/install.ps1" in names + assert not any("dev-setup" in n for n in names) + + linux_pkg = next(a for a in manifest["artifacts"] if a["platform"] == "linux") + with tarfile.open(release_dir / linux_pkg["file_name"]) as tf: + members = set(tf.getnames()) + # El instalador remoto extrae y corre CloudRestoreAS/install.sh, que a su vez busca + # el binario y la unit systemd relativos a su ubicación. + assert "CloudRestoreAS/CloudRestoreAS-linux" in members + assert "CloudRestoreAS/install.sh" in members + assert "CloudRestoreAS/packaging/linux/cloudrestoreas.service" in members + + +def _stage_package_tree(tmp_path: Path) -> Path: + """Árbol mínimo para correr package-release.sh. Devuelve dist/.""" + for rel in ( + "app/__init__.py", + "packaging/scripts/package-release.sh", + "packaging/LEEME.txt", + "packaging/bundled-versions.json", + "packaging/linux/cloudrestoreas.service", + "install.sh", + "install.ps1", + ): + dest = tmp_path / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes((ROOT / rel).read_bytes()) + dist = tmp_path / "dist" + (dist / "release").mkdir(parents=True, exist_ok=True) + return dist + + +def _run_package(tmp_path: Path, min_mb: str = "1") -> subprocess.CompletedProcess: + return subprocess.run( + ["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")], + capture_output=True, + text=True, + env={**os.environ, "CLOUDRESTORE_MIN_BINARY_MB": min_mb}, + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="usa bash") +def test_package_release_ignora_binario_rancio_en_release(tmp_path: Path): + """ + Un binario que quedó en dist/release/ de una corrida anterior NO debe empaquetarse. + + Así se generó una vez un zip etiquetado 1.1.0 con un .exe parcial de 4.9 MB: el build de + Windows había fallado, pero el empaquetado tomaba la copia vieja de dist/release/ en lugar + del binario recién compilado en dist/. El sha256 y el release.json quedaban consistentes + con los bytes equivocados, así que la verificación de integridad no lo detectaba. + """ + dist = _stage_package_tree(tmp_path) + (dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024)) # Linux sí compiló + # Sobrante de una corrida previa; dist/CloudRestoreAS.exe NO existe. + (dist / "release" / "CloudRestoreAS.exe").write_bytes(b"parcial" * 1000) + + result = _run_package(tmp_path) + assert result.returncode == 0, result.stderr + + manifest = json.loads((dist / "release" / "release.json").read_text(encoding="utf-8")) + assert [a["platform"] for a in manifest["artifacts"]] == ["linux"] + assert not list((dist / "release").glob("*win*.zip")), "no debió empaquetar Windows" + # La copia rancia se elimina para que no reaparezca en la siguiente corrida. + assert not (dist / "release" / "CloudRestoreAS.exe").exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="usa bash") +def test_package_release_rechaza_binario_truncado(tmp_path: Path): + """Un build a medias debe abortar el empaquetado completo, no publicarse.""" + dist = _stage_package_tree(tmp_path) + (dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024)) + (dist / "CloudRestoreAS.exe").write_bytes(b"x" * 1024) # muy por debajo del piso + + result = _run_package(tmp_path, min_mb="1") + assert result.returncode != 0 + salida = result.stdout + result.stderr + assert "truncado" in salida + assert "abortado" in salida + # Nada debe quedar publicable si alguna plataforma es inválida. + assert not (dist / "release" / "release.json").exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="usa bash") +def test_package_release_rechaza_binario_anterior_al_bump(tmp_path: Path): + """ + Un binario más viejo que app/__init__.py pertenece a otra versión. Empaquetarlo con el + nombre de la versión actual publicaría una mentira que el sha256 no puede delatar. + """ + dist = _stage_package_tree(tmp_path) + (dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024)) + exe = dist / "CloudRestoreAS.exe" + exe.write_bytes(b"x" * (2 * 1024 * 1024)) + os.utime(exe, (0, 0)) # 1970: anterior a cualquier cambio de versión + + result = _run_package(tmp_path) + assert result.returncode != 0 + salida = result.stdout + result.stderr + assert "MÁS VIEJO" in salida + assert not (dist / "release" / "release.json").exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="usa bash") +def test_package_release_rechaza_version_invalida(tmp_path: Path): + """Una versión no comparable debe abortar el empaquetado, no publicarse.""" + for rel in ("packaging/scripts/package-release.sh", "packaging/LEEME.txt"): + dest = tmp_path / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes((ROOT / rel).read_bytes()) + (tmp_path / "app").mkdir() + (tmp_path / "app" / "__init__.py").write_text('__version__ = "1.0.0-rc1"\n', encoding="utf-8") + (tmp_path / "dist").mkdir() + + result = subprocess.run( + ["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "inválida" in (result.stdout + result.stderr) diff --git a/tests/test_retention.py b/tests/test_retention.py new file mode 100644 index 0000000..581500c --- /dev/null +++ b/tests/test_retention.py @@ -0,0 +1,157 @@ +""" +Pruebas de la retención por nodo (Procesados/) y por antigüedad (Fallados/). + +Se mockea el acceso a la BD (JobRepository/EventRepository) y se opera sobre carpetas reales +en tmp_path. Las fechas-carpeta de Procesados/ se derivan con el mismo helper que usa el +limpiador para que la prueba sea independiente de la zona horaria del runner. +""" +from datetime import datetime + +import pytest + +from app.engine.retention import RetentionCleaner + + +class FakeJob: + def __init__(self, job_id, node_name, source_name, finished_at): + self.job_id = job_id + self.node_name = node_name + self.source_name = source_name + self.finished_at = finished_at + + +def _config(tmp_path, *, days=2, failed_days=7, dry_run=False): + return { + "paths": { + "processed_folder": str(tmp_path / "processed"), + "failed_folder": str(tmp_path / "failed"), + }, + "retention": {"days": days, "failed_days": failed_days, "dry_run": dry_run}, + } + + +def _date_folder(base, iso): + """Crea (si falta) la carpeta-fecha local correspondiente a un finished_at ISO UTC.""" + local_date = RetentionCleaner._local_date_from_iso(iso) + folder = base / local_date.isoformat() + folder.mkdir(parents=True, exist_ok=True) + return folder + + +def test_procesados_borra_obsoletos_conserva_reciente_y_nodo_unico(monkeypatch, tmp_path): + processed = tmp_path / "processed" + ref_iso = "2026-07-24T10:00:00" + obsolete_iso = "2026-07-20T10:00:00" + node_b_iso = "2026-07-22T10:00:00" + + # NODO_A: copia obsoleta (a borrar) + copia más reciente (a conservar). + obsolete_file = _date_folder(processed, obsolete_iso) / "NODO_A.zip" + obsolete_file.write_bytes(b"viejo") + recent_file = _date_folder(processed, ref_iso) / "NODO_A.zip" + recent_file.write_bytes(b"nuevo") + # NODO_B: una sola restauración (nunca se toca). + node_b_file = _date_folder(processed, node_b_iso) / "NODO_B.zip" + node_b_file.write_bytes(b"unico") + + obsolete_job = FakeJob("job-a-old", "NODO_A", "NODO_A.zip", obsolete_iso) + + purged: list[str] = [] + monkeypatch.setattr( + "app.engine.retention.JobRepository.get_obsolete_completed_by_node", + lambda days: [obsolete_job], + ) + monkeypatch.setattr( + "app.engine.retention.JobRepository.get_latest_completed_per_node", + lambda: {"NODO_A": ref_iso, "NODO_B": node_b_iso}, + ) + monkeypatch.setattr( + "app.engine.retention.JobRepository.mark_purged", lambda job_id: purged.append(job_id) + ) + monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None) + + result = RetentionCleaner(_config(tmp_path)).run() + + assert not obsolete_file.exists() # obsoleto borrado + assert recent_file.exists() # más reciente intacto + assert node_b_file.exists() # nodo de una sola copia intacto + assert purged == ["job-a-old"] + assert result.deleted_files == 1 + + +def test_procesados_dry_run_no_borra(monkeypatch, tmp_path): + processed = tmp_path / "processed" + obsolete_iso = "2026-07-20T10:00:00" + obsolete_file = _date_folder(processed, obsolete_iso) / "NODO_A.zip" + obsolete_file.write_bytes(b"viejo") + + monkeypatch.setattr( + "app.engine.retention.JobRepository.get_obsolete_completed_by_node", + lambda days: [FakeJob("job-a-old", "NODO_A", "NODO_A.zip", obsolete_iso)], + ) + monkeypatch.setattr( + "app.engine.retention.JobRepository.get_latest_completed_per_node", + lambda: {"NODO_A": "2026-07-24T10:00:00"}, + ) + purged: list[str] = [] + monkeypatch.setattr( + "app.engine.retention.JobRepository.mark_purged", lambda job_id: purged.append(job_id) + ) + monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None) + + result = RetentionCleaner(_config(tmp_path, dry_run=True)).run() + + assert obsolete_file.exists() # dry-run no borra + assert purged == [] # ni marca purgado + assert result.dry_run is True + assert result.deleted_files == 1 # sí lo contabiliza como "se borraría" + + +def test_fallados_borra_por_antiguedad_absoluta(monkeypatch, tmp_path): + failed = tmp_path / "failed" + old_folder = failed / "2026-07-10" # < (hoy - 7) + recent_folder = failed / "2026-07-20" # >= (hoy - 7) + old_folder.mkdir(parents=True) + recent_folder.mkdir(parents=True) + old_file = old_folder / "VIEJO.zip" + old_file.write_bytes(b"x") + recent_file = recent_folder / "RECIENTE.zip" + recent_file.write_bytes(b"y") + + # Sin obsoletos en Procesados; solo probamos Fallados. + monkeypatch.setattr( + "app.engine.retention.JobRepository.get_obsolete_completed_by_node", lambda days: [] + ) + monkeypatch.setattr( + "app.engine.retention.JobRepository.get_latest_completed_per_node", lambda: {} + ) + monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None) + + cleaner = RetentionCleaner( + _config(tmp_path, failed_days=7), clock=lambda: datetime(2026, 7, 24, 12, 0, 0) + ) + cleaner.run() + + assert not old_file.exists() # carpeta-fecha vieja borrada + assert recent_file.exists() # dentro de la ventana, se conserva + + +def test_fallados_ignora_carpetas_no_fecha(monkeypatch, tmp_path): + failed = tmp_path / "failed" + weird = failed / "no-es-fecha" + weird.mkdir(parents=True) + keep = weird / "algo.zip" + keep.write_bytes(b"z") + + monkeypatch.setattr( + "app.engine.retention.JobRepository.get_obsolete_completed_by_node", lambda days: [] + ) + monkeypatch.setattr( + "app.engine.retention.JobRepository.get_latest_completed_per_node", lambda: {} + ) + monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None) + + RetentionCleaner( + _config(tmp_path), clock=lambda: datetime(2026, 7, 24, 12, 0, 0) + ).run() + + assert keep.exists() # nombre que no es fecha: no se toca diff --git a/tests/test_sftp_copy.py b/tests/test_sftp_copy.py index 7037545..5580970 100644 --- a/tests/test_sftp_copy.py +++ b/tests/test_sftp_copy.py @@ -2,6 +2,8 @@ Pruebas de la transferencia SFTP al servidor remoto. Se mockea paramiko para no requerir un servidor SSH real; se valida la conversión de rutas y el flujo de subida. """ +import os + import pytest from app.transfer import sftp_copy @@ -32,12 +34,26 @@ def test_upload_origen_inexistente(tmp_path): sftp_copy.upload_to_remote(str(tmp_path / "noexiste.bak"), CFG) +class FakeStat: + def __init__(self, st_size): + self.st_size = st_size + + class FakeSFTP: def __init__(self, store): self.store = store - def put(self, local, remote): + def put(self, local, remote, confirm=True): self.store["put"] = (local, remote) + self.store["confirm"] = confirm + # Registra el tamaño para que stat() (verificación de subida) lo confirme. + self.store.setdefault("sizes", {})[remote] = os.path.getsize(local) + + def stat(self, remote): + sizes = self.store.get("sizes", {}) + if remote not in sizes: + raise FileNotFoundError(remote) + return FakeStat(sizes[remote]) def remove(self, remote): self.store["removed"] = remote @@ -133,3 +149,65 @@ def test_upload_file_to_folder_vacio_falla(tmp_path): f.write_bytes(b"x") with pytest.raises(SFTPCopyError, match="carpeta remota"): sftp_copy.upload_file_to_folder(str(f), CFG, " ") + + +def test_upload_usa_confirm_false(tmp_path, monkeypatch): + """La subida no debe delegar la verificación al confirm inmediato de paramiko.""" + zf = tmp_path / "backup.zip" + zf.write_bytes(b"zipdata") + store: dict = {} + monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store)) + + sftp_copy.upload_file_to_folder(str(zf), CFG, "D:\\In") + assert store["confirm"] is False + + +def test_verify_remote_size_reintenta_stat_flaky(monkeypatch): + """Un stat transitoriamente fallido se reintenta y NO produce falso fallo.""" + monkeypatch.setattr(sftp_copy, "VERIFY_DELAY_SECONDS", 0) + calls = {"n": 0} + + class Flaky: + def stat(self, remote): + calls["n"] += 1 + if calls["n"] < 2: + raise OSError("stat flaky") + return FakeStat(100) + + sftp_copy._verify_remote_size(Flaky(), "C:/In/x.zip", 100) # no debe lanzar + assert calls["n"] == 2 + + +def test_verify_remote_size_tamano_incorrecto_falla(monkeypatch): + """Si el tamaño remoto nunca coincide, es un fallo genuino de entrega.""" + monkeypatch.setattr(sftp_copy, "VERIFY_DELAY_SECONDS", 0) + + class Wrong: + def stat(self, remote): + return FakeStat(50) + + with pytest.raises(SFTPCopyError, match="verificar"): + sftp_copy._verify_remote_size(Wrong(), "C:/In/x.zip", 100) + + +def test_upload_zip_parts_adjunta_uploaded_en_fallo(tmp_path, monkeypatch): + """Ante un fallo parcial, la excepción lleva las partes ya subidas para limpieza.""" + p1 = tmp_path / "big.zip.001" + p2 = tmp_path / "big.zip.002" + p1.write_bytes(b"a") + p2.write_bytes(b"b") + + calls = {"n": 0} + + def fake_upload(local, cfg, remote_folder): + calls["n"] += 1 + if calls["n"] == 1: + return "D:/In/big.zip.001" + raise SFTPCopyError("boom en la parte 2") + + monkeypatch.setattr(sftp_copy, "upload_file_to_folder", fake_upload) + + with pytest.raises(SFTPCopyError) as exc_info: + sftp_copy.upload_zip_parts([str(p1), str(p2)], CFG, "D:\\In") + + assert getattr(exc_info.value, "uploaded", None) == ["D:/In/big.zip.001"]