feature/generador-instaladores-linux-windows
This commit is contained in:
138
BUILD.md
138
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.
|
Orquestador único. **Se ejecuta desde WSL** (bash). Genera Windows + Linux + los paquetes.
|
||||||
|
|
||||||
```bash
|
```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 --linux-only # solo Linux (Docker)
|
||||||
./build-all.sh --windows-only # solo Windows (PowerShell + build.ps1)
|
./build-all.sh --windows-only # solo Windows (PowerShell + build.ps1)
|
||||||
./build-all.sh --no-package # compila sin generar .tar.gz/.zip
|
./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 --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
|
./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
|
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/`:
|
Genera en `dist/release/`:
|
||||||
|
|
||||||
| Archivo | Contenido |
|
| Archivo | Contenido |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `CloudRestoreAS-linux.tar.gz` | `CloudRestoreAS/` → binario + `install.sh` + `packaging/linux/cloudrestoreas.service` + `LEEME.txt` |
|
| `CloudRestoreAS-<version>-linux-<arch>.tar.gz` | `CloudRestoreAS/` → binario + `install.sh` + `packaging/linux/cloudrestoreas.service` + `LEEME.txt` |
|
||||||
| `CloudRestoreAS-win.zip` | `CloudRestoreAS/` → `CloudRestoreAS.exe` + `install.ps1` + `LEEME.txt` |
|
| `CloudRestoreAS-<version>-win-<arch>.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/<version>/`
|
||||||
|
|
||||||
|
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)
|
### Linux — `install.sh` (no instala nada del sistema)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar xzf CloudRestoreAS-linux.tar.gz && cd CloudRestoreAS
|
tar xzf CloudRestoreAS-<version>-linux-x86_64.tar.gz && cd CloudRestoreAS
|
||||||
|
|
||||||
sudo ./install.sh --service # servicio systemd 24/7 headless (recomendado en servidor)
|
sudo ./install.sh --service # servicio systemd 24/7 headless (recomendado en servidor)
|
||||||
./install.sh --desktop # autostart .desktop (requiere sesión gráfica)
|
./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>` (usuario del servicio).
|
Variables: `PREFIX=/opt/cloudrestoreas` (destino), `SERVICE_USER=<usuario>` (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:
|
Servicio systemd:
|
||||||
```bash
|
```bash
|
||||||
systemctl status cloudrestoreas
|
systemctl status cloudrestoreas
|
||||||
@@ -102,14 +170,45 @@ journalctl -u cloudrestoreas -f
|
|||||||
sudo systemctl restart cloudrestoreas # tras editar config/.env
|
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
|
```powershell
|
||||||
crea `config/` y un icono en la bandeja. Ver [packaging/LEEME.txt](packaging/LEEME.txt).
|
Expand-Archive CloudRestoreAS-<version>-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
|
```bash
|
||||||
# Linux servidor sin pantalla (headless): motor de restauración sin GUI
|
# 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 |
|
| `--start-engine` | Inicia el motor de restauración al arrancar |
|
||||||
| `--headless` | Fuerza modo sin interfaz (Qt `offscreen`), para servidores sin display |
|
| `--headless` | Fuerza modo sin interfaz (Qt `offscreen`), para servidores sin display |
|
||||||
| `--minimized` | Inicia minimizado en la bandeja |
|
| `--minimized` | Inicia minimizado en la bandeja |
|
||||||
|
| `--version` | Imprime `CloudRestoreAS <version> (<platform>/<arch>)` 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) |
|
| `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`.
|
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/`,
|
El binario crea automáticamente: `config/`, `config/.env`, `Entrada/`, `Procesados/`,
|
||||||
`Fallados/`, `Temp/`. Editar `config/.env`:
|
`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:
|
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 |
|
| Ruta | Qué es |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `dist/CloudRestoreAS` | Binario Linux onefile |
|
| `dist/CloudRestoreAS` | Binario Linux onefile |
|
||||||
| `dist/CloudRestoreAS.exe` | Ejecutable Windows onefile |
|
| `dist/CloudRestoreAS.exe` | Ejecutable Windows onefile |
|
||||||
| `dist/release/*.tar.gz` / `*.zip` | Paquetes de despliegue (binario + instalador + docs) |
|
| `dist/release/CloudRestoreAS-<version>-*.{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) |
|
| `packaging/bundled/{linux,windows}/` | Deps embebidas (generadas en build; git-ignored) |
|
||||||
| `venv-linux/`, `venv-windows/` | Entornos virtuales de 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 |
|
| 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 |
|
| `build.sh` / `build.ps1` | Build individual Linux / Windows |
|
||||||
| `packaging/scripts/docker-build-linux.sh` | Build Linux en contenedor controlado |
|
| `packaging/scripts/docker-build-linux.sh` | Build Linux en contenedor controlado |
|
||||||
| `packaging/scripts/download-bundled-deps.sh` / `.ps1` | Descarga+embebe deps |
|
| `packaging/scripts/download-bundled-deps.sh` / `.ps1` | Descarga+embebe deps |
|
||||||
| `packaging/scripts/package-release.sh` | Genera `.tar.gz` / `.zip` |
|
| `packaging/scripts/package-release.sh` | Genera los paquetes versionados + `SHA256SUMS` + `release.json` |
|
||||||
| `install.sh` / `install.ps1` | Instalador Linux / Windows |
|
| `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) |
|
| `packaging/linux/cloudrestoreas.service` | Unit systemd (24/7 headless) |
|
||||||
|
|||||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -1,5 +1,52 @@
|
|||||||
# Changelog
|
# 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-<version>-{linux,win}-<arch>.{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/<version>`) 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
|
## [1.0.0] - 2026-01-25
|
||||||
|
|
||||||
### Lanzamiento Inicial
|
### Lanzamiento Inicial
|
||||||
|
|||||||
@@ -132,15 +132,69 @@ Usado por utilidades legacy; el flujo principal de jobs usa `resolve-route`.
|
|||||||
|
|
||||||
### POST `/api/restore/instance-config`
|
### 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
|
## 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
|
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**).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ CloudRestoreAs/
|
|||||||
├── runner.py # Punto de entrada principal
|
├── runner.py # Punto de entrada principal
|
||||||
├── requirements.txt # Dependencias Python
|
├── 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.ps1 # Script de inicio rápido (PowerShell)
|
||||||
├── start.bat # Script de inicio rápido (Batch)
|
├── start.bat # Script de inicio rápido (Batch)
|
||||||
├── build.ps1 # Script para generar ejecutable con PyInstaller
|
├── build.ps1 # Script para generar ejecutable con PyInstaller
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"}
|
|||||||
|
|
||||||
### 2. Instala y Ejecuta
|
### 2. Instala y Ejecuta
|
||||||
```powershell
|
```powershell
|
||||||
# Ejecuta el script de instalación
|
# Prepara el entorno de desarrollo (Python + venv + requirements)
|
||||||
.\install.ps1
|
.\scripts\dev-setup.ps1
|
||||||
|
|
||||||
# Ejecuta la aplicación
|
# Ejecuta la aplicación
|
||||||
.\venv\Scripts\python.exe runner.py
|
.\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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"""CloudRestoreAS - Aplicación de restauración automática de bases de datos SQL Server."""
|
"""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"
|
__author__ = "Aduanasoft"
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
"""Creación automática de config/ y carpetas de trabajo."""
|
"""Creación automática de config/ y carpetas de trabajo."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .. import __version__
|
||||||
from ..constants import (
|
from ..constants import (
|
||||||
APP_DIR,
|
APP_DIR,
|
||||||
BUNDLE_DIR,
|
BUNDLE_DIR,
|
||||||
BUNDLED_SOURCE_7ZIP,
|
BUNDLED_SOURCE_7ZIP,
|
||||||
BUNDLED_SOURCE_ODBC,
|
BUNDLED_SOURCE_ODBC,
|
||||||
|
BUNDLED_STAMP_PATH,
|
||||||
CONFIG_DIR,
|
CONFIG_DIR,
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
DIR_ENTRADA,
|
DIR_ENTRADA,
|
||||||
@@ -20,19 +24,100 @@ from ..constants import (
|
|||||||
LOGS_DIR,
|
LOGS_DIR,
|
||||||
ODBC_DIR,
|
ODBC_DIR,
|
||||||
SEVEN_ZIP_DIR,
|
SEVEN_ZIP_DIR,
|
||||||
|
VERSION_PATH,
|
||||||
)
|
)
|
||||||
from ..db.database import DatabaseManager
|
from ..db.database import DatabaseManager
|
||||||
from .env_loader import render_env_template
|
from .env_loader import render_env_template
|
||||||
|
|
||||||
|
|
||||||
def _copy_tree_if_missing(src: Path, dest: Path) -> None:
|
def _bundled_resource(rel_path: str) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Recurso empaquetado: primero dentro del onefile (BUNDLE_DIR), luego junto al código
|
||||||
|
en desarrollo. Mismo orden de preferencia que usa _write_env_if_missing.
|
||||||
|
"""
|
||||||
|
for base in (BUNDLE_DIR, APP_DIR):
|
||||||
|
candidate = base / rel_path
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_stamp(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _write_stamp(path: Path, content: str) -> None:
|
||||||
|
"""Escribe un sello solo si cambió, para no tocar disco en cada arranque."""
|
||||||
|
value = content.strip()
|
||||||
|
if _read_stamp(path) == value:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
path.write_text(value, encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
# Los sellos son informativos: si config/ no es escribible, el arranque sigue.
|
||||||
|
# La consecuencia es re-copiar las deps embebidas en el próximo arranque.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _bundled_deps_stamp() -> str:
|
||||||
|
"""
|
||||||
|
Huella de las dependencias embebidas en ESTE build: el sha256 de
|
||||||
|
packaging/bundled-versions.json, que es el archivo que fija las versiones de 7-Zip,
|
||||||
|
del driver ODBC y de unixODBC. Si no viene empaquetado, cae a la versión de la app.
|
||||||
|
"""
|
||||||
|
manifest = _bundled_resource("packaging/bundled-versions.json")
|
||||||
|
if manifest is not None:
|
||||||
|
try:
|
||||||
|
return "sha256:" + hashlib.sha256(manifest.read_bytes()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return f"app:{__version__}"
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_before_refresh(dest: Path) -> None:
|
||||||
|
"""
|
||||||
|
Aparta el contenido actual a `<dest>.bak` antes de re-copiarlo.
|
||||||
|
|
||||||
|
El re-despliegue usa copytree(dirs_exist_ok=True), que SOBRESCRIBE los archivos: si el
|
||||||
|
operador editó a mano algo como odbcinst.ini, se perdería en la primera ejecución tras
|
||||||
|
actualizar y sin aviso. Esa edición no se puede reconstruir, así que se conserva.
|
||||||
|
|
||||||
|
Un solo respaldo rotatorio, sin fecha: estas carpetas viven en servidores que almacenan
|
||||||
|
respaldos de bases y no conviene acumular una copia del driver ODBC por cada actualización.
|
||||||
|
"""
|
||||||
|
backup = dest.with_name(f"{dest.name}.bak")
|
||||||
|
try:
|
||||||
|
if backup.exists():
|
||||||
|
shutil.rmtree(backup, ignore_errors=True)
|
||||||
|
shutil.copytree(dest, backup, dirs_exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
# El respaldo es una red de seguridad, no un requisito: si el disco no da o los
|
||||||
|
# permisos no alcanzan, el refresco debe seguir su curso.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_bundled_tree(src: Path, dest: Path, refresh: bool) -> None:
|
||||||
|
"""
|
||||||
|
Despliega 7-Zip/ODBC del bundle a config/. Copia si el destino está vacío y RE-copia
|
||||||
|
cuando refresh es True, es decir cuando este build trae otras versiones embebidas:
|
||||||
|
una actualización con driver ODBC nuevo debe reemplazar el viejo, no conservarlo.
|
||||||
|
|
||||||
|
Antes de re-copiar sobre contenido existente se aparta una copia a `<dest>.bak`, porque
|
||||||
|
el copytree sobrescribe y las ediciones manuales del operador no son reconstruibles.
|
||||||
|
|
||||||
|
dest puede existir pero VACÍO: ensure_runtime_layout crea ODBC_DIR/SEVEN_ZIP_DIR
|
||||||
|
antes de llamar aquí, así que la condición mira el contenido, no la existencia.
|
||||||
|
"""
|
||||||
if not src.is_dir():
|
if not src.is_dir():
|
||||||
return
|
return
|
||||||
# dest puede existir pero VACÍO: ensure_runtime_layout crea ODBC_DIR/SEVEN_ZIP_DIR
|
has_content = dest.exists() and any(dest.iterdir())
|
||||||
# antes de llamar aquí. Solo saltar si ya tiene contenido (evita re-copiar en cada
|
if has_content and not refresh:
|
||||||
# arranque). Antes se saltaba por dest.exists(), dejando 7-Zip/ODBC sin desplegar.
|
|
||||||
if dest.exists() and any(dest.iterdir()):
|
|
||||||
return
|
return
|
||||||
|
if has_content and refresh:
|
||||||
|
_backup_before_refresh(dest)
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copytree(src, dest, dirs_exist_ok=True)
|
shutil.copytree(src, dest, dirs_exist_ok=True)
|
||||||
|
|
||||||
@@ -92,9 +177,25 @@ def ensure_runtime_layout() -> None:
|
|||||||
|
|
||||||
_write_env_if_missing()
|
_write_env_if_missing()
|
||||||
|
|
||||||
_copy_tree_if_missing(BUNDLED_SOURCE_7ZIP, SEVEN_ZIP_DIR)
|
# Los sellos se ubican bajo el CONFIG_DIR vigente (no la ruta absoluta precalculada)
|
||||||
_copy_tree_if_missing(BUNDLED_SOURCE_ODBC, ODBC_DIR)
|
# 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()
|
_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
|
from ..constants import DB_PATH
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ..constants import (
|
|||||||
ENV_PATH,
|
ENV_PATH,
|
||||||
default_seven_zip_path,
|
default_seven_zip_path,
|
||||||
)
|
)
|
||||||
|
from ..utils.logger import app_logger
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool = False) -> bool:
|
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")
|
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:
|
def load_env_file() -> bool:
|
||||||
"""Carga config/.env si existe."""
|
"""Carga config/.env si existe."""
|
||||||
if ENV_PATH.is_file():
|
if ENV_PATH.is_file():
|
||||||
@@ -101,6 +127,33 @@ def apply_env_overrides(config: dict) -> dict:
|
|||||||
if os.getenv("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH"):
|
if os.getenv("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH"):
|
||||||
sql["use_windows_auth"] = _env_bool("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH", False)
|
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
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -136,4 +189,12 @@ CLOUDRESTORE_EXTRACT_FOLDER={p("Temp")}
|
|||||||
# CLOUDRESTORE_SQL_SERVER=localhost
|
# CLOUDRESTORE_SQL_SERVER=localhost
|
||||||
# CLOUDRESTORE_SQL_USERNAME=
|
# CLOUDRESTORE_SQL_USERNAME=
|
||||||
# CLOUDRESTORE_DATA_SQL_FOLDER=
|
# CLOUDRESTORE_DATA_SQL_FOLDER=
|
||||||
|
|
||||||
|
# Retención (limpieza diaria de respaldos obsoletos para no saturar el disco)
|
||||||
|
# CLOUDRESTORE_RETENTION_ENABLED=true
|
||||||
|
# CLOUDRESTORE_RETENTION_DAYS=2 # Procesados: por nodo, respecto al más reciente
|
||||||
|
# CLOUDRESTORE_RETENTION_FAILED_DAYS=7 # Fallados: por antigüedad absoluta
|
||||||
|
# CLOUDRESTORE_RETENTION_RUN_AT_HOUR=3 # hora local de la corrida diaria (0-23)
|
||||||
|
# CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS=3600
|
||||||
|
# CLOUDRESTORE_RETENTION_DRY_RUN=true # true = solo simula; poner false para borrar
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
"""Constantes globales de la aplicación."""
|
"""Constantes globales de la aplicación."""
|
||||||
|
|
||||||
|
import platform
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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:
|
def _resolve_app_dir() -> Path:
|
||||||
"""Directorio donde vive el ejecutable (persistente)."""
|
"""Directorio donde vive el ejecutable (persistente)."""
|
||||||
@@ -15,19 +43,27 @@ def _resolve_bundle_dir() -> Path:
|
|||||||
"""Recursos embebidos en el binario PyInstaller onefile."""
|
"""Recursos embebidos en el binario PyInstaller onefile."""
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
return Path(sys._MEIPASS)
|
return Path(sys._MEIPASS)
|
||||||
platform = "windows" if sys.platform == "win32" else "linux"
|
return _resolve_app_dir() / "packaging" / "bundled" / APP_PLATFORM
|
||||||
return _resolve_app_dir() / "packaging" / "bundled" / platform
|
|
||||||
|
|
||||||
|
|
||||||
APP_DIR = _resolve_app_dir()
|
APP_DIR = _resolve_app_dir()
|
||||||
CONFIG_DIR = APP_DIR / "config"
|
CONFIG_DIR = APP_DIR / "config"
|
||||||
ENV_PATH = CONFIG_DIR / ".env"
|
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"
|
DATA_DIR = CONFIG_DIR / "data"
|
||||||
LOGS_DIR = CONFIG_DIR / "logs"
|
LOGS_DIR = CONFIG_DIR / "logs"
|
||||||
ODBC_DIR = CONFIG_DIR / "odbc"
|
ODBC_DIR = CONFIG_DIR / "odbc"
|
||||||
SEVEN_ZIP_DIR = CONFIG_DIR / "7zip"
|
SEVEN_ZIP_DIR = CONFIG_DIR / "7zip"
|
||||||
|
|
||||||
IS_WINDOWS = sys.platform == "win32"
|
|
||||||
BUNDLE_DIR = _resolve_bundle_dir()
|
BUNDLE_DIR = _resolve_bundle_dir()
|
||||||
BUNDLED_SOURCE_7ZIP = BUNDLE_DIR / "bundled" / "7zip"
|
BUNDLED_SOURCE_7ZIP = BUNDLE_DIR / "bundled" / "7zip"
|
||||||
BUNDLED_SOURCE_ODBC = BUNDLE_DIR / "bundled" / "odbc"
|
BUNDLED_SOURCE_ODBC = BUNDLE_DIR / "bundled" / "odbc"
|
||||||
@@ -136,4 +172,18 @@ DEFAULT_CONFIG = {
|
|||||||
"instance_key": "",
|
"instance_key": "",
|
||||||
"verify_ssl": False,
|
"verify_ssl": False,
|
||||||
},
|
},
|
||||||
|
"retention": {
|
||||||
|
# Limpieza diaria de respaldos obsoletos para no saturar el disco del servidor.
|
||||||
|
"enabled": True,
|
||||||
|
# Procesados/: por nodo, borra los aplicados con finished_at < (ref_del_nodo - days).
|
||||||
|
"days": 2,
|
||||||
|
# Fallados/: por antigüedad absoluta, borra los más viejos que (hoy - failed_days).
|
||||||
|
"failed_days": 7,
|
||||||
|
# Hora local (0-23) a la que corre la limpieza diaria.
|
||||||
|
"run_at_hour": 3,
|
||||||
|
# Cada cuánto despierta el hilo para evaluar si toca correr.
|
||||||
|
"check_interval_seconds": 3600,
|
||||||
|
# Arranca en SECO: solo reporta qué borraría. El operador lo desactiva tras validar.
|
||||||
|
"dry_run": True,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,54 @@
|
|||||||
"""Esquema y gestión de la base de datos SQLite."""
|
"""Esquema y gestión de la base de datos SQLite."""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from ..constants import DB_PATH
|
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:
|
class DatabaseManager:
|
||||||
"""Gestor de la base de datos SQLite."""
|
"""Gestor de la base de datos SQLite."""
|
||||||
|
|
||||||
def __init__(self, db_path: Optional[Path] = None):
|
def __init__(self, db_path: Optional[Path] = None):
|
||||||
"""
|
"""
|
||||||
Inicializa el gestor de base de datos.
|
Inicializa el gestor de base de datos.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db_path: Ruta a la base de datos (usa DB_PATH por defecto)
|
db_path: Ruta a la base de datos (usa DB_PATH por defecto)
|
||||||
"""
|
"""
|
||||||
self.db_path = db_path or DB_PATH
|
self.db_path = db_path or DB_PATH
|
||||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
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()
|
self._initialize_schema()
|
||||||
|
|
||||||
def get_connection(self) -> sqlite3.Connection:
|
def get_connection(self) -> sqlite3.Connection:
|
||||||
"""Obtiene una conexión a la base de datos."""
|
"""Obtiene una conexión a SQLite con protecciones de concurrencia (WAL + timeouts)."""
|
||||||
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
conn = sqlite3.connect(
|
||||||
|
str(self.db_path), check_same_thread=False, timeout=_SQLITE_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
conn.row_factory = sqlite3.Row
|
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
|
return conn
|
||||||
|
|
||||||
def _initialize_schema(self):
|
def _initialize_schema(self):
|
||||||
"""Crea las tablas si no existen."""
|
"""Crea las tablas si no existen."""
|
||||||
with self.get_connection() as conn:
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Tabla de jobs
|
# Tabla de jobs
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
@@ -51,7 +68,8 @@ class DatabaseManager:
|
|||||||
total_ms INTEGER,
|
total_ms INTEGER,
|
||||||
extract_ms INTEGER,
|
extract_ms INTEGER,
|
||||||
restore_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
|
# Índices
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)")
|
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)")
|
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_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_events_job ON events(job_id)")
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_job_steps_job ON job_steps(job_id)")
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_job_steps_job ON job_steps(job_id)")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def execute(self, query: str, params: tuple = ()) -> sqlite3.Cursor:
|
def execute(self, query: str, params: tuple = ()) -> sqlite3.Cursor:
|
||||||
"""Ejecuta una consulta SQL."""
|
"""Ejecuta una escritura, serializada por lock de proceso y con conexión cerrada."""
|
||||||
with self.get_connection() as conn:
|
with self._write_lock:
|
||||||
cursor = conn.cursor()
|
conn = self.get_connection()
|
||||||
cursor.execute(query, params)
|
try:
|
||||||
conn.commit()
|
cursor = conn.cursor()
|
||||||
return cursor
|
cursor.execute(query, params)
|
||||||
|
conn.commit()
|
||||||
|
return cursor
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def fetchone(self, query: str, params: tuple = ()) -> Optional[sqlite3.Row]:
|
def fetchone(self, query: str, params: tuple = ()) -> Optional[sqlite3.Row]:
|
||||||
"""Ejecuta una consulta y devuelve una fila."""
|
"""Ejecuta una consulta y devuelve una fila."""
|
||||||
with self.get_connection() as conn:
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
return cursor.fetchone()
|
return cursor.fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def fetchall(self, query: str, params: tuple = ()) -> list[sqlite3.Row]:
|
def fetchall(self, query: str, params: tuple = ()) -> list[sqlite3.Row]:
|
||||||
"""Ejecuta una consulta y devuelve todas las filas."""
|
"""Ejecuta una consulta y devuelve todas las filas."""
|
||||||
with self.get_connection() as conn:
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
return cursor.fetchall()
|
return cursor.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# Instancia global
|
# Instancia global
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class Job:
|
|||||||
extract_ms: Optional[int]
|
extract_ms: Optional[int]
|
||||||
restore_ms: Optional[int]
|
restore_ms: Optional[int]
|
||||||
filelist_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:
|
class JobRepository:
|
||||||
@@ -185,14 +187,109 @@ class JobRepository:
|
|||||||
"""
|
"""
|
||||||
db.execute("DELETE FROM jobs WHERE job_id = ?", (job_id,))
|
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
|
@staticmethod
|
||||||
def exists_by_hash(source_hash: str) -> bool:
|
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(
|
row = db.fetchone(
|
||||||
"SELECT COUNT(*) as count FROM jobs WHERE source_hash = ?",
|
"SELECT COUNT(*) as count FROM jobs WHERE source_hash = ?",
|
||||||
(source_hash,)
|
(source_hash,)
|
||||||
)
|
)
|
||||||
return row["count"] > 0
|
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
|
@staticmethod
|
||||||
def get_stats() -> dict:
|
def get_stats() -> dict:
|
||||||
|
|||||||
@@ -2,18 +2,21 @@
|
|||||||
|
|
||||||
import platform
|
import platform
|
||||||
import socket
|
import socket
|
||||||
|
from threading import Thread
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PySide6.QtCore import QObject, Signal, QThreadPool
|
from PySide6.QtCore import QObject, Signal, QThreadPool
|
||||||
|
|
||||||
from .file_watcher import FileWatcher, FileStabilityChecker, calculate_file_hash
|
from .file_watcher import FileWatcher, FileStabilityChecker, calculate_file_hash
|
||||||
|
from .maintenance_scheduler import DailyMaintenanceScheduler
|
||||||
from .restore_worker import RestoreWorker
|
from .restore_worker import RestoreWorker
|
||||||
|
from .retention import RetentionCleaner
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
from ..db.job_repository import JobRepository
|
from ..db.job_repository import JobRepository
|
||||||
from ..db.event_repository import EventRepository
|
from ..db.event_repository import EventRepository
|
||||||
from ..db.config_repository import ConfigRepository
|
from ..db.config_repository import ConfigRepository
|
||||||
from ..config.env_loader import apply_env_overrides, load_env_file
|
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 ..panel import panel_client
|
||||||
from ..utils.logger import app_logger
|
from ..utils.logger import app_logger
|
||||||
|
|
||||||
@@ -39,7 +42,10 @@ class RestoreEngine(QObject):
|
|||||||
|
|
||||||
# File watcher
|
# File watcher
|
||||||
self._file_watcher: Optional[FileWatcher] = None
|
self._file_watcher: Optional[FileWatcher] = None
|
||||||
|
|
||||||
|
# Mantenimiento diario (retención de respaldos obsoletos)
|
||||||
|
self._maintenance: Optional[DailyMaintenanceScheduler] = None
|
||||||
|
|
||||||
# Estado
|
# Estado
|
||||||
self._running = False
|
self._running = False
|
||||||
self._paused = False
|
self._paused = False
|
||||||
@@ -75,9 +81,10 @@ class RestoreEngine(QObject):
|
|||||||
app_logger.info("Configuración guardada")
|
app_logger.info("Configuración guardada")
|
||||||
self._report_instance_config_to_panel()
|
self._report_instance_config_to_panel()
|
||||||
|
|
||||||
# Reconfigurar file watcher si está corriendo
|
# Reconfigurar file watcher y mantenimiento si está corriendo
|
||||||
if self._running:
|
if self._running:
|
||||||
self._restart_file_watcher()
|
self._restart_file_watcher()
|
||||||
|
self._restart_maintenance()
|
||||||
|
|
||||||
def get_config(self) -> dict:
|
def get_config(self) -> dict:
|
||||||
"""Obtiene la configuración actual."""
|
"""Obtiene la configuración actual."""
|
||||||
@@ -93,7 +100,12 @@ class RestoreEngine(QObject):
|
|||||||
if not self._validate_config():
|
if not self._validate_config():
|
||||||
app_logger.error("Configuración inválida, no se puede iniciar el motor")
|
app_logger.error("Configuración inválida, no se puede iniciar el motor")
|
||||||
return
|
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
|
# Configurar thread pool
|
||||||
extract_workers = self._config["concurrency"]["extract_workers"]
|
extract_workers = self._config["concurrency"]["extract_workers"]
|
||||||
restore_workers = self._config["concurrency"]["restore_workers"]
|
restore_workers = self._config["concurrency"]["restore_workers"]
|
||||||
@@ -108,7 +120,10 @@ class RestoreEngine(QObject):
|
|||||||
# Iniciar file watcher
|
# Iniciar file watcher
|
||||||
if self._config["features"]["auto_scan_enabled"]:
|
if self._config["features"]["auto_scan_enabled"]:
|
||||||
self._start_file_watcher()
|
self._start_file_watcher()
|
||||||
|
|
||||||
|
# Iniciar el mantenimiento diario (limpieza de respaldos obsoletos)
|
||||||
|
self._start_maintenance()
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._paused = False
|
self._paused = False
|
||||||
self._report_instance_config_to_panel()
|
self._report_instance_config_to_panel()
|
||||||
@@ -116,6 +131,28 @@ class RestoreEngine(QObject):
|
|||||||
EventRepository.create("INFO", "Motor iniciado")
|
EventRepository.create("INFO", "Motor iniciado")
|
||||||
app_logger.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):
|
def stop(self):
|
||||||
"""Detiene el motor."""
|
"""Detiene el motor."""
|
||||||
if not self._running:
|
if not self._running:
|
||||||
@@ -125,7 +162,10 @@ class RestoreEngine(QObject):
|
|||||||
if self._file_watcher:
|
if self._file_watcher:
|
||||||
self._file_watcher.stop()
|
self._file_watcher.stop()
|
||||||
self._file_watcher = None
|
self._file_watcher = None
|
||||||
|
|
||||||
|
# Detener el mantenimiento diario
|
||||||
|
self._stop_maintenance()
|
||||||
|
|
||||||
# Esperar a que terminen los workers
|
# Esperar a que terminen los workers
|
||||||
self._thread_pool.waitForDone(msecs=30000) # 30s timeout
|
self._thread_pool.waitForDone(msecs=30000) # 30s timeout
|
||||||
|
|
||||||
@@ -207,28 +247,45 @@ class RestoreEngine(QObject):
|
|||||||
if not api_url or not api_token:
|
if not api_url or not api_token:
|
||||||
return
|
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:
|
try:
|
||||||
host_name = socket.gethostname() or platform.node()
|
host_name = socket.gethostname() or platform.node()
|
||||||
except Exception:
|
except Exception:
|
||||||
host_name = platform.node()
|
host_name = platform.node()
|
||||||
|
|
||||||
instance_key = (panel_cfg.get("instance_key") or "").strip() or None
|
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(
|
panel_client.report_instance_config(
|
||||||
api_url=api_url,
|
api_url=api_url,
|
||||||
api_token=api_token,
|
api_token=api_token,
|
||||||
input_folder=input_folder,
|
input_folder=input_folder,
|
||||||
|
processed_folder=processed_folder,
|
||||||
host_name=host_name,
|
host_name=host_name,
|
||||||
app_version=__version__,
|
app_version=__version__,
|
||||||
instance_key=instance_key,
|
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:
|
def _validate_config(self) -> bool:
|
||||||
"""Valida que la configuración sea correcta."""
|
"""Valida que la configuración sea correcta."""
|
||||||
paths = self._config["paths"]
|
paths = self._config["paths"]
|
||||||
|
|
||||||
# Validar carpetas requeridas
|
# Validar carpetas requeridas. processed_folder/failed_folder se incluyen para que la
|
||||||
required_paths = ["input_folder", "extract_folder", "data_sql_folder"]
|
# 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:
|
for key in required_paths:
|
||||||
if not paths.get(key):
|
if not paths.get(key):
|
||||||
app_logger.error(f"Falta configurar: {key}")
|
app_logger.error(f"Falta configurar: {key}")
|
||||||
@@ -280,6 +337,52 @@ class RestoreEngine(QObject):
|
|||||||
"""Reinicia el file watcher con nueva configuración."""
|
"""Reinicia el file watcher con nueva configuración."""
|
||||||
if self._running and not self._paused:
|
if self._running and not self._paused:
|
||||||
self._start_file_watcher()
|
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):
|
def _on_file_ready(self, file_path: str):
|
||||||
"""
|
"""
|
||||||
@@ -296,10 +399,18 @@ class RestoreEngine(QObject):
|
|||||||
# Calcular hash para evitar duplicados
|
# Calcular hash para evitar duplicados
|
||||||
file_hash = calculate_file_hash(file_path)
|
file_hash = calculate_file_hash(file_path)
|
||||||
|
|
||||||
if JobRepository.exists_by_hash(file_hash):
|
if JobRepository.has_blocking_job_by_hash(file_hash):
|
||||||
app_logger.warning(f"Archivo ya procesado (hash duplicado): {file_path}")
|
app_logger.warning(f"Archivo ya procesado o en curso (hash duplicado): {file_path}")
|
||||||
return
|
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
|
# Crear job
|
||||||
file_name = Path(file_path).name
|
file_name = Path(file_path).name
|
||||||
job_id = JobRepository.create(file_path, file_name, file_hash)
|
job_id = JobRepository.create(file_path, file_name, file_hash)
|
||||||
|
|||||||
125
app/engine/maintenance_scheduler.py
Normal file
125
app/engine/maintenance_scheduler.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Programador de mantenimiento diario in-process.
|
||||||
|
|
||||||
|
Corre una tarea (p.ej. la retención) UNA VEZ por día calendario, dentro del propio proceso de
|
||||||
|
CloudRestoreAS. No depende de cron/systemd externos (el despliegue es embedded-only) ni del
|
||||||
|
event loop de Qt: usa un hilo daemon con el mismo patrón que ``FileWatcher``.
|
||||||
|
|
||||||
|
La marca de la última corrida se persiste en la tabla ``config`` (vía ``ConfigRepository``), de
|
||||||
|
modo que:
|
||||||
|
- corre a lo más una vez por día calendario ("claim" de la fecha ANTES de ejecutar), y
|
||||||
|
- si el servicio estuvo caído se "pone al día" en el primer arranque de un día nuevo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from threading import Event, Thread
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from ..db.config_repository import ConfigRepository
|
||||||
|
from ..db.event_repository import EventRepository
|
||||||
|
from ..utils.logger import app_logger
|
||||||
|
|
||||||
|
|
||||||
|
class DailyMaintenanceScheduler:
|
||||||
|
"""Ejecuta ``task`` una vez al día en un hilo daemon."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
task: Callable[[], None],
|
||||||
|
*,
|
||||||
|
check_interval_seconds: int = 3600,
|
||||||
|
run_at_hour: Optional[int] = 3,
|
||||||
|
state_key: str = "retention_last_run",
|
||||||
|
clock: Callable[[], datetime] = datetime.now,
|
||||||
|
) -> None:
|
||||||
|
self._task = task
|
||||||
|
self._check_interval = max(60, int(check_interval_seconds))
|
||||||
|
self._run_at_hour = run_at_hour
|
||||||
|
self._state_key = state_key
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
self._stop_event = Event()
|
||||||
|
self._thread: Optional[Thread] = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Inicia el hilo de mantenimiento (hace un chequeo inmediato de 'catch-up')."""
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
app_logger.warning("DailyMaintenanceScheduler ya está corriendo")
|
||||||
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._thread = Thread(target=self._run, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
app_logger.info(
|
||||||
|
f"Mantenimiento diario iniciado (hora={self._run_at_hour}, "
|
||||||
|
f"cada {self._check_interval}s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 30.0) -> None:
|
||||||
|
"""Detiene el hilo (timeout amplio: la limpieza puede tardar)."""
|
||||||
|
if self._thread:
|
||||||
|
self._stop_event.set()
|
||||||
|
self._thread.join(timeout=timeout)
|
||||||
|
app_logger.info("Mantenimiento diario detenido")
|
||||||
|
|
||||||
|
def trigger_now(self) -> None:
|
||||||
|
"""Corre la tarea de inmediato sin importar la fecha (uso manual/validación)."""
|
||||||
|
self._execute(force=True)
|
||||||
|
|
||||||
|
# -- Interno ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
self._run_if_due()
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(
|
||||||
|
f"Error en DailyMaintenanceScheduler: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
self._stop_event.wait(self._check_interval)
|
||||||
|
|
||||||
|
def _run_if_due(self) -> None:
|
||||||
|
now = self._clock()
|
||||||
|
if self._is_due(now, self._load_state()):
|
||||||
|
self._execute(now=now)
|
||||||
|
|
||||||
|
def _is_due(self, now: datetime, state: dict) -> bool:
|
||||||
|
if state.get("last_run_date") == now.date().isoformat():
|
||||||
|
return False # ya corrió hoy
|
||||||
|
if self._run_at_hour is None:
|
||||||
|
return True # primera oportunidad de un día nuevo
|
||||||
|
return now.hour >= self._run_at_hour
|
||||||
|
|
||||||
|
def _execute(self, now: Optional[datetime] = None, force: bool = False) -> None:
|
||||||
|
now = now or self._clock()
|
||||||
|
today = now.date().isoformat()
|
||||||
|
|
||||||
|
# Claim al inicio: marca la fecha ANTES de correr para garantizar "máximo 1/día"
|
||||||
|
# aunque la corrida falle o el proceso muera a mitad (la tarea es idempotente).
|
||||||
|
if not force:
|
||||||
|
self._save_state(today, now, "running")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._task()
|
||||||
|
status = "ok"
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(
|
||||||
|
f"Fallo en la tarea de mantenimiento diaria: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
EventRepository.create("ERROR", f"Fallo en la limpieza diaria: {e}")
|
||||||
|
status = f"error: {e}"
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
self._save_state(today, now, status)
|
||||||
|
|
||||||
|
def _load_state(self) -> dict:
|
||||||
|
state = ConfigRepository.get(self._state_key, {})
|
||||||
|
return state if isinstance(state, dict) else {}
|
||||||
|
|
||||||
|
def _save_state(self, run_date: str, now: datetime, status: str) -> None:
|
||||||
|
ConfigRepository.set(
|
||||||
|
self._state_key,
|
||||||
|
{
|
||||||
|
"last_run_date": run_date,
|
||||||
|
"last_run_at": now.isoformat(),
|
||||||
|
"last_status": status,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -146,11 +146,23 @@ class RestoreWorker(QRunnable):
|
|||||||
EventRepository.create("ERROR", f"Job falló: {error_msg}", self.job_id)
|
EventRepository.create("ERROR", f"Job falló: {error_msg}", self.job_id)
|
||||||
|
|
||||||
job = JobRepository.get(self.job_id)
|
job = JobRepository.get(self.job_id)
|
||||||
|
# Mover el ZIP fallido a Fallados/<fecha>/ para diagnóstico y descarga desde el panel.
|
||||||
|
try:
|
||||||
|
if job:
|
||||||
|
self._move_zip_to_failed(job)
|
||||||
|
except Exception as move_err:
|
||||||
|
app_logger.warning(f"No se pudo mover ZIP a Fallados: {move_err}")
|
||||||
|
|
||||||
self._report_to_panel(job, "failed", error_message=error_msg)
|
self._report_to_panel(job, "failed", error_message=error_msg)
|
||||||
|
|
||||||
self.signals.error_occurred.emit(self.job_id, error_msg)
|
self.signals.error_occurred.emit(self.job_id, error_msg)
|
||||||
self.signals.job_completed.emit(self.job_id, False)
|
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:
|
def _resolve_route(self, job) -> dict:
|
||||||
"""
|
"""
|
||||||
Enrutamiento automático vía panel: restore_local o forward según nodo/asignación.
|
Enrutamiento automático vía panel: restore_local o forward según nodo/asignación.
|
||||||
@@ -185,12 +197,41 @@ class RestoreWorker(QRunnable):
|
|||||||
return route
|
return route
|
||||||
|
|
||||||
def _collect_zip_paths(self, source_path: str) -> list[str]:
|
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)
|
path = Path(source_path)
|
||||||
if SevenZipExtractor.is_multipart(str(path)):
|
if not SevenZipExtractor.is_multipart(str(path)):
|
||||||
base_name = path.stem.split(".zip")[0]
|
return [str(path)]
|
||||||
parts = sorted(path.parent.glob(f"{base_name}.zip.*"))
|
|
||||||
return [str(p) for p in parts]
|
# "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)]
|
return [str(path)]
|
||||||
|
|
||||||
def _move_zip_to_processed(self, job):
|
def _move_zip_to_processed(self, job):
|
||||||
@@ -205,15 +246,53 @@ class RestoreWorker(QRunnable):
|
|||||||
shutil.move(str(src), str(dest))
|
shutil.move(str(src), str(dest))
|
||||||
app_logger.info(f"Movido: {src.name} -> {dest}")
|
app_logger.info(f"Movido: {src.name} -> {dest}")
|
||||||
|
|
||||||
|
def _move_zip_to_failed(self, job):
|
||||||
|
"""Mueve el ZIP (y partes multipart) a Fallados/<fecha>/ para diagnóstico y descarga.
|
||||||
|
|
||||||
|
Antes los fallidos quedaban en Entrada (mezclados con pendientes y bloqueando el
|
||||||
|
pickup por dedup). Al moverlos a Fallados, el panel puede listarlos/descargarlos por
|
||||||
|
restaurador con la misma lógica relativa que Procesados.
|
||||||
|
"""
|
||||||
|
failed_folder = Path(self.config["paths"]["failed_folder"])
|
||||||
|
date_folder = failed_folder / datetime.now().strftime("%Y-%m-%d")
|
||||||
|
date_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for zip_path in self._collect_zip_paths(job.source_path):
|
||||||
|
src = Path(zip_path)
|
||||||
|
if not src.exists():
|
||||||
|
continue
|
||||||
|
dest = date_folder / src.name
|
||||||
|
shutil.move(str(src), str(dest))
|
||||||
|
app_logger.info(f"Movido a Fallados: {src.name} -> {dest}")
|
||||||
|
|
||||||
|
def _purge_extract_dir(self):
|
||||||
|
"""Elimina la carpeta temporal de extracción si quedó (best-effort, cualquier salida).
|
||||||
|
|
||||||
|
_cleanup() solo corre en éxito; sin esto, los caminos de fallo/diferido dejan
|
||||||
|
`Temp/<job_id>/` huérfano acumulándose. Se invoca en el `finally` de run().
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._extract_dir and Path(self._extract_dir).exists():
|
||||||
|
shutil.rmtree(self._extract_dir, ignore_errors=True)
|
||||||
|
app_logger.info(f"Temp de extracción purgado: {self._extract_dir}")
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.warning(f"No se pudo purgar Temp {self._extract_dir}: {e}")
|
||||||
|
|
||||||
def _forward_zip(self, job, route: dict, start_time: float):
|
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"]
|
target = route["target"]
|
||||||
self._target = 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)
|
step_id = JobStepRepository.create(self.job_id, StepType.FORWARD_ZIP)
|
||||||
try:
|
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)
|
uploaded = sftp_copy.upload_zip_parts(zip_paths, target, remote_folder)
|
||||||
JobStepRepository.complete(
|
JobStepRepository.complete(
|
||||||
step_id,
|
step_id,
|
||||||
@@ -221,15 +300,18 @@ class RestoreWorker(QRunnable):
|
|||||||
stdout=f"Destino: {target.get('name')} ({len(uploaded)} archivo(s))",
|
stdout=f"Destino: {target.get('name')} ({len(uploaded)} archivo(s))",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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))
|
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
|
||||||
raise
|
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)
|
total_ms = int((time.time() - start_time) * 1000)
|
||||||
JobRepository.update_timing(self.job_id, total_ms=total_ms)
|
JobRepository.update_timing(self.job_id, total_ms=total_ms)
|
||||||
JobRepository.update_status(self.job_id, JobStatus.COMPLETED)
|
JobRepository.update_status(self.job_id, JobStatus.COMPLETED)
|
||||||
|
|
||||||
app_logger.info(
|
app_logger.info(
|
||||||
f"Job {self.job_id} reenviado a '{target.get('name')}' en {total_ms}ms"
|
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.job_id,
|
||||||
)
|
)
|
||||||
self._report_to_panel(job, "forwarded", duration_ms=total_ms)
|
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)
|
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(
|
def _report_to_panel(
|
||||||
self,
|
self,
|
||||||
job,
|
job,
|
||||||
@@ -479,28 +586,9 @@ class RestoreWorker(QRunnable):
|
|||||||
shutil.rmtree(self._extract_dir)
|
shutil.rmtree(self._extract_dir)
|
||||||
app_logger.info(f"Carpeta de extracción eliminada: {self._extract_dir}")
|
app_logger.info(f"Carpeta de extracción eliminada: {self._extract_dir}")
|
||||||
|
|
||||||
# Mover ZIP a Processed
|
# Mover ZIP (y partes multipart) a Processed y registrar rel_path/tamaño.
|
||||||
processed_folder = Path(self.config["paths"]["processed_folder"])
|
self._move_zip_to_processed(job)
|
||||||
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}")
|
|
||||||
|
|
||||||
JobStepRepository.complete(step_id, exit_code=0)
|
JobStepRepository.complete(step_id, exit_code=0)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
253
app/engine/retention.py
Normal file
253
app/engine/retention.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""Retención diaria de respaldos aplicados para no saturar el disco del servidor.
|
||||||
|
|
||||||
|
Dos limpiezas independientes sobre las carpetas locales de CloudRestoreAS:
|
||||||
|
|
||||||
|
- ``Procesados/``: por NODO. Para cada nodo se toma su restauración más reciente como
|
||||||
|
referencia y se conservan las de los últimos ``days``; se borran las anteriores (respaldos
|
||||||
|
ya aplicados y obsoletos). Nunca se borra la más reciente ni nodos con una sola restauración.
|
||||||
|
- ``Fallados/``: por ANTIGÜEDAD absoluta. Se borran los ZIP cuya carpeta-fecha sea anterior a
|
||||||
|
``hoy - failed_days`` (los fallos no tienen semántica de "última restauración exitosa por nodo").
|
||||||
|
|
||||||
|
Salvaguardas: solo ``unlink`` de archivos dentro de la carpeta configurada; nunca ``rmtree``;
|
||||||
|
no sigue symlinks; y jamás toca la carpeta-fecha de la restauración más reciente de un nodo.
|
||||||
|
El borrado físico se correlaciona con la tabla ``jobs`` (la BD no guarda la ruta destino), por
|
||||||
|
lo que la carpeta-fecha se deriva de ``finished_at`` con tolerancia de ±1 día por el desfase
|
||||||
|
UTC/local del momento del movimiento.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from ..db.event_repository import EventRepository
|
||||||
|
from ..db.job_repository import Job, JobRepository
|
||||||
|
from ..extract.seven_zip import SevenZipExtractor
|
||||||
|
from ..utils.logger import app_logger
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RetentionResult:
|
||||||
|
"""Resumen de una corrida de retención."""
|
||||||
|
|
||||||
|
dry_run: bool
|
||||||
|
deleted_files: int = 0
|
||||||
|
freed_bytes: int = 0
|
||||||
|
purged_jobs: int = 0
|
||||||
|
missing_files: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class RetentionCleaner:
|
||||||
|
"""Aplica la política de retención sobre las carpetas Procesados/ y Fallados/."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: dict,
|
||||||
|
*,
|
||||||
|
dry_run: Optional[bool] = None,
|
||||||
|
clock: Callable[[], datetime] = datetime.now,
|
||||||
|
) -> None:
|
||||||
|
retention = config.get("retention", {})
|
||||||
|
paths = config.get("paths", {})
|
||||||
|
|
||||||
|
processed_raw = (paths.get("processed_folder") or "").strip()
|
||||||
|
failed_raw = (paths.get("failed_folder") or "").strip()
|
||||||
|
self._processed_folder = Path(processed_raw) if processed_raw else None
|
||||||
|
self._failed_folder = Path(failed_raw) if failed_raw else None
|
||||||
|
|
||||||
|
self._days = int(retention.get("days", 2))
|
||||||
|
self._failed_days = int(retention.get("failed_days", 7))
|
||||||
|
# El dry_run explícito (p.ej. botón manual) gana sobre la config.
|
||||||
|
self._dry_run = bool(retention.get("dry_run", True)) if dry_run is None else dry_run
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
# -- Orquestación ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def run(self) -> RetentionResult:
|
||||||
|
"""Ejecuta ambas limpiezas y devuelve el resumen."""
|
||||||
|
result = RetentionResult(dry_run=self._dry_run)
|
||||||
|
self._clean_processed(result)
|
||||||
|
self._clean_failed(result)
|
||||||
|
|
||||||
|
mode = "SIMULACRO" if self._dry_run else "real"
|
||||||
|
freed_mb = result.freed_bytes / (1024 * 1024)
|
||||||
|
verb = "se borrarían" if self._dry_run else "borrados"
|
||||||
|
message = (
|
||||||
|
f"Retención ({mode}): {result.deleted_files} archivo(s) {verb} "
|
||||||
|
f"({freed_mb:.1f} MB), {result.purged_jobs} job(s) marcados, "
|
||||||
|
f"{result.missing_files} no hallado(s), {result.errors} error(es)"
|
||||||
|
)
|
||||||
|
app_logger.info(message)
|
||||||
|
EventRepository.create("INFO", message)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -- Procesados (por nodo) -------------------------------------------------------
|
||||||
|
|
||||||
|
def _clean_processed(self, result: RetentionResult) -> None:
|
||||||
|
if not self._processed_folder or not self._processed_folder.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
obsolete = JobRepository.get_obsolete_completed_by_node(self._days)
|
||||||
|
if not obsolete:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fecha-carpeta local de la restauración más reciente de cada nodo: intocable.
|
||||||
|
ref_dates = {
|
||||||
|
node: self._local_date_from_iso(finished_at)
|
||||||
|
for node, finished_at in JobRepository.get_latest_completed_per_node().items()
|
||||||
|
}
|
||||||
|
|
||||||
|
for job in obsolete:
|
||||||
|
local_date = self._local_date_from_iso(job.finished_at)
|
||||||
|
if local_date is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ref_date = ref_dates.get(job.node_name)
|
||||||
|
parts, date_folder = self._resolve_processed_paths(job, local_date, ref_date)
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
# El archivo ya no está en disco (o el nombre no coincide): lo damos por
|
||||||
|
# purgado para no re-escanearlo indefinidamente.
|
||||||
|
result.missing_files += 1
|
||||||
|
self._mark_purged(job, result)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._delete_paths(parts, self._processed_folder, result)
|
||||||
|
self._mark_purged(job, result)
|
||||||
|
if not self._dry_run and date_folder is not None:
|
||||||
|
self._cleanup_empty_dir(date_folder)
|
||||||
|
|
||||||
|
def _resolve_processed_paths(
|
||||||
|
self, job: Job, local_date: date, ref_date: Optional[date]
|
||||||
|
) -> tuple[list[Path], Optional[Path]]:
|
||||||
|
"""Localiza el/los archivo(s) del job en Procesados/ derivando la carpeta-fecha.
|
||||||
|
|
||||||
|
Devuelve (partes_existentes, carpeta_fecha) o ([], None) si no se localizó. Nunca
|
||||||
|
considera la carpeta-fecha de la restauración más reciente del nodo (``ref_date``).
|
||||||
|
"""
|
||||||
|
for candidate in self._candidate_dates(local_date):
|
||||||
|
if ref_date is not None and candidate == ref_date:
|
||||||
|
continue
|
||||||
|
date_folder = self._processed_folder / candidate.isoformat()
|
||||||
|
if not date_folder.is_dir():
|
||||||
|
continue
|
||||||
|
parts = self._collect_parts_in_folder(date_folder, job.source_name)
|
||||||
|
if parts:
|
||||||
|
return parts, date_folder
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
# -- Fallados (por antigüedad absoluta) ------------------------------------------
|
||||||
|
|
||||||
|
def _clean_failed(self, result: RetentionResult) -> None:
|
||||||
|
if not self._failed_folder or not self._failed_folder.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
cutoff = self._clock().date() - timedelta(days=self._failed_days)
|
||||||
|
for date_folder in sorted(self._failed_folder.iterdir()):
|
||||||
|
if not date_folder.is_dir():
|
||||||
|
continue
|
||||||
|
folder_date = self._parse_date_folder(date_folder.name)
|
||||||
|
if folder_date is None:
|
||||||
|
# Carpeta con nombre que no es una fecha: no la tocamos.
|
||||||
|
continue
|
||||||
|
if folder_date >= cutoff:
|
||||||
|
continue
|
||||||
|
|
||||||
|
files = [p for p in date_folder.iterdir() if p.is_file()]
|
||||||
|
self._delete_paths(files, self._failed_folder, result)
|
||||||
|
if not self._dry_run:
|
||||||
|
self._cleanup_empty_dir(date_folder)
|
||||||
|
|
||||||
|
# -- Helpers de borrado ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _delete_paths(
|
||||||
|
self, paths: list[Path], root: Path, result: RetentionResult
|
||||||
|
) -> None:
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
if path.is_symlink():
|
||||||
|
app_logger.warning(f"Retención: se omite symlink {path}")
|
||||||
|
continue
|
||||||
|
if not self._is_inside(path, root):
|
||||||
|
app_logger.warning(
|
||||||
|
f"Retención: se omite ruta fuera de {root}: {path}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
size = path.stat().st_size
|
||||||
|
if self._dry_run:
|
||||||
|
app_logger.info(f"[SIMULACRO] Se borraría {path} ({size} bytes)")
|
||||||
|
else:
|
||||||
|
path.unlink()
|
||||||
|
app_logger.info(f"Retención: borrado {path} ({size} bytes)")
|
||||||
|
result.deleted_files += 1
|
||||||
|
result.freed_bytes += size
|
||||||
|
except FileNotFoundError:
|
||||||
|
result.missing_files += 1
|
||||||
|
except OSError as e:
|
||||||
|
app_logger.warning(f"Retención: no se pudo borrar {path}: {e}")
|
||||||
|
result.errors += 1
|
||||||
|
|
||||||
|
def _cleanup_empty_dir(self, folder: Path) -> None:
|
||||||
|
"""Borra la carpeta-fecha solo si quedó vacía (best-effort, nunca rmtree)."""
|
||||||
|
try:
|
||||||
|
if folder.is_dir() and not any(folder.iterdir()):
|
||||||
|
folder.rmdir()
|
||||||
|
app_logger.info(f"Retención: carpeta vacía eliminada {folder}")
|
||||||
|
except OSError as e:
|
||||||
|
app_logger.warning(f"Retención: no se pudo eliminar carpeta {folder}: {e}")
|
||||||
|
|
||||||
|
def _mark_purged(self, job: Job, result: RetentionResult) -> None:
|
||||||
|
if self._dry_run:
|
||||||
|
return
|
||||||
|
JobRepository.mark_purged(job.job_id)
|
||||||
|
result.purged_jobs += 1
|
||||||
|
|
||||||
|
# -- Helpers puros ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_parts_in_folder(date_folder: Path, source_name: str) -> list[Path]:
|
||||||
|
"""Rutas del archivo (y sus partes multipart) dentro de una carpeta-fecha."""
|
||||||
|
if SevenZipExtractor.is_multipart(source_name):
|
||||||
|
base_name = Path(source_name).stem.split(".zip")[0]
|
||||||
|
return sorted(date_folder.glob(f"{base_name}.zip.*"))
|
||||||
|
candidate = date_folder / source_name
|
||||||
|
return [candidate] if candidate.exists() else []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _candidate_dates(local_date: date) -> list[date]:
|
||||||
|
"""Fecha exacta y ±1 día, para absorber el desfase de medianoche/zona horaria."""
|
||||||
|
return [local_date, local_date - timedelta(days=1), local_date + timedelta(days=1)]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _local_date_from_iso(finished_at: Optional[str]) -> Optional[date]:
|
||||||
|
"""Convierte un finished_at (ISO UTC naive) a la fecha local del movimiento."""
|
||||||
|
if not finished_at:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(finished_at)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone().date()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_date_folder(name: str) -> Optional[date]:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(name, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_inside(path: Path, root: Path) -> bool:
|
||||||
|
"""True si `path` resuelve dentro de `root` (anti path-traversal)."""
|
||||||
|
try:
|
||||||
|
resolved = path.resolve()
|
||||||
|
root_resolved = root.resolve()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return resolved == root_resolved or root_resolved in resolved.parents
|
||||||
@@ -305,11 +305,24 @@ def report_instance_config(
|
|||||||
host_name: Optional[str] = None,
|
host_name: Optional[str] = None,
|
||||||
app_version: Optional[str] = None,
|
app_version: Optional[str] = None,
|
||||||
instance_key: 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:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Reporta la carpeta de entrada vigente a POST /api/restore/instance-config (best-effort).
|
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.
|
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:
|
Returns:
|
||||||
True si el PANEL aceptó el reporte (200), False en cualquier otro caso.
|
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
|
key = (instance_key or "").strip() or None
|
||||||
payload = {
|
payload = {
|
||||||
"input_folder": input_folder.strip(),
|
"input_folder": input_folder.strip(),
|
||||||
|
"processed_folder": (processed_folder or "").strip() or None,
|
||||||
"host_name": (host_name or "").strip() or None,
|
"host_name": (host_name or "").strip() or None,
|
||||||
"app_version": (app_version 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:
|
if key:
|
||||||
payload["instance_key"] = key
|
payload["instance_key"] = key
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ producción conviene fijar/known_hosts las claves de cada servidor.
|
|||||||
|
|
||||||
import ntpath
|
import ntpath
|
||||||
import posixpath
|
import posixpath
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -22,6 +23,12 @@ from ..utils.logger import app_logger
|
|||||||
# Timeout de conexión SSH en segundos.
|
# Timeout de conexión SSH en segundos.
|
||||||
SSH_TIMEOUT = 30
|
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):
|
class SFTPCopyError(Exception):
|
||||||
"""Error al transferir o limpiar el .bak en el servidor remoto vía SFTP."""
|
"""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")
|
raise SFTPCopyError("La carpeta remota destino está vacía")
|
||||||
|
|
||||||
remote_sftp = _sftp_path(str(remote_folder).strip(), src.name)
|
remote_sftp = _sftp_path(str(remote_folder).strip(), src.name)
|
||||||
|
expected_size = src.stat().st_size
|
||||||
client = _connect(cfg)
|
client = _connect(cfg)
|
||||||
try:
|
try:
|
||||||
sftp = client.open_sftp()
|
sftp = client.open_sftp()
|
||||||
try:
|
try:
|
||||||
app_logger.info(f"Subiendo archivo por SFTP a {cfg['ssh_host']}: {remote_sftp}")
|
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:
|
finally:
|
||||||
sftp.close()
|
sftp.close()
|
||||||
except SFTPCopyError:
|
except SFTPCopyError:
|
||||||
@@ -120,16 +132,45 @@ def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str
|
|||||||
return remote_sftp
|
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]:
|
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.
|
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:
|
Returns:
|
||||||
Lista de rutas SFTP subidas.
|
Lista de rutas SFTP subidas.
|
||||||
"""
|
"""
|
||||||
uploaded: list[str] = []
|
uploaded: list[str] = []
|
||||||
for local_path in local_paths:
|
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
|
return uploaded
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from .nodes_tab import NodesTab
|
|||||||
from .config_tab import ConfigTab
|
from .config_tab import ConfigTab
|
||||||
from .logs_tab import LogsTab
|
from .logs_tab import LogsTab
|
||||||
from .tray_assets import load_tray_icon
|
from .tray_assets import load_tray_icon
|
||||||
|
from .. import __version__
|
||||||
|
from ..constants import APP_ARCH, APP_PLATFORM
|
||||||
from ..engine.engine import RestoreEngine
|
from ..engine.engine import RestoreEngine
|
||||||
from ..utils.logger import app_logger
|
from ..utils.logger import app_logger
|
||||||
|
|
||||||
@@ -128,6 +130,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.scan_action.triggered.connect(self._scan_now)
|
self.scan_action.triggered.connect(self._scan_now)
|
||||||
motor_menu.addAction(self.scan_action)
|
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")
|
help_menu = menubar.addMenu("Ayuda")
|
||||||
|
|
||||||
about_action = QAction("Acerca de", self)
|
about_action = QAction("Acerca de", self)
|
||||||
@@ -282,6 +288,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.engine.scan_now()
|
self.engine.scan_now()
|
||||||
app_logger.info("Escaneo manual solicitado desde UI")
|
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):
|
def _update_stats(self):
|
||||||
"""Actualiza las estadísticas."""
|
"""Actualiza las estadísticas."""
|
||||||
self._update_tray_status()
|
self._update_tray_status()
|
||||||
@@ -308,7 +319,7 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.about(
|
QMessageBox.about(
|
||||||
self,
|
self,
|
||||||
"Acerca de CloudRestoreAS",
|
"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"
|
"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"
|
"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"
|
"Use Archivo → Salir o la bandeja → Salir para cerrar por completo.\n\n"
|
||||||
|
|||||||
41
build-all.sh
41
build-all.sh
@@ -4,7 +4,9 @@
|
|||||||
#
|
#
|
||||||
# Linux : build en Docker (ubuntu:22.04) -> dist/CloudRestoreAS (autocontenido)
|
# Linux : build en Docker (ubuntu:22.04) -> dist/CloudRestoreAS (autocontenido)
|
||||||
# Windows: build vía powershell.exe -> build.ps1 -> dist/CloudRestoreAS.exe
|
# 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-<version>-{linux,win}-<arch>.{tar.gz,zip}
|
||||||
|
# + SHA256SUMS + release.json
|
||||||
|
# Publica: paquetes genéricos de Gitea (fuente de verdad que consume el PANEL)
|
||||||
#
|
#
|
||||||
# Uso:
|
# Uso:
|
||||||
# ./build-all.sh # ambos + empaquetado
|
# ./build-all.sh # ambos + empaquetado
|
||||||
@@ -12,6 +14,9 @@
|
|||||||
# ./build-all.sh --windows-only # solo Windows
|
# ./build-all.sh --windows-only # solo Windows
|
||||||
# ./build-all.sh --no-package # sin generar .tar.gz/.zip
|
# ./build-all.sh --no-package # sin generar .tar.gz/.zip
|
||||||
# ./build-all.sh --clean # rebuild desde cero (borra venvs/bundled/dist)
|
# ./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
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -22,6 +27,8 @@ DO_LINUX=1
|
|||||||
DO_WINDOWS=1
|
DO_WINDOWS=1
|
||||||
DO_PACKAGE=1
|
DO_PACKAGE=1
|
||||||
DO_CLEAN=0
|
DO_CLEAN=0
|
||||||
|
DO_PUBLISH=0
|
||||||
|
PUBLISH_ARGS=()
|
||||||
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
@@ -29,6 +36,9 @@ for arg in "$@"; do
|
|||||||
--windows-only) DO_LINUX=0 ;;
|
--windows-only) DO_LINUX=0 ;;
|
||||||
--no-package) DO_PACKAGE=0 ;;
|
--no-package) DO_PACKAGE=0 ;;
|
||||||
--clean) DO_CLEAN=1 ;;
|
--clean) DO_CLEAN=1 ;;
|
||||||
|
--publish) DO_PUBLISH=1 ;;
|
||||||
|
--notify-panel) PUBLISH_ARGS+=(--notify-panel) ;;
|
||||||
|
--force-publish) PUBLISH_ARGS+=(--force) ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;;
|
grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
*) echo "Opción desconocida: $arg" >&2; exit 2 ;;
|
*) echo "Opción desconocida: $arg" >&2; exit 2 ;;
|
||||||
@@ -128,4 +138,33 @@ fi
|
|||||||
FAIL=0
|
FAIL=0
|
||||||
[[ "$DO_LINUX" -eq 1 && "$LIN_OK" -ne 1 ]] && FAIL=1
|
[[ "$DO_LINUX" -eq 1 && "$LIN_OK" -ne 1 ]] && FAIL=1
|
||||||
[[ "$DO_WINDOWS" -eq 1 && "$WIN_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
|
exit $FAIL
|
||||||
|
|||||||
319
install.ps1
319
install.ps1
@@ -1,124 +1,227 @@
|
|||||||
# Script de instalación rápida
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Instalador Windows de CloudRestoreAS.
|
||||||
|
|
||||||
Write-Host "===============================================" -ForegroundColor Cyan
|
.DESCRIPTION
|
||||||
Write-Host "CloudRestoreAS - Instalación Rápida" -ForegroundColor Cyan
|
NO instala ni descarga NADA en el sistema: el binario es 100% autocontenido (Qt,
|
||||||
Write-Host "===============================================" -ForegroundColor Cyan
|
driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo coloca el
|
||||||
Write-Host ""
|
.exe, hace el bootstrap de config\, opcionalmente siembra las credenciales del PANEL
|
||||||
|
y registra el arranque automático.
|
||||||
|
|
||||||
# Verificar Python
|
Por eso tampoco se usa NSSM ni ningún envoltorio de servicio descargado: el arranque
|
||||||
Write-Host "1. Verificando Python..." -ForegroundColor Yellow
|
24/7 se resuelve con una tarea programada ONSTART, que ya viene en el SO.
|
||||||
$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
|
.PARAMETER Service
|
||||||
$versionString = $pythonVersion -replace "Python ", ""
|
Arranque 24/7 sin sesión: tarea programada ONSTART como SYSTEM (recomendado en servidor).
|
||||||
$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
|
.PARAMETER Desktop
|
||||||
Write-Host ""
|
Arranque al iniciar sesión. La app registra su propia tarea ONLOGON al ejecutarse.
|
||||||
Write-Host "2. Verificando 7-Zip..." -ForegroundColor Yellow
|
|
||||||
$sevenZipPaths = @(
|
.PARAMETER Prefix
|
||||||
"C:\Program Files\7-Zip\7z.exe",
|
Carpeta destino. Default C:\Aduanasoft\CloudRestoreAS.
|
||||||
"D:\Program Files\7-Zip\7z.exe",
|
|
||||||
"C:\Program Files (x86)\7-Zip\7z.exe"
|
.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) {
|
$ErrorActionPreference = 'Stop'
|
||||||
if (Test-Path $path) {
|
Set-StrictMode -Version Latest
|
||||||
Write-Host "✅ 7-Zip encontrado en: $path" -ForegroundColor Green
|
|
||||||
$sevenZipFound = $true
|
$BinName = 'CloudRestoreAS.exe'
|
||||||
break
|
$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
|
||||||
}
|
}
|
||||||
}
|
} finally {
|
||||||
if (-not $sevenZipFound) {
|
Remove-Item Env:\QT_QPA_PLATFORM -ErrorAction SilentlyContinue
|
||||||
Write-Host "⚠️ 7-Zip no encontrado en ubicaciones estándar" -ForegroundColor Yellow
|
Pop-Location
|
||||||
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
|
$envPath = Join-Path $Prefix 'config\.env'
|
||||||
Write-Host ""
|
if (Test-Path -LiteralPath $envPath) {
|
||||||
Write-Host "3. Verificando ODBC Driver for SQL Server..." -ForegroundColor Yellow
|
Write-Ok 'config\.env creado.'
|
||||||
$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 {
|
} else {
|
||||||
Write-Host "⚠️ ODBC Driver for SQL Server no encontrado" -ForegroundColor Yellow
|
Write-Warn 'config\.env se creará en la primera ejecución.'
|
||||||
Write-Host " Descarga desde: https://aka.ms/downloadmsodbcsql" -ForegroundColor Yellow
|
|
||||||
Write-Host " (Requerido para conectar con SQL Server)" -ForegroundColor Cyan
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Crear entorno virtual
|
# --- Siembra de credenciales del PANEL ----------------------------------------------
|
||||||
Write-Host ""
|
# Fusión replace-or-append: respeta el resto de config\.env y es idempotente, así que
|
||||||
Write-Host "4. Creando entorno virtual..." -ForegroundColor Yellow
|
# reinstalar no duplica claves ni pierde ajustes locales.
|
||||||
if (Test-Path "venv") {
|
function Merge-EnvFile {
|
||||||
Write-Host " El entorno virtual ya existe, omitiendo..." -ForegroundColor Gray
|
param([string]$Target, [hashtable]$Values)
|
||||||
} else {
|
|
||||||
python -m venv venv
|
$lines = if (Test-Path -LiteralPath $Target) {
|
||||||
if ($LASTEXITCODE -eq 0) {
|
@(Get-Content -LiteralPath $Target -Encoding UTF8)
|
||||||
Write-Host "✅ Entorno virtual creado" -ForegroundColor Green
|
} 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 {
|
} else {
|
||||||
Write-Host "❌ Error creando entorno virtual" -ForegroundColor Red
|
Write-Warn 'El archivo -PanelEnvFile no traía claves CLOUDRESTORE_PANEL_* válidas.'
|
||||||
Read-Host "Presiona Enter para salir..."
|
}
|
||||||
exit 1
|
# 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 ""
|
Write-Host "Siguiente paso: revisa $envPath (CLOUDRESTORE_PANEL_*) y reinicia."
|
||||||
Write-Host "5. Instalando dependencias..." -ForegroundColor Yellow
|
if ($Mode -eq 'service') {
|
||||||
& ".\venv\Scripts\python.exe" -m pip install --upgrade pip
|
Write-Host " Tras editar: Stop-ScheduledTask -TaskName $TaskName; Start-ScheduledTask -TaskName $TaskName"
|
||||||
& ".\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 'Listo.'
|
||||||
# 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..."
|
|
||||||
|
|||||||
150
install.sh
150
install.sh
@@ -1,35 +1,49 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Instalador Linux de CloudRestoreAS.
|
# Instalador Linux de CloudRestoreAS.
|
||||||
#
|
#
|
||||||
# NO instala ni descarga NADA del sistema: el binario es 100% autocontenido
|
# NO instala ni descarga NADA del sistema: el binario es 100% autocontenido (Qt/xcb,
|
||||||
# (Qt/xcb, driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo
|
# driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo coloca el
|
||||||
# coloca el binario, marca permisos, hace el bootstrap de config/, y opcionalmente
|
# binario, marca permisos, hace el bootstrap de config/, opcionalmente siembra las
|
||||||
# registra el arranque automático (servicio systemd headless o autostart de escritorio).
|
# credenciales del PANEL, y registra el arranque automático (servicio systemd headless
|
||||||
|
# o autostart de escritorio).
|
||||||
#
|
#
|
||||||
# Uso:
|
# Uso:
|
||||||
# sudo ./install.sh --service # 24/7 headless vía systemd (recomendado en servidor)
|
# 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 --desktop # autostart de escritorio (requiere sesión gráfica)
|
||||||
# ./install.sh # solo instala; sin arranque automático
|
# ./install.sh # solo instala; sin arranque automático
|
||||||
#
|
#
|
||||||
|
# Opciones:
|
||||||
|
# --panel-env-file <ruta> # 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:
|
# Variables:
|
||||||
# PREFIX=/opt/cloudrestoreas # carpeta destino (default)
|
# PREFIX=/opt/cloudrestoreas # carpeta destino (default)
|
||||||
# SERVICE_USER=<usuario> # usuario del servicio systemd (default: quien invoca)
|
# SERVICE_USER=<usuario> # usuario del servicio systemd (default: quien invoca)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
MODE="none"
|
MODE="none"
|
||||||
for arg in "$@"; do
|
PANEL_ENV_FILE=""
|
||||||
case "$arg" in
|
|
||||||
--service) MODE="service" ;;
|
while [[ $# -gt 0 ]]; do
|
||||||
--desktop) MODE="desktop" ;;
|
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)
|
-h|--help)
|
||||||
grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;;
|
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
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PREFIX="${PREFIX:-/opt/cloudrestoreas}"
|
PREFIX="${PREFIX:-/opt/cloudrestoreas}"
|
||||||
BIN_NAME="CloudRestoreAS"
|
BIN_NAME="CloudRestoreAS"
|
||||||
|
UNIT_NAME="cloudrestoreas.service"
|
||||||
|
|
||||||
# Localizar el binario: dist/CloudRestoreAS, dist/CloudRestoreAS-linux, o junto al script.
|
# Localizar el binario: dist/CloudRestoreAS, dist/CloudRestoreAS-linux, o junto al script.
|
||||||
SRC=""
|
SRC=""
|
||||||
@@ -52,8 +66,27 @@ echo " Destino : $PREFIX"
|
|||||||
echo " Modo : $MODE"
|
echo " Modo : $MODE"
|
||||||
echo "==============================================="
|
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"
|
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"
|
echo "Binario instalado en $PREFIX/$BIN_NAME"
|
||||||
|
|
||||||
# Bootstrap de config/: el propio binario crea config/, .env y carpetas de trabajo
|
# 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."
|
echo "NOTA: config/.env se creará en la primera ejecución."
|
||||||
fi
|
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
|
case "$MODE" in
|
||||||
service)
|
service)
|
||||||
SERVICE_USER="${SERVICE_USER:-${SUDO_USER:-$(id -un)}}"
|
SERVICE_USER="${SERVICE_USER:-${SUDO_USER:-$(id -un)}}"
|
||||||
UNIT_SRC="$SCRIPT_DIR/packaging/linux/cloudrestoreas.service"
|
UNIT_SRC="$SCRIPT_DIR/packaging/linux/$UNIT_NAME"
|
||||||
UNIT_DST="/etc/systemd/system/cloudrestoreas.service"
|
UNIT_DST="/etc/systemd/system/$UNIT_NAME"
|
||||||
if [[ ! -w "$(dirname "$UNIT_DST")" ]]; then
|
if [[ ! -w "$(dirname "$UNIT_DST")" ]]; then
|
||||||
echo "ERROR: se requiere sudo para instalar el servicio systemd" >&2
|
echo "ERROR: se requiere sudo para instalar el servicio systemd" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -82,7 +198,7 @@ case "$MODE" in
|
|||||||
-e "s|__EXEC__|$PREFIX/$BIN_NAME|g" \
|
-e "s|__EXEC__|$PREFIX/$BIN_NAME|g" \
|
||||||
"$UNIT_SRC" > "$UNIT_DST"
|
"$UNIT_SRC" > "$UNIT_DST"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now cloudrestoreas.service
|
systemctl enable --now "$UNIT_NAME"
|
||||||
echo "Servicio systemd instalado y arrancado (usuario: $SERVICE_USER)."
|
echo "Servicio systemd instalado y arrancado (usuario: $SERVICE_USER)."
|
||||||
echo " Estado : systemctl status cloudrestoreas"
|
echo " Estado : systemctl status cloudrestoreas"
|
||||||
echo " Logs : journalctl -u cloudrestoreas -f"
|
echo " Logs : journalctl -u cloudrestoreas -f"
|
||||||
@@ -97,6 +213,14 @@ case "$MODE" in
|
|||||||
;;
|
;;
|
||||||
esac
|
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 ""
|
||||||
echo "Siguiente paso: edita $PREFIX/config/.env (CLOUDRESTORE_PANEL_*) y reinicia."
|
echo "Siguiente paso: edita $PREFIX/config/.env (CLOUDRESTORE_PANEL_*) y reinicia."
|
||||||
[[ "$MODE" == "service" ]] && echo " Tras editar: sudo systemctl restart cloudrestoreas"
|
[[ "$MODE" == "service" ]] && echo " Tras editar: sudo systemctl restart cloudrestoreas"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
"""PyInstaller spec — onefile portable (ejecutar en Windows o Linux según destino)."""
|
"""PyInstaller spec — onefile portable (ejecutar en Windows o Linux según destino)."""
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -8,9 +9,27 @@ ROOT = Path(SPECPATH).resolve().parent
|
|||||||
platform = "windows" if sys.platform == "win32" else "linux"
|
platform = "windows" if sys.platform == "win32" else "linux"
|
||||||
bundled = ROOT / "packaging" / "bundled" / platform
|
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"
|
_assets = ROOT / "packaging" / "assets"
|
||||||
datas = [
|
datas = [
|
||||||
(str(ROOT / "packaging" / "templates" / "env.default"), "packaging/templates"),
|
(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():
|
if _assets.is_dir():
|
||||||
for _icon in _assets.iterdir():
|
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_file = ROOT / "packaging" / "assets" / "tray-icon.ico"
|
||||||
_icon = str(_icon_file) if _icon_file.is_file() else None
|
_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(
|
exe = EXE(
|
||||||
pyz,
|
pyz,
|
||||||
a.scripts,
|
a.scripts,
|
||||||
@@ -98,6 +193,7 @@ exe = EXE(
|
|||||||
[],
|
[],
|
||||||
name="CloudRestoreAS",
|
name="CloudRestoreAS",
|
||||||
icon=_icon,
|
icon=_icon,
|
||||||
|
version=_version_info,
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
|
|||||||
@@ -1,47 +1,242 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Empaqueta artefactos en dist/release/
|
# Empaqueta artefactos publicables en dist/release/.
|
||||||
|
#
|
||||||
|
# Salida:
|
||||||
|
# CloudRestoreAS-<version>-linux-x86_64.tar.gz binario + install.sh + unit + LEEME
|
||||||
|
# CloudRestoreAS-<version>-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
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
RELEASE="$ROOT/dist/release"
|
RELEASE="$ROOT/dist/release"
|
||||||
mkdir -p "$RELEASE"
|
mkdir -p "$RELEASE"
|
||||||
|
|
||||||
[[ -f "$ROOT/dist/CloudRestoreAS.exe" ]] && cp -f "$ROOT/dist/CloudRestoreAS.exe" "$RELEASE/"
|
RED='\033[31m'; NC='\033[0m'
|
||||||
[[ -f "$ROOT/dist/CloudRestoreAS" ]] && cp -f "$ROOT/dist/CloudRestoreAS" "$RELEASE/CloudRestoreAS-linux" && chmod +x "$RELEASE/CloudRestoreAS-linux"
|
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/"
|
cp -f "$ROOT/packaging/LEEME.txt" "$RELEASE/"
|
||||||
|
|
||||||
# Paquete Windows: carpeta raíz CloudRestoreAS/ con .exe + install.ps1 + LEEME
|
# --- Paquete Windows ---------------------------------------------------------------
|
||||||
# (paridad con el .tar.gz de Linux).
|
# install.ps1 es el instalador de despliegue (autocontenido). El script de entorno de
|
||||||
if [[ -f "$RELEASE/CloudRestoreAS.exe" ]]; then
|
# desarrollo vive en scripts/dev-setup.ps1 y NO va en el paquete: antes se empaquetaba
|
||||||
ROOT="$ROOT" RELEASE="$RELEASE" python3 <<'PY'
|
# 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
|
import os, zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
root = Path(os.environ["ROOT"]); release = Path(os.environ["RELEASE"])
|
|
||||||
items = [(release / "CloudRestoreAS.exe", "CloudRestoreAS/CloudRestoreAS.exe"),
|
root = Path(os.environ["ROOT"])
|
||||||
(root / "packaging" / "LEEME.txt", "CloudRestoreAS/LEEME.txt")]
|
release = Path(os.environ["RELEASE"])
|
||||||
if (root / "install.ps1").exists():
|
# El .exe se toma de dist/, que es donde escribe PyInstaller. Tomarlo de dist/release/
|
||||||
items.append((root / "install.ps1", "CloudRestoreAS/install.ps1"))
|
# permitía empaquetar la copia de una corrida anterior.
|
||||||
with zipfile.ZipFile(release / "CloudRestoreAS-win.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
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:
|
for src, arc in items:
|
||||||
z.write(src, arc)
|
z.write(src, arc)
|
||||||
print(" zip Windows:", release / "CloudRestoreAS-win.zip")
|
print(f" zip Windows: {out.name}")
|
||||||
PY
|
PY
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "$RELEASE/CloudRestoreAS-linux" ]]; then
|
# --- Paquete Linux -----------------------------------------------------------------
|
||||||
# Paquete de despliegue completo: binario + instalador + unit systemd + LEEME,
|
if [[ "$HAVE_LINUX" -eq 1 ]]; then
|
||||||
# bajo una carpeta raíz para que extraiga limpio. install.sh encuentra el binario
|
# 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.
|
# (CloudRestoreAS-linux) y la unit (packaging/linux/) relativos a su ubicación.
|
||||||
STAGE="$RELEASE/CloudRestoreAS-linux"
|
|
||||||
PKG="$RELEASE/pkg-linux/CloudRestoreAS"
|
PKG="$RELEASE/pkg-linux/CloudRestoreAS"
|
||||||
rm -rf "$RELEASE/pkg-linux"
|
rm -rf "$RELEASE/pkg-linux"
|
||||||
mkdir -p "$PKG/packaging/linux"
|
mkdir -p "$PKG/packaging/linux"
|
||||||
cp -f "$STAGE" "$PKG/CloudRestoreAS-linux"; chmod +x "$PKG/CloudRestoreAS-linux"
|
cp -f "$ROOT/dist/CloudRestoreAS" "$PKG/CloudRestoreAS-linux"
|
||||||
cp -f "$ROOT/install.sh" "$PKG/install.sh"; chmod +x "$PKG/install.sh"
|
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/linux/cloudrestoreas.service" "$PKG/packaging/linux/"
|
||||||
cp -f "$ROOT/packaging/LEEME.txt" "$PKG/LEEME.txt"
|
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"
|
rm -rf "$RELEASE/pkg-linux"
|
||||||
|
echo " tar.gz Linux: $LINUX_PKG"
|
||||||
fi
|
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"
|
ls -lh "$RELEASE"
|
||||||
|
|||||||
221
packaging/scripts/publish-release.sh
Executable file
221
packaging/scripts/publish-release.sh
Executable file
@@ -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"
|
||||||
48
runner.py
48
runner.py
@@ -28,9 +28,53 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Fuerza el modo sin interfaz (Qt offscreen) para servidores sin display",
|
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)
|
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:
|
def _ensure_qt_platform(headless: bool = False) -> str:
|
||||||
"""
|
"""
|
||||||
Selecciona el plugin de plataforma Qt en Linux. En un servidor headless (sin
|
Selecciona el plugin de plataforma Qt en Linux. En un servidor headless (sin
|
||||||
@@ -175,6 +219,10 @@ def _run() -> int:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Punto de entrada: ejecuta _run() y hace visible cualquier fallo de arranque."""
|
"""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:
|
try:
|
||||||
sys.exit(_run())
|
sys.exit(_run())
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
|
|||||||
128
scripts/dev-setup.ps1
Normal file
128
scripts/dev-setup.ps1
Normal file
@@ -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..."
|
||||||
@@ -77,3 +77,111 @@ def test_apply_env_overrides_paths(monkeypatch: pytest.MonkeyPatch):
|
|||||||
monkeypatch.setenv("CLOUDRESTORE_INPUT_FOLDER", "/tmp/in")
|
monkeypatch.setenv("CLOUDRESTORE_INPUT_FOLDER", "/tmp/in")
|
||||||
cfg = apply_env_overrides(DEFAULT_CONFIG.copy())
|
cfg = apply_env_overrides(DEFAULT_CONFIG.copy())
|
||||||
assert cfg["paths"]["input_folder"] == "/tmp/in"
|
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()
|
||||||
|
|||||||
170
tests/test_forward_false_failure.py
Normal file
170
tests/test_forward_false_failure.py
Normal file
@@ -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"]
|
||||||
89
tests/test_maintenance_scheduler.py
Normal file
89
tests/test_maintenance_scheduler.py
Normal file
@@ -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")
|
||||||
116
tests/test_multipart_case.py
Normal file
116
tests/test_multipart_case.py
Normal file
@@ -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
|
||||||
@@ -57,7 +57,9 @@ def test_target_db_vacio_devuelve_none():
|
|||||||
def test_target_ok(monkeypatch):
|
def test_target_ok(monkeypatch):
|
||||||
captured = {}
|
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
|
captured["url"] = url
|
||||||
return FakeResponse(200, VALID_TARGET)
|
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):
|
def test_report_job_result_201_true(monkeypatch):
|
||||||
captured = {}
|
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
|
captured["json"] = json
|
||||||
return FakeResponse(201)
|
return FakeResponse(201)
|
||||||
|
|
||||||
@@ -166,7 +168,7 @@ def test_test_connection_url_invalida():
|
|||||||
def test_report_instance_config_200_true(monkeypatch):
|
def test_report_instance_config_200_true(monkeypatch):
|
||||||
captured = {}
|
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["url"] = url
|
||||||
captured["json"] = json
|
captured["json"] = json
|
||||||
return FakeResponse(200)
|
return FakeResponse(200)
|
||||||
@@ -256,7 +258,7 @@ CATALOG_RESPONSE = {
|
|||||||
def test_list_restore_target_names_ok(monkeypatch):
|
def test_list_restore_target_names_ok(monkeypatch):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def fake_get(url, headers=None, timeout=None):
|
def fake_get(url, headers=None, timeout=None, **kwargs):
|
||||||
captured["url"] = url
|
captured["url"] = url
|
||||||
return FakeResponse(200, CATALOG_RESPONSE)
|
return FakeResponse(200, CATALOG_RESPONSE)
|
||||||
|
|
||||||
@@ -346,7 +348,7 @@ ROUTE_RESTORE_LOCAL = {
|
|||||||
def test_resolve_route_forward_ok(monkeypatch):
|
def test_resolve_route_forward_ok(monkeypatch):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def fake_get(url, headers=None, timeout=None):
|
def fake_get(url, headers=None, timeout=None, **kwargs):
|
||||||
captured["url"] = url
|
captured["url"] = url
|
||||||
return FakeResponse(200, ROUTE_FORWARD)
|
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)
|
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, bad)
|
||||||
)
|
)
|
||||||
assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None
|
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
|
||||||
|
|||||||
291
tests/test_release_metadata.py
Normal file
291
tests/test_release_metadata.py
Normal file
@@ -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)
|
||||||
157
tests/test_retention.py
Normal file
157
tests/test_retention.py
Normal file
@@ -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
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
Pruebas de la transferencia SFTP al servidor remoto. Se mockea paramiko para no
|
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.
|
requerir un servidor SSH real; se valida la conversión de rutas y el flujo de subida.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.transfer import sftp_copy
|
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)
|
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:
|
class FakeSFTP:
|
||||||
def __init__(self, store):
|
def __init__(self, store):
|
||||||
self.store = store
|
self.store = store
|
||||||
|
|
||||||
def put(self, local, remote):
|
def put(self, local, remote, confirm=True):
|
||||||
self.store["put"] = (local, remote)
|
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):
|
def remove(self, remote):
|
||||||
self.store["removed"] = remote
|
self.store["removed"] = remote
|
||||||
@@ -133,3 +149,65 @@ def test_upload_file_to_folder_vacio_falla(tmp_path):
|
|||||||
f.write_bytes(b"x")
|
f.write_bytes(b"x")
|
||||||
with pytest.raises(SFTPCopyError, match="carpeta remota"):
|
with pytest.raises(SFTPCopyError, match="carpeta remota"):
|
||||||
sftp_copy.upload_file_to_folder(str(f), CFG, " ")
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user