14 Commits

Author SHA1 Message Date
c8133a5108 chore(version): 1.1.2 2026-07-31 07:27:33 -06:00
874fe0c46f fix(install): devolver la propiedad del árbol al usuario del servicio
Instalando con sudo, el agente quedaba sin poder leer ni escribir NADA de lo
suyo, y tanto el panel como su sonda lo reportaban como éxito.

El bootstrap ejecuta el binario como root, y ese arranque crea todo el árbol bajo
PREFIX: config/, config/data/app.db, config/logs/, Entrada/, Procesados/,
Fallados/ y Temp/. La siembra escribe config/.env en 0600, también de root. Pero
el unit se registra con User=$SUDO_USER, así que el agente arranca como una
cuenta común que no puede leer su configuración (load_dotenv sin try/except),
ni guardar jobs en su base, ni mover ZIPs entre las carpetas de trabajo.
install.sh no hacía chown en ninguna parte.

Reproducido en un contenedor antes de arreglarlo: .env root:root 600, app.db y
Entrada/ de root, las tres operaciones del agente fallando — y `test -f` de la
sonda del panel dando ok, o sea verde justo en el caso roto.

SERVICE_USER se resuelve ahora al principio (antes se calculaba dentro del case
de --service, después del bootstrap y de la siembra), conservando la misma
cadena de precedencia. El chown va después de ambos pasos, solo cuando el script
corre como root y el servicio no es root, y solo del usuario —no del grupo—;
`chown` no toca los modos, así que el 0600 del .env sobrevive.

Con dos cuidados que no son opcionales:

- Guarda contra un PREFIX de sistema: un `chown -R` sobre / o /opt sería
  catastrófico. Se rechazan las rutas de sistema y las de un solo componente,
  avisando en vez de abortar una instalación ya hecha.
- Si el chown falla se reporta como ERROR con el comando de arreglo, no se traga.
  Es el mismo criterio que el repo ya aplica al icacls de Windows: no se asume
  que un comando de endurecimiento tuvo éxito.

Probado: arreglo, guarda de /opt, usuario inexistente, y sin sudo (no intenta
nada). Windows no necesita el arreglo simétrico y queda documentado por qué: la
tarea corre como SYSTEM con RunLevel Highest y install.ps1 no restringe ninguna
ACL, así que hereda de su carpeta padre y puede leer todo lo del Administrador.
2026-07-30 16:16:28 -06:00
f02cd1f4c3 feat(install): --update-in-place, actualización sin privilegios y reversible
Hay entornos donde no se usa root en absoluto, así que ni `sudo -n` ni una cuenta
root son opciones. Este modo actualiza una instalación existente dejando su unit
de systemd intacto, que es lo único que una actualización necesita de verdad:
reemplazar el binario y reiniciar el proceso.

Se apoya en dos hechos, uno de ellos contrario a lo que decía el propio repo:

- `install` NO sufre ETXTBSY. A diferencia de `cp` —que abre con O_TRUNC—,
  desvincula el destino antes de crearlo, y por eso `make install` funciona sobre
  binarios en ejecución. Comprobado: `cp` sobre un ELF corriendo da "Text file
  busy" y `install` no. La consecuencia es la que importa: reemplazar el binario
  exige escritura en el DIRECTORIO, no en el archivo. El comentario de install.sh,
  BUILD.md y el CHANGELOG afirmaban lo contrario y mandaban al operador a
  diagnosticar un archivo en uso cuando lo que tenía era un EACCES.
- El unit corre como el usuario que instaló (cadena SUDO_USER) y trae
  Restart=always, así que esa cuenta puede señalizar el proceso y systemd lo
  relevanta con el binario nuevo. No hace falta systemctl ni tocar /etc.

Robustez, que es donde estaba el trabajo real:

- **Reversible.** Respalda el binario antes de reemplazarlo y, si el nuevo no
  arranca, lo restaura y confirma que el proceso volvió. Sin esto, una
  actualización fallida deja el servidor sin agente. Si tampoco puede revertir,
  conserva el respaldo y lo dice en vez de fingir éxito.
- **No interrumpe restauraciones.** El agente no atiende SIGTERM: matarlo a media
  restauración deja ese respaldo vetado para siempre (has_blocking_job_by_hash) y
  puede dejar la base en SINGLE_USER. Se comprueba Temp/ dos veces —antes de
  copiar y otra vez justo antes de señalizar, para cerrar la ventana— y sale con
  75 (EX_TEMPFAIL), que el panel traduce a "reintenta luego" y no a un fallo.
- **Diagnostica por qué no volvió**: distingue un unit sin Restart=always de un
  StartLimitBurst agotado, con el comando de recuperación.
- Omite el bootstrap de 20 s: es redundante en una actualización
  (ensure_runtime_layout corre en cada arranque) y una segunda instancia junto a
  la viva purgaría el Temp de la que está trabajando.

Un bug que solo aparecía fuera del camino feliz: con `set -euo pipefail`, un
`$(pgrep ... | head -1)` sin resultados hace fallar la sustitución y `set -e`
mataba el script en silencio — justo en el caso "el proceso no volvió", que es el
que había que manejar. Por eso el rollback no se ejecutaba nunca.
2026-07-30 15:40:37 -06:00
17c0dea4bf feat(install): modo --user-service, instalación sin privilegios
El instalador solo necesitaba root por dos razones circunstanciales: el PREFIX
por omisión en /opt y el unit en /etc/systemd/system. El agente en sí no lo
necesita — su unit corre como el usuario que instala, y las rutas de data_folder
las escribe SQL Server vía las cláusulas MOVE del T-SQL, no el agente.

--user-service instala donde apunte PREFIX (escribible por el usuario, típicamente
bajo su home) y registra el unit en ~/.config/systemd/user/. Para que sobreviva al
cierre de sesión intenta habilitar lingering; si el destino no lo permite, cae a una
entrada @reboot en el crontab del usuario más un vigilante cada 5 min que cubre lo
que en systemd hace Restart=always. Ninguno de los dos mecanismos requiere root.

Con esto, el instalador remoto del panel puede actualizar un servidor cuya cuenta
SSH no es root ni tiene sudo sin contraseña, sin pasarle nunca la contraseña a sudo.

Detalles que costaron una prueba cada uno:

- `pgrep -f "$PREFIX/$BIN_NAME"` se auto-detectaba: la línea de cron del vigilante
  contiene esa ruta, así que el `sh -c` que la ejecuta hacía match consigo mismo y
  el vigilante nunca rearrancaba. Va anclado con `^`.
- El sed de la plantilla sustituía los marcadores dentro de su propio comentario,
  dejando rutas absolutas en un texto sin sentido.
- El bootstrap de 20 s era el único hijo que heredaba el stdin del canal SSH; ahora
  lleva `</dev/null`, lo que hace estructural que no pueda consumir nada de él.

Reinstalar es idempotente: no duplica entradas de cron.
2026-07-30 13:24:50 -06:00
fd077dad42 Merge branch 'development' into feature/generador-instaladores-linux-windows 2026-07-30 07:34:58 -06:00
c3f1d70e23 feature/generador-instaladores-linux-windows 2026-07-30 07:34:17 -06:00
ce38956e3e Merge pull request 'feature/generador-instaladores-linux-windows' (#2) from feature/generador-instaladores-linux-windows into development
Reviewed-on: #2
2026-07-01 16:37:35 +00:00
cafe3f1b87 Merge branch 'development' into feature/generador-instaladores-linux-windows 2026-07-01 10:36:29 -06:00
0c14f1166b feature/generador-instaladores-linux-windows 2026-07-01 10:33:05 -06:00
0d8d80d61d feature/build-y-ui-contraible 2026-06-30 16:40:01 -06:00
0111712df6 Merge pull request 'feature/integracion-panel-restore-targets' (#1) from feature/integracion-panel-restore-targets into development
Reviewed-on: #1
2026-06-30 17:29:55 +00:00
034a5ca5bb feature/integracion-cpanel-asrecovery 2026-06-30 11:29:06 -06:00
072be5b5db feature/integracion-panel-restore-targets 2026-06-05 12:28:04 -06:00
afb76ce4a6 feature/integracion-panel-restore-targets 2026-06-05 10:49:05 -06:00
71 changed files with 9293 additions and 609 deletions

21
.gitignore vendored
View File

@@ -25,6 +25,8 @@ MANIFEST
# Virtual Environment # Virtual Environment
venv/ venv/
venv-windows/
venv-linux/
ENV/ ENV/
env/ env/
@@ -35,16 +37,25 @@ env/
*.swo *.swo
*~ *~
# Logs # Logs y datos locales
logs/*.log logs/*.log
logs/*.log.* logs/*.log.*
# Database
data/*.db data/*.db
data/*.db-journal data/*.db-journal
config/.env
config/data/
config/logs/
config/odbc/
config/7zip/
config/.bootstrap_ok
config/.autostart_registered
# PyInstaller # PyInstaller / empaquetado
*.spec packaging/bundled/
dist/installers/
# Variables de entorno locales
.env
# OS # OS
.DS_Store .DS_Store

315
BUILD.md Normal file
View File

@@ -0,0 +1,315 @@
# BUILD.md — Compilación, empaquetado y despliegue
Referencia de **todos** los comandos, ejecutables y scripts para generar, empaquetar,
instalar y verificar CloudRestoreAS en Windows y Linux.
El ejecutable es **autocontenido**: no requiere Python, 7-Zip, driver ODBC ni librerías
Qt instaladas en el equipo destino. Todo se embebe dentro del binario en tiempo de build.
---
## 1. Todo en una tarea (recomendado) — `build-all.sh`
Orquestador único. **Se ejecuta desde WSL** (bash). Genera Windows + Linux + los paquetes.
```bash
./build-all.sh # Windows + Linux + paquetes versionados en dist/release/
./build-all.sh --linux-only # solo Linux (Docker)
./build-all.sh --windows-only # solo Windows (PowerShell + build.ps1)
./build-all.sh --no-package # compila sin generar .tar.gz/.zip
./build-all.sh --clean # rebuild desde cero (borra venvs, bundled, dist)
./build-all.sh --publish # además publica en Gitea (ver §5; requiere GITEA_TOKEN)
./build-all.sh --help
```
**Requisitos:**
- Linux: **Docker** (usa `docker-build-linux.sh` en ubuntu:22.04).
- Windows: **`powershell.exe`** accesible desde WSL + **Python 3.11+** instalado en Windows
(`build.ps1` lo auto-detecta en `%LOCALAPPDATA%\Programs\Python\Python31X`).
- Es *fail-soft*: si no hay `powershell.exe`, compila solo Linux y avisa.
> El build de Windows se hace sobre la ruta `\\wsl.localhost\...`, por eso tarda
> (~15-18 min: pip install de PySide6 + PyInstaller). El de Linux (Docker, con deps
> cacheadas) es rápido.
`packaging/scripts/build-all.sh` es un wrapper que delega en este mismo script.
---
## 2. Builds individuales
| Objetivo | Comando | Salida | Notas |
|---|---|---|---|
| **Linux (Docker, recomendado)** | `bash packaging/scripts/docker-build-linux.sh` | `dist/CloudRestoreAS` | ubuntu:22.04 con todas las deps + `patchelf`; garantiza autocontención |
| **Linux (host)** | `./build.sh` | `dist/CloudRestoreAS` | Requiere que el host tenga las libs Qt/ODBC; usar solo si no hay Docker |
| **Windows** | `.\build.ps1` (en Windows/PowerShell) | `dist\CloudRestoreAS.exe` | Auto-detecta Python 3.11+; crea `venv-windows` |
Ambos usan el mismo spec: [packaging/CloudRestoreAS.spec](packaging/CloudRestoreAS.spec) (PyInstaller **onefile**, `console=False`).
---
## 3. Dependencias embebidas (build-time)
Se descargan e integran al binario. No se instalan en el destino.
| Script | Qué embebe |
|---|---|
| `packaging/scripts/download-bundled-deps.sh` (Linux) | `7zz` (7-Zip Linux), driver **MS ODBC 18** + unixODBC + Kerberos/GSSAPI + libltdl + OpenSSL, y cluster **Qt xcb/X11 + EGL**. Aplica `patchelf --set-rpath '$ORIGIN'` a las `.so` del cluster ODBC para que resuelvan entre sí. |
| `packaging/scripts/download-bundled-deps.ps1` (Windows) | `7z.exe` y `msodbcsql18.dll`. |
Versiones/URLs en [packaging/bundled-versions.json](packaging/bundled-versions.json).
Los binarios quedan en `packaging/bundled/{linux,windows}/` (git-ignored, se generan en el build).
**Ubicación en runtime** (creada por el bootstrap en la primera ejecución):
- `config/7zip/7zz` — extractor.
- `config/odbc/lib/` — driver ODBC + toda su cadena (resuelven por `$ORIGIN`).
- Cluster Qt xcb/EGL — dentro del onefile, en `PySide6/Qt/lib`.
---
## 4. Empaquetado — `package-release.sh`
```bash
bash packaging/scripts/package-release.sh
```
La versión sale de `app/__init__.py` (fuente única) y va en el nombre de cada paquete.
El script aborta si la versión no es puntos-y-números: el PANEL las compara como tuplas
de enteros y otro formato rompería en silencio la detección de "hay versión nueva".
Genera en `dist/release/`:
| Archivo | Contenido |
|---|---|
| `CloudRestoreAS-<version>-linux-<arch>.tar.gz` | `CloudRestoreAS/` → binario + `install.sh` + `packaging/linux/cloudrestoreas.service` + `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. 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)
```bash
tar xzf CloudRestoreAS-<version>-linux-x86_64.tar.gz && cd CloudRestoreAS
sudo ./install.sh --service # servicio systemd 24/7 headless (recomendado en servidor)
./install.sh --user-service # 24/7 SIN privilegios: unit de systemd de usuario
./install.sh --update-in-place # actualiza una instalación existente SIN privilegios
./install.sh --desktop # autostart .desktop (requiere sesión gráfica)
./install.sh # solo instala + bootstrap; lo corres a mano
./install.sh --help
```
Variables: `PREFIX=/opt/cloudrestoreas` (destino), `SERVICE_USER=<usuario>` (usuario del servicio).
Detiene el servicio antes de reemplazar el binario en los modos de servicio, para que el apagado
sea ordenado. **No** porque la copia lo exija: `install` desvincula el destino antes de crearlo —a
diferencia de `cp`, que abre con `O_TRUNC` y sí da `ETXTBSY`—, y por eso `make install` funciona
sobre binarios en ejecución. Comprobado. Lo que hace falta para reemplazar el binario es permiso de
escritura en el **directorio**, no en el archivo. (Esta nota decía lo contrario y mandó a más de
uno por la pista equivocada al diagnosticar un `EACCES`.)
### Sin privilegios
`--user-service` instala bajo el home con un unit de systemd **de usuario** (lingering, y si el
destino no lo permite, `@reboot` en el crontab del usuario más un vigilante). Sirve para
instalaciones nuevas donde nunca vas a tener root.
`--update-in-place` actualiza una instalación **que ya existe**, dejando su unit intacto: solo
reemplaza el binario y señaliza al proceso para que `Restart=always` lo relevante. Exige que el
directorio sea escribible por la cuenta, que el unit corra con ese mismo usuario y que tenga
`Restart=always`. Se niega si hay una restauración en curso (sale con **75**, `EX_TEMPFAIL`) y
**revierte al binario anterior** si el nuevo no arranca.
Servicio systemd:
```bash
systemctl status cloudrestoreas
journalctl -u cloudrestoreas -f
sudo systemctl restart cloudrestoreas # tras editar config/.env
```
### Windows — `install.ps1` (autocontenido, sin NSSM ni descargas)
```powershell
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.
---
## 7. Ejecución manual y flags del binario
```bash
# Linux servidor sin pantalla (headless): motor de restauración sin GUI
QT_QPA_PLATFORM=offscreen ./CloudRestoreAS --start-engine --headless
# Linux con escritorio o Windows: abre la GUI normal
./CloudRestoreAS
```
| Flag / variable | Efecto |
|---|---|
| `--start-engine` | Inicia el motor de restauración al arrancar |
| `--headless` | Fuerza modo sin interfaz (Qt `offscreen`), para servidores sin display |
| `--minimized` | Inicia minimizado en la bandeja |
| `--version` | Imprime `CloudRestoreAS <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) |
En Linux sin `DISPLAY`, el binario selecciona `offscreen` **solo**; con display usa `xcb`.
---
## 8. Configuración (primera ejecución)
El binario crea automáticamente: `config/`, `config/.env`, `Entrada/`, `Procesados/`,
`Fallados/`, `Temp/`. Editar `config/.env`:
```
CLOUDRESTORE_AUTO_START=true # el motor arranca solo
CLOUDRESTORE_PANEL_API_URL=... # servicio PANEL_BASES_ANEXO24 (enrutamiento)
CLOUDRESTORE_PANEL_API_TOKEN=...
CLOUDRESTORE_PANEL_INSTANCE_KEY=...
```
El PANEL entrega el servidor SQL destino y su `data_folder`. Con SQL Server sobre Linux
serán rutas POSIX (p. ej. `/var/opt/mssql/data`); el `RESTORE ... MOVE` adapta el separador
automáticamente según el formato del `data_folder`.
---
## 9. Verificación de autocontención (contenedor pelado)
Confirma que el binario Linux corre sin instalar NADA del sistema:
```bash
docker run --rm -v "$PWD/dist:/dist:ro" debian:12-slim bash -c '
cp /dist/CloudRestoreAS /root/app && cd /root
QT_QPA_PLATFORM=offscreen timeout 12 ./app --headless --start-engine 2>&1 | \
grep -iE "offscreen|Ventana principal|Traceback|platform plugin"
# ODBC: driver + cadena resuelven por $ORIGIN (debe dar 0)
ldd /root/config/odbc/lib/libmsodbcsql-18*.so* 2>&1 | grep -c "not found"
# 7-Zip embebido
/root/config/7zip/7zz | head -1
'
```
Esperado: arranca en `offscreen` sin errores Qt, `0` deps ODBC faltantes, `7zz` ejecuta.
---
## 10. Artefactos y ubicaciones
| Ruta | Qué es |
|---|---|
| `dist/CloudRestoreAS` | Binario Linux onefile |
| `dist/CloudRestoreAS.exe` | Ejecutable Windows onefile |
| `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) |
| `venv-linux/`, `venv-windows/` | Entornos virtuales de build (git-ignored) |
---
## 11. Scripts de referencia rápida
| Script | Propósito |
|---|---|
| `build-all.sh` | **Todo en uno** (Windows + Linux + paquetes) desde WSL |
| `build.sh` / `build.ps1` | Build individual Linux / Windows |
| `packaging/scripts/docker-build-linux.sh` | Build Linux en contenedor controlado |
| `packaging/scripts/download-bundled-deps.sh` / `.ps1` | Descarga+embebe deps |
| `packaging/scripts/package-release.sh` | Genera los paquetes versionados + `SHA256SUMS` + `release.json` |
| `packaging/scripts/publish-release.sh` | Publica en los paquetes genéricos de Gitea y verifica el sha256 |
| `install.sh` / `install.ps1` | Instalador de **despliegue** Linux / Windows (autocontenidos) |
| `scripts/dev-setup.ps1` | Entorno de **desarrollo** en Windows (Python + venv). No sirve para desplegar |
| `packaging/linux/cloudrestoreas.service` | Unit systemd (24/7 headless) |

View File

@@ -1,5 +1,53 @@
# 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 y lo vuelve a levantar si
estaba activo. (La razón que se dio aquí —que un ELF en ejecución da `ETXTBSY`— era incorrecta:
eso le pasa a `cp`, no a `install`, que desvincula el destino antes de crearlo. Ver BUILD.md.)
#### 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

251
INTEGRACION_PANEL.md Normal file
View File

@@ -0,0 +1,251 @@
# Integración CloudRestoreAS ↔ PANEL_BASES_ANEXO24
## Variables de entorno y configuración
| Panel (`.env`) | CloudRestoreAS (pestaña Config → PANEL) | Debe coincidir |
|----------------|-------------------------------------------|----------------|
| `CLOUDRESTORE_API_TOKEN` | `panel.api_token` | **Sí** — mismo valor en todas las instalaciones |
| `ENCRYPTION_KEY` | — | Solo panel (cifra credenciales de `restore_targets`) |
| URL del panel (ej. `https://panel:3000`) | `panel.api_url` | **Sí** — base URL sin barra final |
| — | `panel.instance_key` | Nombre de **este** servidor en el panel (`restore_targets.name`) |
Generar token:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
Generar clave de cifrado (panel):
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```
`BACKUP_PATH` del panel es independiente: lista respaldos en el dashboard. Cada CloudRestoreAS reporta su carpeta local vía `instance-config` (clave = nombre del servidor).
---
## Enrutamiento automático (todos los CRA)
No hay modos que configurar. **Todo CRA con panel** hace lo mismo:
1. Llega un ZIP a la carpeta de entrada.
2. `GET resolve-route?filename=X&instance=<esta instancia>` — el panel identifica el nodo y el servidor asignado.
3. Si `action=restore_local` → extract + RESTORE aquí (credenciales SQL de este servidor en el panel).
4. Si `action=forward` → SFTP del ZIP al `input_folder` del destino (credenciales SSH del destino).
5. Mover ZIP a `processed`; reportar `completed` o `forwarded`.
```mermaid
flowchart TB
ZIP[ZIP en carpeta local]
CRA[Any CRA con instance_key]
Panel["resolve-route"]
SFTP[SFTP al destino]
SQL[RESTORE local]
ZIP --> CRA --> Panel
Panel -->|restore_local| SQL
Panel -->|forward| SFTP
```
Aplica igual en Alfa, Omega o un hub donde llegan todos los ZIP:
| Situación | Qué hace el CRA |
|-----------|-----------------|
| Nodo asignado a **esta** instancia | RESTORE local |
| Nodo asignado a **otro** servidor | SFTP ZIP al destino |
La decisión la toma el **panel** (nodo + asignación en Gestión BD), no el operador.
### Qué configura cada instalación
| Campo | Para qué |
|-------|----------|
| URL + token del panel | Conectar al panel |
| **Instancia** | Identidad de este servidor (`restore_targets.name`) |
| Carpeta entrada | Dónde vigila ZIPs esta máquina |
El selector de instancia se llena desde `GET /api/restore/target-catalog`. No hay límite de servidores.
### Hub donde llegan todos los ZIP
Ejemplo: máquina **Alfa** recibe todos los archivos y también tiene bases propias:
1. Panel: servidor Alfa registrado; bases de Alfa/Omega/Gamma asignadas en Gestión BD.
2. CRA en Alfa: instancia **Alfa**, misma URL/token que el resto.
3. ZIP de base Alfa → RESTORE local.
4. ZIP de base Omega → SFTP a carpeta de Omega (card **Reportada**).
5. CRA Omega detecta el ZIP y restaura localmente.
No hay paso extra ni modo especial.
### Sin panel (legacy local)
Si `panel.api_url` está vacío, CloudRestoreAS usa nodos SQLite locales y SQL de la pestaña Config (sin reparto automático).
### Config legacy `panel.mode`
Valores antiguos (`orchestrator`, `hub_restore`, `colocated`) se **ignoran**. Se registra un aviso en log y se usa siempre `resolve-route`. El modo hub que subía `.bak` por SFTP ya no aplica.
---
## Contrato API (`/api/restore/*`)
Autenticación: `Authorization: Bearer <CLOUDRESTORE_API_TOKEN>`.
### GET `/api/restore/target-catalog`
**Cliente:** `panel_client.list_restore_target_names()`
| Respuesta 200 | Descripción |
|---------------|-------------|
| `targets[]` | `{ "id", "name" }` — sin credenciales |
### GET `/api/restore/resolve-route?filename=<zip>&instance=<nombre>`
**Cliente:** `panel_client.resolve_route()`
| Query | Obligatorio | Descripción |
|-------|-------------|-------------|
| `filename` | Sí | Nombre del ZIP (panel resuelve nodo desde el stem) |
| `instance` | Sí | Instancia de este CRA (`restore_targets.name`) |
| Respuesta 200 | Descripción |
|---------------|-------------|
| `action` | `restore_local` o `forward` |
| `db_name` | Base resuelta |
| `node_key` | Nodo en panel |
| `target` | Servidor destino (SQL/SSH + `input_folder`) |
| Código | Significado |
|--------|-------------|
| 404 | Sin nodo/base o sin servidor asignado |
| 503 | Destino sin `input_folder` reportado |
### GET `/api/restore/target-for?database=<db_name>&instance=<nombre>`
Usado por utilidades legacy; el flujo principal de jobs usa `resolve-route`.
### POST `/api/restore/job-result`
`status`: `completed` | `failed` | `forwarded`.
### POST `/api/restore/instance-config`
Reporte de carpeta de entrada e identidad del agente (`instance_key` = nombre del servidor).
Es *best-effort*: un fallo aquí nunca bloquea una restauración.
```jsonc
{
"input_folder": "D:\\Backups\\Entrada",
"processed_folder": "D:\\Backups\\Procesados", // opcional; el panel la deriva si falta
"host_name": "WIN-RESTORE-01",
"app_version": "1.1.0", // versión instalada → cloudrestore_status.app_version
"platform": "windows", // "windows" | "linux"
"arch": "x86_64", // "x86_64" | "arm64"
"instance_key": "Alfa" // = restore_targets.name
}
```
`platform` y `arch` le dicen al panel **qué artefacto le corresponde a este servidor** al
instalar o actualizar: `a24c.cras_releases` se llavea por `version + platform + arch`. Un
agente viejo que no las mande sigue funcionando; el panel cae al texto libre de
`restore_targets.os` para la primera instalación.
Respuesta: `200 { "ok": true, "trace_id": "…" }`.
### POST `/api/restore/agent-sync`
Dispara la sincronización del catálogo de versiones contra Gitea. Lo usa
`publish-release.sh --notify-panel` para que una versión recién publicada aparezca de
inmediato, sin esperar a que un admin abra `/versiones-cras`.
Body vacío (`{}`). Respuesta: `200 { "ok": true, "discovered": N, "versions": N }`.
---
## Distribución de versiones (Gitea → PANEL → servidor)
Los binarios se publican en el registro de paquetes genéricos de Gitea; el panel los
descubre leyendo su API, los cachea verificando el `sha256` que Gitea calcula, e instala
por SSH/SFTP en el servidor destino.
```
build local → Gitea (generic packages) → PANEL (caché + instalador SSH) → servidor
```
- **Publicar:** ver [BUILD.md](BUILD.md) §5 (`publish-release.sh`).
- **Instalar/actualizar:** panel → **Versiones CRAS** (`/versiones-cras`) → *Sincronizar con
Gitea* → *Activar**Instalar* en el servidor.
- El panel siembra `config/.env` con `api_url`, `api_token` e `instance_key` durante la
instalación, así que el servidor queda operativo sin configuración manual.
- El servidor destino **no descarga nada de internet**: el binario es autocontenido y los
bytes llegan del panel por SFTP.
Tablas involucradas: `a24c.cras_releases` (catálogo de versiones publicadas) y
`a24c.cras_install_runs` (bitácora de instalaciones con progreso paso a paso).
---
## Agregar servidores adicionales
1. Panel → **Servidores de Restauración → + Nuevo servidor** (incluye credenciales SSH)
2. **Gestión de Bases de Datos** → dropdown servidor por base
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**).
---
## Despliegue inicial
### Orden de arranque (migraciones automáticas)
1. **a24c-postgres** — volumen Postgres.
2. **a24c-backend** — aplica `alembic upgrade head` al iniciar (incluye tablas CRA:
`restore_targets`, `restore_job_logs`, `cloudrestore_status`, `restore_target_id`).
3. **Panel** (`docker compose up`) — espera el esquema CRA en Postgres antes de abrir el puerto 3000.
No hace falta ejecutar `database/migrations/*.sql` a mano: la fuente de verdad es Alembic en **a24c**.
### Panel
```bash
cd ~/dev/PANEL_BASES_ANEXO24
cp .env.example .env
docker compose up -d
```
Servidores de restauración + asignación de bases en Gestión BD.
### Cada Windows con CloudRestoreAS
| Config | Valor |
|--------|--------|
| URL PANEL | misma en todos |
| API Token | mismo en todos |
| **Instancia** | nombre de **este** servidor en panel |
| Carpeta Entrada | local de esta máquina |
### Verificaciones
| Paso | Qué comprobar |
|------|----------------|
| Cards en panel | **Reportada** con carpeta |
| ZIP propio | RESTORE local |
| ZIP ajeno en esta carpeta | SFTP al destino; bitácora `forwarded` |
| Destino recibe ZIP | RESTORE local allí |
### Troubleshooting
- **Sin reportar**: CRA destino no guardó config o no alcanza el panel.
- **503 en forward**: destino sin `input_folder` reportado.
- **Job diferido**: panel caído, sin `instance_key`, o base sin asignación.
- **401**: token distinto entre panel y CRA.
---
## Prueba local
Un CRA con `instance_key` igual al nombre en panel. El panel muestra una card por servidor; las no reportadas aparecen como **Sin reportar**.

View File

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

View File

@@ -13,13 +13,29 @@ 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
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.
**Prueba local en tu PC** (carpetas + config automática):
```powershell
.\scripts\prepare_local_test.ps1
.\venv\Scripts\python.exe runner.py
```
Copia el ZIP a `C:\CloudRestore\Entrada` (ver `LEEME.txt` en esa carpeta).
### 3. Configuración Básica (en la aplicación) ### 3. Configuración Básica (en la aplicación)
1. **Tab Configuración**: 1. **Tab Configuración**:
@@ -113,10 +129,10 @@ C:\CloudRestore\
## Para Ejecutar 24/7 ## Para Ejecutar 24/7
1. Inicia la aplicación 1. Inicia la aplicación (el motor arranca solo si `CLOUDRESTORE_AUTO_START=true`)
2. Menú Motor → Iniciar 2. Cierra la ventana con **X** — se minimiza a la bandeja del sistema
3. Minimiza (se va a la bandeja del sistema) 3. La app continuará monitoreando en segundo plano
4. La app continuará monitoreando en segundo plano 4. Para salir por completo: bandeja → **Salir** o menú **Archivo → Salir**
--- ---

View File

@@ -24,6 +24,9 @@ CloudRestoreAS es una aplicación Windows de escritorio desarrollada en Python 3
### Sistema Operativo ### Sistema Operativo
- Windows 10/11 - Windows 10/11
- Windows Server 2019/2022 o superior - Windows Server 2019/2022 o superior
- Linux x86-64 (servidor o escritorio). En servidor **headless** (sin pantalla) corre
automáticamente en modo Qt `offscreen`. El binario Linux es **autocontenido**: no
requiere instalar Python, 7-Zip, driver ODBC ni librerías Qt en el destino.
### Software Requerido ### Software Requerido
@@ -73,19 +76,49 @@ CloudRestoreAS es una aplicación Windows de escritorio desarrollada en Python 3
python runner.py python runner.py
``` ```
### Opción 2: Ejecutable Empaquetado ### Opción 2: Ejecutable portable (recomendado para producción)
1. **Generar el ejecutable** **Todo de una vez (desde WSL)** — genera Windows + Linux + los paquetes de release:
```powershell ```bash
.\build.ps1 ./build-all.sh # ambos + dist/release/*.tar.gz y *.zip
``` ./build-all.sh --linux-only # solo Linux (Docker)
./build-all.sh --windows-only # solo Windows (PowerShell + build.ps1)
./build-all.sh --clean # rebuild desde cero
```
Requiere Docker (para Linux) y, para Windows, `powershell.exe` accesible desde WSL con Python 3.11+ instalado en Windows.
Esto creará `CloudRestoreAS.exe` en la carpeta `dist\` **Solo Windows** — generar: `.\build.ps1` → `dist\CloudRestoreAS.exe`
**Solo Linux** — generar: `./build.sh` (o `packaging/scripts/docker-build-linux.sh` para el entorno controlado) → `dist/CloudRestoreAS`
2. **Distribuir** > 📦 Referencia completa de compilación, empaquetado, instalación (`install.sh`/systemd),
- Copiar `CloudRestoreAS.exe` a la ubicación deseada > flags del binario y verificación: **[BUILD.md](BUILD.md)**.
- Crear carpetas `data` y `logs` en el mismo directorio
- El ejecutable es portable (no requiere instalación) > El build de Linux embebe dentro del binario, además de la app: las libs de sistema
> de Qt (cluster xcb/X11 + EGL), el driver **ODBC 18** con sus dependencias
> (unixODBC, Kerberos/GSSAPI, OpenSSL) y el **7-Zip** (`7zz`). Por eso el destino no
> necesita `apt install` de nada. (El build se hace en un entorno con esas libs; ver
> `packaging/scripts/download-bundled-deps.sh` y `docker-build-linux.sh`.)
**Desplegar en cada equipo** (sin Python, sin instalador, sin dependencias de sistema):
1. Copiar **solo** el ejecutable a una carpeta.
2. Ejecutarlo (doble clic o `./CloudRestoreAS`).
3. Se crean automáticamente: `config/`, `Entrada/`, `Procesados/`, `Fallados/`, `Temp/`.
4. Editar `config/.env` (URL, token e instancia del panel).
5. Reiniciar la aplicación.
**Despliegue en servidor Linux (headless / SQL Server sobre Linux):**
```bash
sudo ./install.sh --service # servicio systemd 24/7 (offscreen, motor auto-inicio)
# o manual:
QT_QPA_PLATFORM=offscreen ./CloudRestoreAS --start-engine --headless
```
`install.sh` **no instala ni descarga nada** del sistema: solo coloca el binario, hace
el bootstrap de `config/` y registra el arranque (systemd headless o autostart de
escritorio con `--desktop`). El `data_folder` del destino viene del PANEL; con SQL
Server sobre Linux serán rutas POSIX (p. ej. `/var/opt/mssql/data`) y el `RESTORE`
las adapta automáticamente.
Ver `packaging/LEEME.txt` para instrucciones resumidas.
## ⚙️ Configuración Inicial ## ⚙️ Configuración Inicial

View File

@@ -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.2"
__author__ = "Aduanasoft" __author__ = "Aduanasoft"

36
app/config/__init__.py Normal file
View File

@@ -0,0 +1,36 @@
"""Configuración de entorno, bootstrap y arranque."""
from .bootstrap import ensure_runtime_layout
from .env_loader import (
apply_env_overrides,
get_launch_options,
is_panel_configured,
load_env_file,
)
from .odbc_setup import configure_odbc_environment
from .autostart import register_autostart_if_requested
def initialize_runtime() -> None:
"""Prepara directorios, ODBC y auto-arranque (llamar antes de importar pyodbc)."""
ensure_runtime_layout()
configure_odbc_environment()
def initialize_env() -> None:
"""Post-bootstrap: carga .env y registra auto-arranque si aplica."""
load_env_file()
register_autostart_if_requested()
__all__ = [
"initialize_runtime",
"initialize_env",
"ensure_runtime_layout",
"load_env_file",
"apply_env_overrides",
"get_launch_options",
"is_panel_configured",
"configure_odbc_environment",
"register_autostart_if_requested",
]

88
app/config/autostart.py Normal file
View File

@@ -0,0 +1,88 @@
"""Registro de arranque automático al login (Windows / Linux)."""
import os
import subprocess
import sys
from pathlib import Path
from ..constants import APP_DIR, CONFIG_DIR, IS_WINDOWS
from .env_loader import _env_bool, load_env_file
def _marker_path() -> Path:
return CONFIG_DIR / ".autostart_registered"
def _executable_command() -> tuple[str, list[str]]:
if getattr(sys, "frozen", False):
exe = Path(sys.executable).resolve()
return str(exe), ["--start-engine"]
runner = (APP_DIR / "runner.py").resolve()
return sys.executable, [str(runner), "--start-engine"]
def _register_windows(exe: str, args: list[str]) -> None:
task_name = "CloudRestoreAS"
arg_str = " ".join(f'"{a}"' if " " in a else a for a in args)
tr = f'"{exe}" {arg_str}'
check = subprocess.run(
["schtasks", "/Query", "/TN", task_name],
capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
)
if check.returncode == 0:
return
subprocess.run(
[
"schtasks",
"/Create",
"/TN",
task_name,
"/TR",
tr,
"/SC",
"ONLOGON",
"/RL",
"LIMITED",
"/F",
],
check=False,
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
)
def _register_linux(exe: str, args: list[str]) -> None:
autostart_dir = Path.home() / ".config" / "autostart"
autostart_dir.mkdir(parents=True, exist_ok=True)
desktop = autostart_dir / "cloudrestoreas.desktop"
if desktop.exists():
return
exec_line = " ".join([exe, *args])
content = f"""[Desktop Entry]
Type=Application
Name=CloudRestoreAS
Comment=Restauración automática SQL Server
Exec={exec_line}
Path={APP_DIR}
Terminal=false
X-GNOME-Autostart-enabled=true
"""
desktop.write_text(content, encoding="utf-8")
def register_autostart_if_requested() -> None:
load_env_file()
if not _env_bool("CLOUDRESTORE_REGISTER_AUTOSTART", True):
return
if _marker_path().exists():
return
exe, args = _executable_command()
try:
if IS_WINDOWS:
_register_windows(exe, args)
else:
_register_linux(exe, args)
_marker_path().write_text("ok", encoding="utf-8")
except OSError:
pass

208
app/config/bootstrap.py Normal file
View File

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

200
app/config/env_loader.py Normal file
View File

@@ -0,0 +1,200 @@
"""Carga de variables desde config/.env."""
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
from ..constants import (
APP_DIR,
DIR_ENTRADA,
DIR_FALLADOS,
DIR_PROCESADOS,
DIR_TEMP,
ENV_PATH,
default_seven_zip_path,
)
from ..utils.logger import app_logger
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name, "").strip().lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
def _env_int(
name: str,
default: int,
*,
min_value: int | None = None,
max_value: int | None = None,
) -> int:
"""Lee un entero de entorno con validación; ante valor inválido loguea y usa el default."""
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
app_logger.warning(f"{name}='{raw}' no es un entero válido; se usa {default}")
return default
if min_value is not None and value < min_value:
app_logger.warning(f"{name}={value} < {min_value} (mínimo); se usa {default}")
return default
if max_value is not None and value > max_value:
app_logger.warning(f"{name}={value} > {max_value} (máximo); se usa {default}")
return default
return value
def load_env_file() -> bool:
"""Carga config/.env si existe."""
if ENV_PATH.is_file():
load_dotenv(ENV_PATH, override=True)
return True
return False
def is_panel_configured() -> bool:
"""True si URL, token e instancia del panel están definidos."""
url = os.getenv("CLOUDRESTORE_PANEL_API_URL", "").strip()
token = os.getenv("CLOUDRESTORE_PANEL_API_TOKEN", "").strip()
instance = os.getenv("CLOUDRESTORE_PANEL_INSTANCE_KEY", "").strip()
return bool(url and token and instance)
@dataclass
class LaunchOptions:
minimized: bool
start_engine: bool
register_autostart: bool
def get_launch_options() -> LaunchOptions:
load_env_file()
return LaunchOptions(
minimized=_env_bool("CLOUDRESTORE_START_MINIMIZED", False),
start_engine=_env_bool("CLOUDRESTORE_AUTO_START", True),
register_autostart=_env_bool("CLOUDRESTORE_REGISTER_AUTOSTART", True),
)
def _path_from_env(name: str, default: Path) -> str:
value = os.getenv(name, "").strip()
return value if value else str(default)
def apply_env_overrides(config: dict) -> dict:
"""Aplica variables de entorno sobre la configuración en memoria."""
load_env_file()
paths = config.setdefault("paths", {})
paths["input_folder"] = _path_from_env("CLOUDRESTORE_INPUT_FOLDER", DIR_ENTRADA)
paths["processed_folder"] = _path_from_env(
"CLOUDRESTORE_PROCESSED_FOLDER", DIR_PROCESADOS
)
paths["failed_folder"] = _path_from_env("CLOUDRESTORE_FAILED_FOLDER", DIR_FALLADOS)
paths["extract_folder"] = _path_from_env("CLOUDRESTORE_EXTRACT_FOLDER", DIR_TEMP)
data_sql = os.getenv("CLOUDRESTORE_DATA_SQL_FOLDER", "").strip()
if data_sql:
paths["data_sql_folder"] = data_sql
seven_zip = os.getenv("CLOUDRESTORE_SEVEN_ZIP_EXE", "").strip()
paths["seven_zip_exe"] = seven_zip or default_seven_zip_path()
panel = config.setdefault("panel", {})
api_url = os.getenv("CLOUDRESTORE_PANEL_API_URL", "").strip()
if api_url:
panel["api_url"] = api_url
api_token = os.getenv("CLOUDRESTORE_PANEL_API_TOKEN", "").strip()
if api_token:
panel["api_token"] = api_token
instance = os.getenv("CLOUDRESTORE_PANEL_INSTANCE_KEY", "").strip()
if instance:
panel["instance_key"] = instance
if os.getenv("CLOUDRESTORE_PANEL_VERIFY_SSL"):
panel["verify_ssl"] = _env_bool("CLOUDRESTORE_PANEL_VERIFY_SSL", False)
sql = config.setdefault("sql", {})
server = os.getenv("CLOUDRESTORE_SQL_SERVER", "").strip()
if server:
sql["server"] = server
if os.getenv("CLOUDRESTORE_SQL_USERNAME"):
sql["username"] = os.getenv("CLOUDRESTORE_SQL_USERNAME", "").strip()
if os.getenv("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH"):
sql["use_windows_auth"] = _env_bool("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH", False)
retention = config.setdefault("retention", {})
if os.getenv("CLOUDRESTORE_RETENTION_ENABLED"):
retention["enabled"] = _env_bool("CLOUDRESTORE_RETENTION_ENABLED", True)
if os.getenv("CLOUDRESTORE_RETENTION_DAYS"):
retention["days"] = _env_int(
"CLOUDRESTORE_RETENTION_DAYS", retention.get("days", 2), min_value=0
)
if os.getenv("CLOUDRESTORE_RETENTION_FAILED_DAYS"):
retention["failed_days"] = _env_int(
"CLOUDRESTORE_RETENTION_FAILED_DAYS", retention.get("failed_days", 7), min_value=0
)
if os.getenv("CLOUDRESTORE_RETENTION_RUN_AT_HOUR"):
retention["run_at_hour"] = _env_int(
"CLOUDRESTORE_RETENTION_RUN_AT_HOUR",
retention.get("run_at_hour", 3),
min_value=0,
max_value=23,
)
if os.getenv("CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS"):
retention["check_interval_seconds"] = _env_int(
"CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS",
retention.get("check_interval_seconds", 3600),
min_value=60,
)
if os.getenv("CLOUDRESTORE_RETENTION_DRY_RUN"):
retention["dry_run"] = _env_bool("CLOUDRESTORE_RETENTION_DRY_RUN", True)
return config
def render_env_template(app_dir: Path | None = None) -> str:
"""Genera contenido inicial de config/.env."""
base = (app_dir or APP_DIR).resolve()
sep = "\\" if os.name == "nt" else "/"
def p(*parts: str) -> str:
return sep.join([str(base), *parts])
return f"""# CloudRestoreAS — configuración local (editar y reiniciar la app)
# Arranca el motor al abrir. La ventana SIEMPRE se muestra al iniciar; cerrar (X) la
# minimiza a la bandeja del sistema (START_MINIMIZED ya no oculta la ventana).
CLOUDRESTORE_AUTO_START=true
CLOUDRESTORE_START_MINIMIZED=false
CLOUDRESTORE_REGISTER_AUTOSTART=true
# Servicio PANEL_BASES_ANEXO24 (enrutamiento automático de nodos)
CLOUDRESTORE_PANEL_API_URL=
CLOUDRESTORE_PANEL_API_TOKEN=
CLOUDRESTORE_PANEL_INSTANCE_KEY=
CLOUDRESTORE_PANEL_VERIFY_SSL=false
# Carpetas de trabajo
CLOUDRESTORE_INPUT_FOLDER={p("Entrada")}
CLOUDRESTORE_PROCESSED_FOLDER={p("Procesados")}
CLOUDRESTORE_FAILED_FOLDER={p("Fallados")}
CLOUDRESTORE_EXTRACT_FOLDER={p("Temp")}
# SQL local (solo si no usa panel para credenciales)
# CLOUDRESTORE_SQL_SERVER=localhost
# CLOUDRESTORE_SQL_USERNAME=
# CLOUDRESTORE_DATA_SQL_FOLDER=
# Retención (limpieza diaria de respaldos obsoletos para no saturar el disco)
# CLOUDRESTORE_RETENTION_ENABLED=true
# CLOUDRESTORE_RETENTION_DAYS=2 # Procesados: por nodo, respecto al más reciente
# CLOUDRESTORE_RETENTION_FAILED_DAYS=7 # Fallados: por antigüedad absoluta
# CLOUDRESTORE_RETENTION_RUN_AT_HOUR=3 # hora local de la corrida diaria (0-23)
# CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS=3600
# CLOUDRESTORE_RETENTION_DRY_RUN=true # true = solo simula; poner false para borrar
"""

84
app/config/odbc_setup.py Normal file
View File

@@ -0,0 +1,84 @@
"""Configura ODBC portable desde config/odbc/."""
import os
import sys
from pathlib import Path
from ..constants import IS_WINDOWS, ODBC_DIR
from ..utils.logger import app_logger
def _write_portable_odbc_ini(odbc_dir: Path) -> None:
"""Genera odbcinst.ini mínimo si no existe."""
inst = odbc_dir / "odbcinst.ini"
if inst.exists():
return
if IS_WINDOWS:
driver_path = odbc_dir / "msodbcsql18.dll"
if not driver_path.exists():
for candidate in odbc_dir.glob("msodbcsql*.dll"):
driver_path = candidate
break
driver_line = str(driver_path.resolve()) if driver_path.exists() else "msodbcsql18.dll"
content = f"""[ODBC Driver 18 for SQL Server]
Description=Microsoft ODBC Driver 18 for SQL Server
Driver={driver_line}
UsageCount=1
"""
else:
# Los .so embebidos viven en config/odbc/lib (no en la raíz). Buscar ahí
# primero y usar SIEMPRE la ruta absoluta del archivo versionado real
# (p. ej. libmsodbcsql-18.5.so.1.1); un nombre suelto como
# "libmsodbcsql-18.so" no resuelve porque ese symlink apunta a /opt.
lib_dir = odbc_dir / "lib"
search_dirs = [d for d in (lib_dir, odbc_dir) if d.is_dir()]
so_path = None
for base in search_dirs:
# Preferir el archivo real versionado sobre symlinks (que pueden estar rotos).
candidates = sorted(
(p for p in base.glob("libmsodbcsql-18*.so*") if p.is_file() and not p.is_symlink()),
key=lambda p: len(p.name),
reverse=True,
)
if not candidates:
candidates = [p for p in base.glob("libmsodbcsql-18*.so*") if p.exists()]
if candidates:
so_path = candidates[0]
break
driver_line = str(so_path.resolve()) if so_path else "libmsodbcsql-18.so"
content = f"""[ODBC Driver 18 for SQL Server]
Description=Microsoft ODBC Driver 18 for SQL Server
Driver={driver_line}
UsageCount=1
"""
inst.write_text(content, encoding="utf-8")
ini = odbc_dir / "odbc.ini"
if not ini.exists():
ini.write_text("[ODBC Data Sources]\n", encoding="utf-8")
def configure_odbc_environment() -> None:
"""Apunta pyodbc al driver embebido en config/odbc si está disponible."""
if not ODBC_DIR.is_dir():
return
lib_dir = ODBC_DIR / "lib"
if lib_dir.is_dir():
existing = os.environ.get("LD_LIBRARY_PATH", "")
lib_path = str(lib_dir.resolve())
if lib_path not in existing.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{lib_path}:{existing}" if existing else lib_path
)
_write_portable_odbc_ini(ODBC_DIR)
odbc_sys = str(ODBC_DIR.resolve())
os.environ["ODBCSYSINI"] = odbc_sys
os.environ["ODBCINI"] = str((ODBC_DIR / "odbc.ini").resolve())
if IS_WINDOWS:
os.environ["PATH"] = odbc_sys + os.pathsep + os.environ.get("PATH", "")
app_logger.debug(f"ODBC configurado: ODBCSYSINI={odbc_sys}")

View File

@@ -1,13 +1,87 @@
"""Constantes globales de la aplicación.""" """Constantes globales de la aplicación."""
import platform
import sys
from pathlib import Path from pathlib import Path
# Rutas base IS_WINDOWS = sys.platform == "win32"
APP_DIR = Path(__file__).parent.parent
DATA_DIR = APP_DIR / "data" # Plataforma y arquitectura de ESTE build, en el vocabulario que usa el PANEL para
LOGS_DIR = APP_DIR / "logs" # decidir qué artefacto le corresponde a cada servidor (a24c.cras_releases).
# Se reportan en POST /api/restore/instance-config junto con app_version.
APP_PLATFORM = "windows" if IS_WINDOWS else "linux"
# platform.machine() varía por SO para la misma arquitectura ("AMD64" en Windows,
# "x86_64" en Linux); se normaliza a un solo vocabulario.
_ARCH_ALIASES = {
"x86_64": "x86_64",
"amd64": "x86_64",
"x86": "x86",
"i386": "x86",
"i686": "x86",
"aarch64": "arm64",
"arm64": "arm64",
}
def _resolve_arch() -> str:
raw = (platform.machine() or "").strip().lower()
return _ARCH_ALIASES.get(raw, raw or "unknown")
APP_ARCH = _resolve_arch()
def _resolve_app_dir() -> Path:
"""Directorio donde vive el ejecutable (persistente)."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent
def _resolve_bundle_dir() -> Path:
"""Recursos embebidos en el binario PyInstaller onefile."""
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS)
return _resolve_app_dir() / "packaging" / "bundled" / APP_PLATFORM
APP_DIR = _resolve_app_dir()
CONFIG_DIR = APP_DIR / "config"
ENV_PATH = CONFIG_DIR / ".env"
# Sello con la versión que corrió por última vez en esta instalación. El bootstrap lo
# reescribe en cada arranque; el instalador remoto del PANEL lo lee por SFTP para
# verificar el despliegue sin depender de stdout (el .exe se compila con console=False).
VERSION_PATH = CONFIG_DIR / ".version"
# Sello con el hash de packaging/bundled-versions.json del build que desplegó 7zip/odbc.
# Si cambia, el bootstrap re-copia esas carpetas (ver _copy_bundled_tree).
BUNDLED_STAMP_PATH = CONFIG_DIR / ".bundled_deps"
DATA_DIR = CONFIG_DIR / "data"
LOGS_DIR = CONFIG_DIR / "logs"
ODBC_DIR = CONFIG_DIR / "odbc"
SEVEN_ZIP_DIR = CONFIG_DIR / "7zip"
BUNDLE_DIR = _resolve_bundle_dir()
BUNDLED_SOURCE_7ZIP = BUNDLE_DIR / "bundled" / "7zip"
BUNDLED_SOURCE_ODBC = BUNDLE_DIR / "bundled" / "odbc"
def _default_7zip_name() -> str:
if IS_WINDOWS:
return "7z.exe"
return "7zz"
BUNDLED_7ZIP_EXE = SEVEN_ZIP_DIR / _default_7zip_name()
BUNDLED_7ZIP_FALLBACK = SEVEN_ZIP_DIR / ("7za.exe" if IS_WINDOWS else "7zz")
# Carpetas de trabajo junto al ejecutable
DIR_ENTRADA = APP_DIR / "Entrada"
DIR_PROCESADOS = APP_DIR / "Procesados"
DIR_FALLADOS = APP_DIR / "Fallados"
DIR_TEMP = APP_DIR / "Temp"
# Base de datos
DB_PATH = DATA_DIR / "app.db" DB_PATH = DATA_DIR / "app.db"
# Estados de jobs # Estados de jobs
@@ -21,7 +95,7 @@ class JobStatus:
FAILED_RESTART = "failed_restart" FAILED_RESTART = "failed_restart"
CANCELLED = "cancelled" CANCELLED = "cancelled"
# Niveles de log
class LogLevel: class LogLevel:
DEBUG = "DEBUG" DEBUG = "DEBUG"
INFO = "INFO" INFO = "INFO"
@@ -29,7 +103,7 @@ class LogLevel:
ERROR = "ERROR" ERROR = "ERROR"
CRITICAL = "CRITICAL" CRITICAL = "CRITICAL"
# Tipos de paso de job
class StepType: class StepType:
STABILITY_CHECK = "stability_check" STABILITY_CHECK = "stability_check"
NODE_MAPPING = "node_mapping" NODE_MAPPING = "node_mapping"
@@ -39,44 +113,77 @@ class StepType:
FILELIST = "filelist" FILELIST = "filelist"
RESTORE = "restore" RESTORE = "restore"
CLEANUP = "cleanup" CLEANUP = "cleanup"
SFTP_COPY = "sftp_copy"
FORWARD_ZIP = "forward_zip"
# Configuración por defecto
DEFAULT_CONFIG = { def default_seven_zip_path() -> str:
"paths": { for candidate in (BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK):
"input_folder": "", if candidate.exists():
"processed_folder": "", return str(candidate)
"failed_folder": "", return ""
"extract_folder": "",
def default_work_paths() -> dict[str, str]:
return {
"input_folder": str(DIR_ENTRADA),
"processed_folder": str(DIR_PROCESADOS),
"failed_folder": str(DIR_FALLADOS),
"extract_folder": str(DIR_TEMP),
"data_sql_folder": "", "data_sql_folder": "",
"seven_zip_exe": "" "seven_zip_exe": default_seven_zip_path(),
}, }
DEFAULT_CONFIG = {
"paths": default_work_paths(),
"sql": { "sql": {
"server": "localhost", "server": "localhost",
"use_windows_auth": True, "use_windows_auth": IS_WINDOWS,
"username": "", "username": "",
"password_encrypted": "" "password_encrypted": "",
}, },
"concurrency": { "concurrency": {
"extract_workers": 1, "extract_workers": 1,
"restore_workers": 1 "restore_workers": 1,
}, },
"stability": { "stability": {
"check_enabled": True, "check_enabled": True,
"check_interval_seconds": 5, "check_interval_seconds": 5,
"stable_duration_seconds": 10, "stable_duration_seconds": 10,
"use_ready_marker": False "use_ready_marker": False,
}, },
"timeouts": { "timeouts": {
"extract_minutes": 30, "extract_minutes": 30,
"restore_minutes": 60 "restore_minutes": 60,
}, },
"retries": { "retries": {
"max_attempts": 3, "max_attempts": 3,
"retry_delay_seconds": 60 "retry_delay_seconds": 60,
}, },
"features": { "features": {
"dry_run_mode": False, "dry_run_mode": False,
"auto_scan_enabled": True, "auto_scan_enabled": True,
"scan_interval_seconds": 30 "scan_interval_seconds": 30,
} },
"panel": {
"api_url": "",
"api_token": "",
"instance_key": "",
"verify_ssl": False,
},
"retention": {
# Limpieza diaria de respaldos obsoletos para no saturar el disco del servidor.
"enabled": True,
# Procesados/: por nodo, borra los aplicados con finished_at < (ref_del_nodo - days).
"days": 2,
# Fallados/: por antigüedad absoluta, borra los más viejos que (hoy - failed_days).
"failed_days": 7,
# Hora local (0-23) a la que corre la limpieza diaria.
"run_at_hour": 3,
# Cada cuánto despierta el hilo para evaluar si toca correr.
"check_interval_seconds": 3600,
# Arranca en SECO: solo reporta qué borraría. El operador lo desactiva tras validar.
"dry_run": True,
},
} }

View File

@@ -1,11 +1,18 @@
"""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."""
@@ -19,17 +26,27 @@ class DatabaseManager:
""" """
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
@@ -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

View File

@@ -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:
@@ -176,15 +178,119 @@ class JobRepository:
tuple(params) tuple(params)
) )
@staticmethod
def delete(job_id: str):
"""
Elimina un job y su rastro de hash. Se usa para diferir un job cuando no
hay servidor de restauración activo: al borrarlo, el próximo escaneo del
FileWatcher vuelve a detectar el archivo y reintenta (G8 del plan).
"""
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:
"""Obtiene estadísticas de jobs.""" """Obtiene estadísticas de jobs."""

View File

@@ -1,15 +1,23 @@
"""Motor principal de la aplicación.""" """Motor principal de la aplicación."""
import platform
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 ..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 ..constants import JobStatus, DEFAULT_CONFIG from ..config.env_loader import apply_env_overrides, load_env_file
from ..constants import APP_ARCH, APP_DIR, APP_PLATFORM, JobStatus, DEFAULT_CONFIG
from ..panel import panel_client
from ..utils.logger import app_logger from ..utils.logger import app_logger
@@ -35,24 +43,29 @@ 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
# Configuración # Configuración
self._config = self._load_config() self._config = self._load_config()
self._report_instance_config_to_panel()
app_logger.info("RestoreEngine inicializado") app_logger.info("RestoreEngine inicializado")
def _load_config(self) -> dict: def _load_config(self) -> dict:
"""Carga la configuración desde la base de datos.""" """Carga la configuración desde la base de datos y variables de entorno."""
load_env_file()
config = ConfigRepository.get("app_config", DEFAULT_CONFIG.copy()) config = ConfigRepository.get("app_config", DEFAULT_CONFIG.copy())
# Asegurar que tiene todas las claves
for key, value in DEFAULT_CONFIG.items(): for key, value in DEFAULT_CONFIG.items():
if key not in config: if key not in config:
config[key] = value config[key] = value
config = apply_env_overrides(config)
self.signals.config_loaded.emit(config) self.signals.config_loaded.emit(config)
return config return config
@@ -66,10 +79,12 @@ class RestoreEngine(QObject):
ConfigRepository.set("app_config", config) ConfigRepository.set("app_config", config)
self._config = config self._config = config
app_logger.info("Configuración guardada") app_logger.info("Configuración guardada")
self._report_instance_config_to_panel()
# Reconfigurar file watcher si está corriendo # Reconfigurar file watcher y mantenimiento si está corriendo
if self._running: 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."""
@@ -86,6 +101,11 @@ class RestoreEngine(QObject):
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"]
@@ -101,12 +121,38 @@ class RestoreEngine(QObject):
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()
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:
@@ -117,6 +163,9 @@ class RestoreEngine(QObject):
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
@@ -190,12 +239,53 @@ class RestoreEngine(QObject):
return stats return stats
def _report_instance_config_to_panel(self) -> None:
"""Reporta input_folder al PANEL (best-effort, no bloquea el flujo)."""
panel_cfg = self._config.get("panel", {})
api_url = (panel_cfg.get("api_url") or "").strip()
api_token = (panel_cfg.get("api_token") or "").strip()
if not api_url or not api_token:
return
paths = self._config.get("paths") or {}
input_folder = paths.get("input_folder") or ""
processed_folder = paths.get("processed_folder") or ""
try:
host_name = socket.gethostname() or platform.node()
except Exception:
host_name = platform.node()
instance_key = (panel_cfg.get("instance_key") or "").strip() or None
# platform/arch le dicen al PANEL qué artefacto le toca a este servidor cuando
# instala o actualiza (a24c.cras_releases se llavea por version+platform+arch).
panel_client.report_instance_config(
api_url=api_url,
api_token=api_token,
input_folder=input_folder,
processed_folder=processed_folder,
host_name=host_name,
app_version=__version__,
instance_key=instance_key,
platform_name=APP_PLATFORM,
arch=APP_ARCH,
# Dónde vive el ejecutable. El PANEL actualiza en esta ruta; si instalara en la
# default crearía una segunda instalación y dejaría huérfano este config/.env.
install_path=str(APP_DIR),
)
def _validate_config(self) -> bool: 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}")
@@ -248,6 +338,52 @@ class RestoreEngine(QObject):
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):
""" """
Callback cuando un archivo está listo para procesar. Callback cuando un archivo está listo para procesar.
@@ -263,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)

View File

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

View File

@@ -14,9 +14,19 @@ from ..db.event_repository import EventRepository
from ..db.node_repository import NodeRepository from ..db.node_repository import NodeRepository
from ..extract.seven_zip import SevenZipExtractor from ..extract.seven_zip import SevenZipExtractor
from ..sql.sql_manager import SQLServerManager from ..sql.sql_manager import SQLServerManager
from ..panel import panel_client
from ..transfer import sftp_copy
from ..utils.logger import app_logger from ..utils.logger import app_logger
class RestoreDeferred(Exception):
"""
Señala que el job no puede ejecutarse ahora pero NO es un error: no hay
servidor de restauración activo en el PANEL. El job se difiere (se borra para
que el próximo escaneo lo reintente), no se marca como fallido (G8 del plan).
"""
class RestoreWorkerSignals(QObject): class RestoreWorkerSignals(QObject):
"""Señales para comunicación con la UI.""" """Señales para comunicación con la UI."""
job_started = Signal(str) # job_id job_started = Signal(str) # job_id
@@ -50,6 +60,17 @@ class RestoreWorker(QRunnable):
self._extract_dir: Optional[str] = None self._extract_dir: Optional[str] = None
self._bak_path: Optional[str] = None self._bak_path: Optional[str] = None
# Servidor de restauración activo del PANEL (None = modo local sin PANEL).
self._target: Optional[dict] = None
def _panel_configured(self) -> bool:
panel_cfg = self.config.get("panel", {})
return bool((panel_cfg.get("api_url") or "").strip())
def _legacy_panel_mode(self) -> Optional[str]:
"""Detecta mode legacy en config; solo para log de deprecación."""
mode = (self.config.get("panel", {}).get("mode") or "").strip()
return mode if mode in ("orchestrator", "hub_restore", "colocated") else None
def run(self): def run(self):
"""Ejecuta el procesamiento del job.""" """Ejecuta el procesamiento del job."""
@@ -64,8 +85,26 @@ class RestoreWorker(QRunnable):
if not job: if not job:
raise ValueError(f"Job {self.job_id} no encontrado") raise ValueError(f"Job {self.job_id} no encontrado")
# Pipeline de procesamiento if not self._panel_configured():
self._process_node_mapping(job) self._process_node_mapping(job)
job = JobRepository.get(self.job_id)
self._target = None
app_logger.info("PANEL no configurado: usando configuración SQL local")
else:
legacy_mode = self._legacy_panel_mode()
if legacy_mode and legacy_mode != "colocated":
app_logger.warning(
f"panel.mode={legacy_mode} está obsoleto; "
"se usa enrutamiento automático (resolve-route)"
)
route = self._resolve_route(job)
job = JobRepository.get(self.job_id)
if route["action"] == "forward":
self._forward_zip(job, route, start_time)
return
self._target = route["target"]
# Extraer y restaurar localmente.
self._extract_backup(job) self._extract_backup(job)
self._restore_database(job) self._restore_database(job)
self._cleanup(job) self._cleanup(job)
@@ -80,8 +119,19 @@ class RestoreWorker(QRunnable):
app_logger.info( app_logger.info(
f"Job {self.job_id} completado exitosamente en {total_ms}ms" f"Job {self.job_id} completado exitosamente en {total_ms}ms"
) )
self._report_to_panel(job, "completed", duration_ms=total_ms)
self.signals.job_completed.emit(self.job_id, True) self.signals.job_completed.emit(self.job_id, True)
except RestoreDeferred as e:
# No hay servidor activo: borrar el job para que el próximo escaneo
# vuelva a detectar el archivo y reintente. No cuenta como fallo.
app_logger.warning(f"Job {self.job_id} diferido: {e}")
EventRepository.create(
"WARNING", f"Restauración diferida (sin servidor activo): {e}", self.job_id
)
JobRepository.delete(self.job_id)
self.signals.job_completed.emit(self.job_id, False)
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
app_logger.error(f"Error procesando job {self.job_id}: {error_msg}", exc_info=True) app_logger.error(f"Error procesando job {self.job_id}: {error_msg}", exc_info=True)
@@ -95,9 +145,231 @@ 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)
# Mover el ZIP fallido a Fallados/<fecha>/ para diagnóstico y descarga desde el panel.
try:
if job:
self._move_zip_to_failed(job)
except Exception as move_err:
app_logger.warning(f"No se pudo mover ZIP a Fallados: {move_err}")
self._report_to_panel(job, "failed", error_message=error_msg)
self.signals.error_occurred.emit(self.job_id, error_msg) self.signals.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:
"""
Enrutamiento automático vía panel: restore_local o forward según nodo/asignación.
Actualiza db_name del job desde el panel.
"""
panel_cfg = self.config.get("panel", {})
api_url = (panel_cfg.get("api_url") or "").strip()
api_token = (panel_cfg.get("api_token") or "").strip()
instance_key = (panel_cfg.get("instance_key") or "").strip()
if not instance_key:
raise RestoreDeferred(
"Falta instance_key (instancia/servidor) en la configuración del PANEL"
)
route = panel_client.resolve_route(
api_url,
api_token,
job.source_name,
instance_key=instance_key,
)
if not route:
raise RestoreDeferred(
f"El PANEL no pudo resolver la ruta para '{job.source_name}'"
)
node_key = (route.get("node_key") or Path(job.source_name).name).upper()
JobRepository.update_node_and_db(self.job_id, node_key, route["db_name"])
app_logger.info(
f"Ruta resuelta: action={route.get('action')}, db={route.get('db_name')}"
)
return route
def _collect_zip_paths(self, source_path: str) -> list[str]:
"""
Rutas locales del ZIP (incluye todas las partes multipart).
La comparación es insensible a mayúsculas en TODOS los pasos, porque en este dominio
los respaldos llegan como .ZIP con frecuencia. Antes se hacía
`path.stem.split(".zip")[0]`, que con "EMPRESA.ZIP.001" dejaba base_name="EMPRESA.ZIP"
y armaba el glob "EMPRESA.ZIP.zip.*": no encontraba nada y devolvía lista vacía. Como
_move_zip_to_processed y _move_zip_to_failed iteran sobre este resultado, las partes
nunca salían de Entrada y se acumulaban mezcladas con los pendientes.
Tampoco se usa glob(): en Linux distingue mayúsculas, así que un patrón en minúsculas
seguiría sin encontrar las partes en MAYÚSCULAS. Se filtra iterdir() comparando en
minúsculas, que funciona igual en Windows y en Linux.
"""
path = Path(source_path)
if not SevenZipExtractor.is_multipart(str(path)):
return [str(path)]
# "EMPRESA.ZIP.001" -> stem "EMPRESA.ZIP" -> base "EMPRESA" (sin importar la caja).
stem = path.stem
base_name = stem[:-4] if stem.lower().endswith(".zip") else stem
prefix = f"{base_name}.zip.".lower()
parts = sorted(
(item for item in path.parent.iterdir() if item.name.lower().startswith(prefix)),
key=lambda item: item.name.lower(),
)
if parts:
return [str(item) for item in parts]
# Sin partes localizadas se devuelve el archivo original: es preferible mover solo esa
# parte a no mover nada y dejarla atorada en Entrada para siempre.
app_logger.warning(
f"No se localizaron las partes multipart de {path.name}; se usa solo ese archivo"
)
return [str(path)]
def _move_zip_to_processed(self, job):
"""Mueve el ZIP (y partes multipart) a la carpeta processed."""
processed_folder = Path(self.config["paths"]["processed_folder"])
date_folder = processed_folder / datetime.now().strftime("%Y-%m-%d")
date_folder.mkdir(parents=True, exist_ok=True)
for zip_path in self._collect_zip_paths(job.source_path):
src = Path(zip_path)
dest = date_folder / src.name
shutil.move(str(src), str(dest))
app_logger.info(f"Movido: {src.name} -> {dest}")
def _move_zip_to_failed(self, job):
"""Mueve el ZIP (y partes multipart) a Fallados/<fecha>/ para diagnóstico y descarga.
Antes los fallidos quedaban en Entrada (mezclados con pendientes y bloqueando el
pickup por dedup). Al moverlos a Fallados, el panel puede listarlos/descargarlos por
restaurador con la misma lógica relativa que Procesados.
"""
failed_folder = Path(self.config["paths"]["failed_folder"])
date_folder = failed_folder / datetime.now().strftime("%Y-%m-%d")
date_folder.mkdir(parents=True, exist_ok=True)
for zip_path in self._collect_zip_paths(job.source_path):
src = Path(zip_path)
if not src.exists():
continue
dest = date_folder / src.name
shutil.move(str(src), str(dest))
app_logger.info(f"Movido a Fallados: {src.name} -> {dest}")
def _purge_extract_dir(self):
"""Elimina la carpeta temporal de extracción si quedó (best-effort, cualquier salida).
_cleanup() solo corre en éxito; sin esto, los caminos de fallo/diferido dejan
`Temp/<job_id>/` huérfano acumulándose. Se invoca en el `finally` de run().
"""
try:
if self._extract_dir and Path(self._extract_dir).exists():
shutil.rmtree(self._extract_dir, ignore_errors=True)
app_logger.info(f"Temp de extracción purgado: {self._extract_dir}")
except Exception as e:
app_logger.warning(f"No se pudo purgar Temp {self._extract_dir}: {e}")
def _forward_zip(self, job, route: dict, start_time: float):
"""Reenvía el ZIP al input_folder del servidor destino vía SFTP.
La entrega exitosa por SFTP es el punto de no retorno: en cuanto la subida se confirma,
el job se marca COMPLETED/forwarded ANTES de cualquier tarea de limpieza local. Así, un
error POSTERIOR a la entrega (p.ej. mover el ZIP a Procesados) ya no degrada el job a
fallido ni reporta 'failed' al panel — el respaldo sí llegó al destino.
"""
target = route["target"]
self._target = target
remote_folder = target["input_folder"]
zip_paths = self._collect_zip_paths(job.source_path)
step_id = JobStepRepository.create(self.job_id, StepType.FORWARD_ZIP)
try:
uploaded = sftp_copy.upload_zip_parts(zip_paths, target, remote_folder)
JobStepRepository.complete(
step_id,
exit_code=0,
stdout=f"Destino: {target.get('name')} ({len(uploaded)} archivo(s))",
)
except Exception as e:
# Envío parcial: limpia best-effort las partes ya subidas para no dejar una
# restauración a medias en el destino, marca el step fallido y re-lanza.
partial = getattr(e, "uploaded", None)
if partial:
self._cleanup_partial_forward(target, partial)
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
raise
# --- Entrega confirmada: commit del éxito ANTES de cualquier limpieza local ---
total_ms = int((time.time() - start_time) * 1000)
JobRepository.update_timing(self.job_id, total_ms=total_ms)
JobRepository.update_status(self.job_id, JobStatus.COMPLETED)
app_logger.info(
f"Job {self.job_id} reenviado a '{target.get('name')}' en {total_ms}ms"
)
EventRepository.create(
"INFO",
f"ZIP reenviado por SFTP a {target.get('name')}",
self.job_id,
)
self._report_to_panel(job, "forwarded", duration_ms=total_ms)
# Housekeeping best-effort: si el move falla, el job SIGUE siendo forwarded y el ZIP
# queda en Entrada (bloqueado por dedup de hash 'completed', no se reenvía en bucle).
try:
self._move_zip_to_processed(job)
except Exception as e:
app_logger.warning(
f"Reenvío OK pero no se pudo mover el ZIP a Procesados: {e}"
)
EventRepository.create(
"WARNING",
f"Reenvío exitoso; el ZIP quedó en Entrada (no se pudo mover a Procesados): {e}",
self.job_id,
)
self.signals.job_completed.emit(self.job_id, True)
def _cleanup_partial_forward(self, target: dict, uploaded: list) -> None:
"""Borra best-effort del destino las partes ya subidas tras un fallo de reenvío."""
for remote_path in uploaded:
try:
sftp_copy.cleanup_remote(target, remote_path)
except Exception as e:
app_logger.warning(
f"No se pudo limpiar la parte remota {remote_path}: {e}"
)
def _report_to_panel(
self,
job,
status: str,
duration_ms: Optional[int] = None,
error_message: Optional[str] = None,
):
"""Reporta el resultado del job al PANEL (best-effort, solo en modo PANEL)."""
if not self._target:
return
panel_cfg = self.config.get("panel", {})
panel_client.report_job_result(
api_url=(panel_cfg.get("api_url") or "").strip(),
api_token=(panel_cfg.get("api_token") or "").strip(),
filename=job.source_name if job else "",
status=status,
restore_target_id=self._target.get("id"),
db_name=job.db_name if job else None,
duration_ms=duration_ms,
error_message=error_message,
)
def _process_node_mapping(self, job): def _process_node_mapping(self, job):
"""Procesa el mapeo de nodo a base de datos.""" """Procesa el mapeo de nodo a base de datos."""
step_id = JobStepRepository.create(self.job_id, StepType.NODE_MAPPING) step_id = JobStepRepository.create(self.job_id, StepType.NODE_MAPPING)
@@ -170,10 +442,16 @@ class RestoreWorker(QRunnable):
stderr=stderr[:1000] if stderr else None stderr=stderr[:1000] if stderr else None
) )
# Buscar archivo .bak # Buscar archivo .bak. El backup puede venir con terminaciones extra
# (p.ej. ".KNOWNWORLD") tanto en el ZIP como en el propio .bak; se
# normaliza a "<nodo>.bak". El nodo es siempre el primer segmento del
# nombre del archivo de origen.
locate_step_id = JobStepRepository.create(self.job_id, StepType.LOCATE_BAK) locate_step_id = JobStepRepository.create(self.job_id, StepType.LOCATE_BAK)
self._bak_path = SevenZipExtractor.find_bak_file(self._extract_dir) node_base = Path(job.source_name).name.split(".")[0].strip() or None
self._bak_path = SevenZipExtractor.find_bak_file(
self._extract_dir, node_name=node_base
)
if not self._bak_path: if not self._bak_path:
raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído") raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído")
@@ -199,16 +477,30 @@ class RestoreWorker(QRunnable):
if not job.db_name: if not job.db_name:
raise ValueError("DB name no está configurado en el job") raise ValueError("DB name no está configurado en el job")
# Servidor y credenciales: del servidor activo del PANEL, o config local.
if self._target:
server = self._target["server"]
use_windows_auth = False
username = self._target["username"]
password = self._target["password"]
data_folder = self._target["data_folder"]
else:
sql_config = self.config["sql"]
server = sql_config["server"]
use_windows_auth = sql_config["use_windows_auth"]
username = sql_config.get("username")
password = sql_config.get("password")
data_folder = self.config["paths"]["data_sql_folder"]
# Conectar a SQL # Conectar a SQL
connect_step_id = JobStepRepository.create(self.job_id, StepType.SQL_CONNECT) connect_step_id = JobStepRepository.create(self.job_id, StepType.SQL_CONNECT)
try: try:
sql_config = self.config["sql"]
sql_manager = SQLServerManager( sql_manager = SQLServerManager(
server=sql_config["server"], server=server,
use_windows_auth=sql_config["use_windows_auth"], use_windows_auth=use_windows_auth,
username=sql_config.get("username"), username=username,
password=sql_config.get("password") password=password
) )
# Test connection # Test connection
@@ -222,13 +514,15 @@ class RestoreWorker(QRunnable):
JobStepRepository.complete(connect_step_id, exit_code=1, error=str(e)) JobStepRepository.complete(connect_step_id, exit_code=1, error=str(e))
raise raise
sql_backup_path = self._bak_path
# Obtener FILELISTONLY # Obtener FILELISTONLY
filelist_step_id = JobStepRepository.create(self.job_id, StepType.FILELIST) filelist_step_id = JobStepRepository.create(self.job_id, StepType.FILELIST)
filelist_start = time.time() filelist_start = time.time()
try: try:
logical_files, error = sql_manager.get_filelist_from_backup( logical_files, error = sql_manager.get_filelist_from_backup(
self._bak_path, sql_backup_path,
timeout_minutes=self.config["timeouts"]["restore_minutes"] timeout_minutes=self.config["timeouts"]["restore_minutes"]
) )
@@ -254,11 +548,9 @@ class RestoreWorker(QRunnable):
restore_start = time.time() restore_start = time.time()
try: try:
data_folder = self.config["paths"]["data_sql_folder"]
success, stdout, error = sql_manager.restore_database( success, stdout, error = sql_manager.restore_database(
db_name=job.db_name, db_name=job.db_name,
backup_path=self._bak_path, backup_path=sql_backup_path,
data_folder=data_folder, data_folder=data_folder,
logical_files=logical_files, logical_files=logical_files,
timeout_minutes=self.config["timeouts"]["restore_minutes"], timeout_minutes=self.config["timeouts"]["restore_minutes"],
@@ -294,27 +586,8 @@ 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)

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

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

View File

@@ -1,9 +1,13 @@
"""Gestión de extracción de archivos con 7-Zip.""" """Gestión de extracción de archivos con 7-Zip."""
import shutil
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
import time import time
from ..constants import BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK, IS_WINDOWS
from ..utils.logger import app_logger from ..utils.logger import app_logger
@@ -25,18 +29,29 @@ class SevenZipExtractor:
@staticmethod @staticmethod
def _auto_detect_7zip() -> Optional[str]: def _auto_detect_7zip() -> Optional[str]:
"""Auto-detecta la ubicación de 7z.exe.""" """Prioriza 7-Zip embebido; en desarrollo busca en el sistema."""
possible_paths = [ for candidate in (BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK):
r"C:\Program Files\7-Zip\7z.exe", if candidate.exists():
r"D:\Program Files\7-Zip\7z.exe", app_logger.info(f"7-Zip embebido: {candidate}")
r"C:\Program Files (x86)\7-Zip\7z.exe", return str(candidate)
r"D:\Program Files (x86)\7-Zip\7z.exe"
]
for path in possible_paths: if not getattr(sys, "frozen", False):
if Path(path).exists(): if IS_WINDOWS:
app_logger.info(f"7-Zip auto-detectado en: {path}") possible_paths = [
return path r"C:\Program Files\7-Zip\7z.exe",
r"C:\Program Files (x86)\7-Zip\7z.exe",
]
else:
possible_paths = []
for name in ("7z", "7zz", "7za"):
found = shutil.which(name)
if found:
possible_paths.append(found)
for path in possible_paths:
if Path(path).exists():
app_logger.info(f"7-Zip detectado en: {path}")
return path
return None return None
@@ -134,35 +149,78 @@ class SevenZipExtractor:
return result.returncode, result.stdout, result.stderr return result.returncode, result.stdout, result.stderr
@staticmethod @staticmethod
def find_bak_file(extract_dir: str) -> Optional[str]: def find_bak_file(extract_dir: str, node_name: Optional[str] = None) -> Optional[str]:
""" """
Busca el archivo .bak extraído en el directorio. Busca el backup extraído y, si se indica node_name, lo normaliza a
'<node_name>.bak'.
Tolera terminaciones extra en el nombre (el backup puede venir como
'nodo.KNOWNWORLD.bak', 'nodo.bak.unknownworld' o incluso sin extensión
'.bak'). Estrategia de búsqueda en orden:
1. archivos que terminan exactamente en '.bak'
2. si no hay, archivos cuyo nombre contiene '.bak' en cualquier parte
3. si no hay, el archivo más grande de la extracción (el backup suele serlo)
Args: Args:
extract_dir: Directorio donde se extrajo extract_dir: Directorio donde se extrajo
node_name: Nombre del nodo; si se indica, el backup se renombra a
'<node_name>.bak'
Returns: Returns:
Ruta al archivo .bak o None si no se encuentra Ruta al archivo .bak (ya renombrado si node_name) o None si no hay archivos.
Raises:
ValueError: Si hay múltiples archivos .bak
""" """
extract_path = Path(extract_dir) extract_path = Path(extract_dir)
bak_files = list(extract_path.rglob("*.bak")) all_files = [p for p in extract_path.rglob("*") if p.is_file()]
if not bak_files: if not all_files:
app_logger.error(f"No se encontró archivo .bak en {extract_dir}") app_logger.error(f"No se encontró ningún archivo en {extract_dir}")
return None return None
if len(bak_files) > 1: bak_files = [p for p in all_files if p.suffix.lower() == ".bak"]
# Si hay múltiples, tomar el más reciente if not bak_files:
bak_files = [p for p in all_files if ".bak" in p.name.lower()]
if not bak_files:
largest = max(all_files, key=lambda p: p.stat().st_size)
app_logger.warning( app_logger.warning(
f"Múltiples archivos .bak encontrados ({len(bak_files)}), " "No se encontró archivo con extensión .bak; usando el archivo más "
"seleccionando el más reciente" f"grande de la extracción: {largest.name}"
) )
bak_files.sort(key=lambda p: p.stat().st_mtime, reverse=True) bak_files = [largest]
bak_path = str(bak_files[0]) if len(bak_files) > 1:
# Preferir el que empiece con el nombre del nodo; si no, el más reciente.
if node_name:
prefixed = [
p for p in bak_files
if p.name.lower().startswith(node_name.lower())
]
if prefixed:
bak_files = prefixed
if len(bak_files) > 1:
app_logger.warning(
f"Múltiples backups encontrados ({len(bak_files)}), "
"seleccionando el más reciente"
)
bak_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
bak_file = bak_files[0]
# Normalizar el nombre a '<node_name>.bak'.
if node_name:
target = bak_file.parent / f"{node_name}.bak"
if bak_file.name != target.name:
try:
if target.exists() and not bak_file.samefile(target):
target.unlink()
bak_file = bak_file.rename(target)
app_logger.info(f"Backup normalizado a: {bak_file.name}")
except OSError as e:
# No es fatal para el RESTORE: se continúa con el nombre original.
app_logger.error(
f"No se pudo renombrar el backup a '{target.name}': {e}"
)
bak_path = str(bak_file)
app_logger.info(f"Archivo .bak encontrado: {bak_path}") app_logger.info(f"Archivo .bak encontrado: {bak_path}")
return bak_path return bak_path

1
app/panel/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Integración con PANEL_BASES_ANEXO24 (servidor de restauración activo)."""

444
app/panel/panel_client.py Normal file
View File

@@ -0,0 +1,444 @@
"""
Cliente HTTP para PANEL_BASES_ANEXO24.
Cada base de datos está asignada en el PANEL a un servidor de restauración
(registrado en restore_targets). CloudRestoreAS, tras resolver el db_name del archivo, pregunta
al PANEL (polling) qué servidor corresponde a ESA base y restaura ahí; al terminar,
reporta el resultado del job.
Política ante fallo (G8/G9): si el PANEL no responde o la base no tiene servidor
asignado, get_target_for_database devuelve None y el worker deja el job en cola para
reintentar; nunca se restaura con datos inciertos.
"""
import os
from typing import Optional
from urllib.parse import urlparse, quote
import requests
from ..utils.logger import app_logger
# Timeout (connect, read) en segundos para las llamadas al PANEL.
DEFAULT_TIMEOUT = (5, 10)
def _http_verify(verify_ssl: Optional[bool] = None) -> bool:
"""Verificación TLS para requests. Por defecto False (panel dev con cert propio)."""
if verify_ssl is not None:
return verify_ssl
env = os.getenv("CLOUDRESTORE_PANEL_VERIFY_SSL", "").strip().lower()
if env in ("1", "true", "yes"):
return True
if env in ("0", "false", "no"):
return False
return False
# Campos obligatorios del servidor devuelto por el endpoint (modo hub central + SFTP).
REQUIRED_TARGET_FIELDS = (
"server",
"username",
"password",
"data_folder",
"ssh_host",
"ssh_username",
"ssh_password",
"remote_inbox_path",
)
# Modo colocado (CRA en el mismo servidor): solo credenciales SQL locales.
REQUIRED_COLOCATED_TARGET_FIELDS = (
"name",
"server",
"username",
"password",
"data_folder",
)
# Reenvío de ZIP por SFTP al input_folder del destino.
REQUIRED_FORWARD_TARGET_FIELDS = (
"name",
"ssh_host",
"ssh_username",
"ssh_password",
"input_folder",
)
def _normalize_base_url(api_url: str) -> Optional[str]:
"""Valida y normaliza la URL base del PANEL. Devuelve None si es inválida."""
if not api_url or not api_url.strip():
return None
base = api_url.strip().rstrip("/")
parsed = urlparse(base)
# Solo http/https; evita esquemas peligrosos (file://, etc.)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
app_logger.error(f"URL de PANEL inválida (esquema/host): {api_url}")
return None
return base
def _validate_target(data: dict, *, colocated: bool = False) -> bool:
"""Verifica que el servidor devuelto tenga todos los campos requeridos no vacíos."""
fields = REQUIRED_COLOCATED_TARGET_FIELDS if colocated else REQUIRED_TARGET_FIELDS
for field in fields:
value = data.get(field)
if value is None or (isinstance(value, str) and not value.strip()):
app_logger.error(f"Servidor del PANEL sin campo requerido: '{field}'")
return False
return True
def get_target_for_database(
api_url: str,
api_token: str,
db_name: str,
instance_key: Optional[str] = None,
) -> Optional[dict]:
"""
Consulta GET /api/restore/target-for?database=<db_name>: el servidor de
restauración asignado a esa base de datos.
Returns:
dict con id, name, server, username, password, data_folder, ssh_host,
ssh_port, ssh_username, ssh_password, remote_inbox_path si la base tiene
servidor asignado; None si no tiene asignación, el PANEL no responde o la
config es inválida.
"""
base = _normalize_base_url(api_url)
if base is None:
return None
if not api_token or not api_token.strip():
app_logger.error("Token de PANEL no configurado; no se puede consultar el servidor de la base")
return None
if not db_name or not db_name.strip():
app_logger.error("db_name vacío; no se puede consultar el servidor de la base")
return None
colocated = bool(instance_key and instance_key.strip())
url = f"{base}/api/restore/target-for?database={quote(db_name.strip())}"
if colocated:
url += f"&instance={quote(instance_key.strip())}"
headers = {"Authorization": f"Bearer {api_token.strip()}"}
try:
resp = requests.get(
url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
)
except requests.exceptions.RequestException as e:
app_logger.error(f"PANEL no accesible al consultar servidor de la base '{db_name}': {e}")
return None
if resp.status_code == 404:
app_logger.warning(f"La base '{db_name}' no tiene servidor de restauración asignado en el PANEL")
return None
if resp.status_code == 401:
app_logger.error("PANEL rechazó el token de CloudRestoreAS (401)")
return None
if resp.status_code != 200:
app_logger.error(f"PANEL respondió {resp.status_code} al consultar servidor de la base")
return None
try:
data = resp.json()
except ValueError:
app_logger.error("Respuesta del PANEL no es JSON válido")
return None
if not _validate_target(data, colocated=colocated):
return None
name = data.get("name") or data.get("server")
app_logger.info(f"Servidor para la base '{db_name}': {name} ({data.get('server')})")
return data
def list_restore_target_names(api_url: str, api_token: str) -> list[str]:
"""
Consulta GET /api/restore/target-catalog: nombres de servidores de restauración
registrados en el panel (para llenar el selector de instancia).
Returns:
Lista de nombres; lista vacía si falla la conexión, el token es inválido o la
respuesta no es válida.
"""
base = _normalize_base_url(api_url)
if base is None:
return []
if not api_token or not api_token.strip():
app_logger.error("Token de PANEL no configurado; no se puede consultar el catálogo")
return []
url = f"{base}/api/restore/target-catalog"
headers = {"Authorization": f"Bearer {api_token.strip()}"}
try:
resp = requests.get(
url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
)
except requests.exceptions.RequestException as e:
app_logger.error(f"PANEL no accesible al consultar catálogo de servidores: {e}")
return []
if resp.status_code == 401:
app_logger.error("PANEL rechazó el token de CloudRestoreAS (401) al consultar catálogo")
return []
if resp.status_code != 200:
app_logger.error(f"PANEL respondió {resp.status_code} al consultar catálogo de servidores")
return []
try:
data = resp.json()
except ValueError:
app_logger.error("Respuesta del PANEL (target-catalog) no es JSON válido")
return []
targets = data.get("targets")
if not isinstance(targets, list):
app_logger.error("Respuesta del PANEL (target-catalog) sin lista 'targets'")
return []
names: list[str] = []
for item in targets:
if isinstance(item, dict):
name = item.get("name")
if isinstance(name, str) and name.strip():
names.append(name.strip())
return names
def resolve_route(
api_url: str,
api_token: str,
filename: str,
instance_key: Optional[str] = None,
) -> Optional[dict]:
"""
Consulta GET /api/restore/resolve-route: resuelve nodo/destino desde el nombre
del archivo y devuelve action (restore_local | forward) más target completo.
Returns:
dict con action, db_name, node_key, target; None si el panel no responde,
token inválido o la respuesta no cumple el contrato.
"""
base = _normalize_base_url(api_url)
if base is None:
return None
if not api_token or not api_token.strip():
app_logger.error("Token de PANEL no configurado; no se puede resolver ruta")
return None
if not filename or not filename.strip():
app_logger.error("filename vacío; no se puede resolver ruta")
return None
url = f"{base}/api/restore/resolve-route?filename={quote(filename.strip())}"
key = (instance_key or "").strip()
if key:
url += f"&instance={quote(key)}"
headers = {"Authorization": f"Bearer {api_token.strip()}"}
try:
resp = requests.get(
url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
)
except requests.exceptions.RequestException as e:
app_logger.error(f"PANEL no accesible al resolver ruta para '{filename}': {e}")
return None
if resp.status_code == 404:
app_logger.warning(f"El archivo '{filename}' no tiene nodo/base asignado en el PANEL")
return None
if resp.status_code == 503:
app_logger.warning(
f"Destino aún sin carpeta de entrada reportada para '{filename}'"
)
return None
if resp.status_code == 401:
app_logger.error("PANEL rechazó el token de CloudRestoreAS (401) al resolver ruta")
return None
if resp.status_code != 200:
app_logger.error(f"PANEL respondió {resp.status_code} al resolver ruta")
return None
try:
data = resp.json()
except ValueError:
app_logger.error("Respuesta del PANEL (resolve-route) no es JSON válido")
return None
action = data.get("action")
target = data.get("target")
db_name = data.get("db_name")
if action not in ("restore_local", "forward") or not isinstance(target, dict):
app_logger.error("Respuesta del PANEL (resolve-route) con action o target inválidos")
return None
if not isinstance(db_name, str) or not db_name.strip():
app_logger.error("Respuesta del PANEL (resolve-route) sin db_name válido")
return None
if action == "restore_local":
if not _validate_target(target, colocated=True):
return None
elif not _validate_target_fields(target, REQUIRED_FORWARD_TARGET_FIELDS):
return None
app_logger.info(
f"Ruta para '{filename}': action={action}, destino={target.get('name')}, db={db_name}"
)
return data
def _validate_target_fields(data: dict, fields: tuple) -> bool:
"""Verifica campos requeridos no vacíos (lista explícita)."""
for field in fields:
value = data.get(field)
if value is None or (isinstance(value, str) and not value.strip()):
app_logger.error(f"Servidor del PANEL sin campo requerido para forward: '{field}'")
return False
return True
def report_instance_config(
api_url: str,
api_token: str,
input_folder: str,
host_name: Optional[str] = None,
app_version: Optional[str] = None,
instance_key: Optional[str] = None,
processed_folder: Optional[str] = None,
platform_name: Optional[str] = None,
arch: Optional[str] = None,
install_path: Optional[str] = None,
) -> bool:
"""
Reporta la carpeta de entrada vigente a POST /api/restore/instance-config (best-effort).
El panel solo la muestra; CloudRestoreAS es la única fuente de escritura.
platform_name y arch identifican el build de ESTA instalación ("windows"/"linux",
"x86_64"/"arm64"). El PANEL los usa para saber qué artefacto le toca a este servidor
cuando instala o actualiza; sin ellos cae al texto libre de restore_targets.os.
install_path es la carpeta donde vive el ejecutable (APP_DIR). Es un espacio de rutas
DISTINTO de input_folder: ahí abajo está config/.env con las rutas de trabajo que el
operador haya personalizado. El PANEL la necesita para actualizar en el lugar correcto
en vez de crear una segunda instalación con la configuración por omisión.
Returns:
True si el PANEL aceptó el reporte (200), False en cualquier otro caso.
"""
base = _normalize_base_url(api_url)
if base is None or not api_token or not api_token.strip():
return False
if not input_folder or not input_folder.strip():
app_logger.warning("input_folder vacío; no se reporta al PANEL")
return False
url = f"{base}/api/restore/instance-config"
headers = {"Authorization": f"Bearer {api_token.strip()}"}
key = (instance_key or "").strip() or None
payload = {
"input_folder": input_folder.strip(),
"processed_folder": (processed_folder or "").strip() or None,
"host_name": (host_name or "").strip() or None,
"app_version": (app_version or "").strip() or None,
"platform": (platform_name or "").strip() or None,
"arch": (arch or "").strip() or None,
"install_path": (install_path or "").strip() or None,
}
if key:
payload["instance_key"] = key
try:
resp = requests.post(
url, json=payload, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
)
except requests.exceptions.RequestException as e:
app_logger.error(f"No se pudo reportar la carpeta de entrada al PANEL: {e}")
return False
if resp.status_code == 200:
app_logger.info(f"Carpeta de entrada reportada al PANEL: {input_folder.strip()}")
return True
app_logger.error(
f"PANEL respondió {resp.status_code} al reportar la carpeta de entrada"
)
return False
def report_job_result(
api_url: str,
api_token: str,
filename: str,
status: str,
restore_target_id: Optional[int] = None,
db_name: Optional[str] = None,
duration_ms: Optional[int] = None,
error_message: Optional[str] = None,
) -> bool:
"""
Reporta el resultado de un job a POST /api/restore/job-result (best-effort).
No lanza excepciones: un fallo aquí no debe romper el flujo de restauración.
Returns:
True si el PANEL aceptó el reporte (201), False en cualquier otro caso.
"""
base = _normalize_base_url(api_url)
if base is None or not api_token or not api_token.strip():
return False
url = f"{base}/api/restore/job-result"
headers = {"Authorization": f"Bearer {api_token.strip()}"}
payload = {
"filename": filename,
"restore_target_id": restore_target_id,
"db_name": db_name,
"status": status,
"duration_ms": duration_ms,
"error_message": error_message,
}
try:
resp = requests.post(
url, json=payload, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
)
except requests.exceptions.RequestException as e:
app_logger.error(f"No se pudo reportar el resultado del job al PANEL: {e}")
return False
if resp.status_code == 201:
return True
app_logger.error(f"PANEL respondió {resp.status_code} al reportar el resultado del job")
return False
def test_connection(api_url: str, api_token: str) -> tuple[bool, Optional[str]]:
"""
Prueba conectividad y autenticación contra el PANEL para la UI de configuración.
Consulta /target-for con un nombre de base inexistente: un 404 confirma que el
PANEL responde y el token es válido (la base simplemente no existe).
Returns:
(True, mensaje) si el PANEL responde y el token es aceptado;
(False, "mensaje de error") si no se pudo conectar o el token fue rechazado.
"""
base = _normalize_base_url(api_url)
if base is None:
return False, "URL de PANEL inválida (debe iniciar con http:// o https://)"
if not api_token or not api_token.strip():
return False, "Falta el token de API del PANEL"
url = f"{base}/api/restore/target-for?database={quote('__cloudrestore_ping__')}"
headers = {"Authorization": f"Bearer {api_token.strip()}"}
try:
resp = requests.get(
url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
)
except requests.exceptions.RequestException as e:
return False, f"No se pudo conectar al PANEL: {e}"
# 200 (base de ping existiera) o 404 (no existe) → conexión y token OK.
if resp.status_code in (200, 404):
return True, "Conexión y token correctos"
if resp.status_code == 401:
return False, "Token rechazado por el PANEL (401)"
return False, f"El PANEL respondió con código {resp.status_code}"

View File

@@ -4,8 +4,28 @@ import pyodbc
from typing import Optional, Tuple, List from typing import Optional, Tuple, List
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from ..utils.logger import app_logger from ..utils.logger import app_logger
_ODBC_DRIVER_CANDIDATES = (
"ODBC Driver 18 for SQL Server",
"ODBC Driver 17 for SQL Server",
"ODBC Driver 13 for SQL Server",
"FreeTDS",
)
def resolve_odbc_driver() -> str:
"""Devuelve el primer driver ODBC de SQL Server disponible."""
installed = set(pyodbc.drivers())
for name in _ODBC_DRIVER_CANDIDATES:
if name in installed:
return name
raise RuntimeError(
"No se encontró driver ODBC para SQL Server. "
f"Instalados: {', '.join(pyodbc.drivers()) or 'ninguno'}"
)
@dataclass @dataclass
class LogicalFile: class LogicalFile:
@@ -34,7 +54,8 @@ class SQLServerManager:
username: Usuario SQL (si no usa Windows Auth) username: Usuario SQL (si no usa Windows Auth)
password: Contraseña SQL (si no usa Windows Auth) password: Contraseña SQL (si no usa Windows Auth)
""" """
self.server = server # ODBC usa coma para el puerto (ip,puerto), no dos puntos
self.server = server.replace(":", ",") if ":" in server else server
self.use_windows_auth = use_windows_auth self.use_windows_auth = use_windows_auth
self.username = username self.username = username
self.password = password self.password = password
@@ -49,10 +70,13 @@ class SQLServerManager:
Returns: Returns:
Cadena de conexión ODBC Cadena de conexión ODBC
""" """
driver = resolve_odbc_driver()
parts = [ parts = [
f"DRIVER={{ODBC Driver 17 for SQL Server}}", f"DRIVER={{{driver}}}",
f"SERVER={self.server}" f"SERVER={self.server}",
] ]
if "18" in driver:
parts.append("TrustServerCertificate=yes")
if database: if database:
parts.append(f"DATABASE={database}") parts.append(f"DATABASE={database}")
@@ -65,6 +89,93 @@ class SQLServerManager:
return ";".join(parts) return ";".join(parts)
@staticmethod
def _drain_cursor(cursor) -> None:
"""Consume todos los result sets de comandos largos (RESTORE, etc.)."""
while True:
try:
if cursor.description:
cursor.fetchall()
except Exception:
pass
if not cursor.nextset():
break
def wait_for_database_state(
self,
db_name: str,
target_state: str = "ONLINE",
timeout_seconds: int = 300,
poll_seconds: float = 2.0,
) -> Optional[str]:
"""Espera hasta que la BD alcance target_state o agote el timeout."""
deadline = time.time() + timeout_seconds
last_state: Optional[str] = None
while time.time() < deadline:
last_state = self.get_database_state(db_name)
if last_state == target_state:
return last_state
if last_state is None and target_state == "ONLINE":
# Puede tardar en aparecer en sys.databases al inicio del RESTORE.
pass
time.sleep(poll_seconds)
return last_state
def get_database_state(self, db_name: str) -> Optional[str]:
"""Devuelve state_desc de sys.databases o None si no existe."""
try:
conn_str = self.get_connection_string()
conn = pyodbc.connect(conn_str, timeout=10)
cursor = conn.cursor()
cursor.execute(
"SELECT state_desc FROM sys.databases WHERE name = ?",
(db_name,),
)
row = cursor.fetchone()
conn.close()
return str(row[0]) if row else None
except Exception as e:
app_logger.error(f"Error consultando estado de DB [{db_name}]: {e}")
return None
def _prepare_database_for_restore(self, cursor, db_name: str) -> None:
"""
Limpia una BD atascada en RESTORING u offline antes de un RESTORE nuevo.
"""
state = self.get_database_state(db_name)
if not state:
return
if state == "RESTORING":
app_logger.warning(
f"BD [{db_name}] en RESTORING; intentando WITH RECOVERY..."
)
try:
cursor.execute(f"RESTORE DATABASE [{db_name}] WITH RECOVERY")
self._drain_cursor(cursor)
state = self.get_database_state(db_name)
except Exception as e:
app_logger.warning(f"RECOVERY falló para [{db_name}]: {e}")
if state == "RESTORING":
app_logger.warning(
f"BD [{db_name}] sigue en RESTORING; eliminando con DROP DATABASE..."
)
cursor.execute(f"DROP DATABASE [{db_name}]")
self._drain_cursor(cursor)
return
if state == "ONLINE":
try:
cursor.execute(
f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE"
)
app_logger.info(f"BD [{db_name}] configurada en SINGLE_USER")
except Exception as e:
app_logger.warning(
f"No se pudo configurar SINGLE_USER en [{db_name}]: {e}"
)
def test_connection(self) -> Tuple[bool, Optional[str]]: def test_connection(self) -> Tuple[bool, Optional[str]]:
""" """
Prueba la conexión a SQL Server. Prueba la conexión a SQL Server.
@@ -168,42 +279,25 @@ class SQLServerManager:
Returns: Returns:
Tupla (éxito, stdout, error) Tupla (éxito, stdout, error)
""" """
conn = None
try: try:
# Construir las cláusulas MOVE # Construir las cláusulas MOVE con un destino único por archivo lógico.
move_clauses = [] # Renombrar todos los data files a {db}.mdf colisiona si el backup tiene
for lf in logical_files: # varios archivos; aquí cada archivo recibe un nombre distinto (G4).
if lf.type == 'D': # Data file move_clauses = self._build_move_clauses(db_name, data_folder, logical_files)
new_path = f"{data_folder}\\{db_name}.mdf"
elif lf.type == 'L': # Log file
new_path = f"{data_folder}\\{db_name}_log.ldf"
else:
# Archivos adicionales (filestream, etc.)
continue
move_clauses.append(f"MOVE N'{lf.logical_name}' TO N'{new_path}'")
if not move_clauses: if not move_clauses:
return False, None, "No se pudieron determinar los archivos de datos y log" return False, None, "No se pudieron determinar los archivos del backup"
# Construir el comando RESTORE restore_query = (
restore_cmd = f""" f"RESTORE DATABASE [{db_name}] FROM DISK = N'{backup_path}' "
-- Poner la base de datos en modo single user f"WITH {', '.join(move_clauses)}, REPLACE"
ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; )
-- Restaurar app_logger.info(f"Comando RESTORE generado:\n{restore_query}")
RESTORE DATABASE [{db_name}]
FROM DISK = N'{backup_path}'
WITH {', '.join(move_clauses)}, REPLACE;
-- Volver a modo multi user
ALTER DATABASE [{db_name}] SET MULTI_USER;
"""
app_logger.info(f"Comando RESTORE generado:\n{restore_cmd}")
if dry_run: if dry_run:
app_logger.info("Modo DRY RUN: No se ejecutará el RESTORE") app_logger.info("Modo DRY RUN: No se ejecutará el RESTORE")
return True, restore_cmd, None return True, restore_query, None
# Ejecutar RESTORE # Ejecutar RESTORE
conn_str = self.get_connection_string() conn_str = self.get_connection_string()
@@ -214,39 +308,129 @@ ALTER DATABASE [{db_name}] SET MULTI_USER;
start_time = time.time() start_time = time.time()
app_logger.info(f"Ejecutando RESTORE DATABASE [{db_name}]...") app_logger.info(f"Ejecutando RESTORE DATABASE [{db_name}]...")
# Ejecutar en múltiples pasos
output_lines = [] output_lines = []
restore_error: Optional[str] = None
# 1. Single user
try: try:
cursor.execute(f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE") # 1. Limpiar BD atascada o poner SINGLE_USER si ya existe ONLINE
output_lines.append("Base de datos configurada en modo SINGLE_USER") self._prepare_database_for_restore(cursor, db_name)
# 2. RESTORE (RECOVERY es el default; drenar result sets hasta completar)
cursor.execute(restore_query)
self._drain_cursor(cursor)
output_lines.append("RESTORE DATABASE ejecutado")
# Esperar a que SQL Server termine (evita borrar el .bak demasiado pronto)
waited_state = self.wait_for_database_state(
db_name,
target_state="ONLINE",
timeout_seconds=timeout_minutes * 60,
)
if waited_state != "ONLINE":
restore_error = (
f"BD [{db_name}] no alcanzó ONLINE tras RESTORE "
f"(estado: {waited_state})"
)
app_logger.error(restore_error)
except Exception as e: except Exception as e:
app_logger.warning(f"Error configurando SINGLE_USER (puede no existir la DB): {e}") restore_error = str(e)
app_logger.error(f"RESTORE DATABASE falló para [{db_name}]: {e}")
# 2. RESTORE finally:
restore_query = f"RESTORE DATABASE [{db_name}] FROM DISK = N'{backup_path}' WITH {', '.join(move_clauses)}, REPLACE" # 3. MULTI_USER solo si la BD quedó ONLINE (G7)
cursor.execute(restore_query) final_state = self.get_database_state(db_name)
output_lines.append("RESTORE DATABASE completado") if final_state == "ONLINE":
try:
# 3. Multi user cursor.execute(f"ALTER DATABASE [{db_name}] SET MULTI_USER")
cursor.execute(f"ALTER DATABASE [{db_name}] SET MULTI_USER") output_lines.append("Base de datos configurada en modo MULTI_USER")
output_lines.append("Base de datos configurada en modo MULTI_USER") except Exception as e:
restore_error = restore_error or (
f"No se pudo volver a MULTI_USER en [{db_name}]: {e}"
)
app_logger.error(restore_error)
elif final_state:
msg = (
f"BD [{db_name}] quedó en estado {final_state} tras RESTORE"
)
restore_error = restore_error or msg
app_logger.error(msg)
elapsed = time.time() - start_time elapsed = time.time() - start_time
output_lines.append(f"Restauración completada en {elapsed:.2f}s") output_lines.append(f"Restauración finalizada en {elapsed:.2f}s")
conn.close()
output = "\n".join(output_lines) output = "\n".join(output_lines)
app_logger.info(output) app_logger.info(output)
if restore_error or self.get_database_state(db_name) != "ONLINE":
state = self.get_database_state(db_name)
err = restore_error or f"BD [{db_name}] no quedó ONLINE (estado: {state})"
return False, output, err
return True, output, None return True, output, None
except Exception as e: except Exception as e:
error_msg = f"Error restaurando base de datos: {str(e)}" error_msg = f"Error restaurando base de datos: {str(e)}"
app_logger.error(error_msg) app_logger.error(error_msg)
return False, None, error_msg return False, None, error_msg
finally:
if conn is not None:
try:
conn.close()
except Exception:
app_logger.warning("No se pudo cerrar la conexión SQL tras el RESTORE")
@staticmethod
def _sql_path_sep(data_folder: str) -> str:
"""
Separador de rutas que espera el SQL Server destino, deducido del formato
de data_folder. Windows (C:\\..., o contiene '\\') usa '\\'; SQL Server
sobre Linux (rutas POSIX '/var/opt/mssql/...') usa '/'. Las rutas del MOVE
son del filesystem del SERVIDOR, no de donde corre la app.
"""
if "\\" in data_folder:
return "\\"
# Ruta POSIX explícita ("/...") o sin separador Windows → '/'.
if data_folder.startswith("/"):
return "/"
# Heurística: "C:" u otra letra de unidad ⇒ Windows.
if len(data_folder) >= 2 and data_folder[1] == ":":
return "\\"
return "/"
@staticmethod
def _build_move_clauses(db_name, data_folder, logical_files) -> list:
"""
Genera una cláusula MOVE por archivo lógico con destino único, evitando
colisiones cuando el backup tiene múltiples data files o logs (G4):
- 1er data → {db}.mdf, siguientes → {db}_N.ndf
- 1er log → {db}_log.ldf, siguientes → {db}_log_N.ldf
- otros tipos (FILESTREAM/full-text) → {db}_{nombre_lógico_saneado}
El separador se adapta al SO del SQL Server destino (deducido de
data_folder), de modo que funciona tanto con SQL Server en Windows
(C:\\...\\db.mdf) como en Linux (/var/opt/mssql/data/db.mdf).
"""
sep = SQLServerManager._sql_path_sep(data_folder)
base = data_folder.rstrip("\\/")
clauses = []
data_idx = 0
log_idx = 0
for lf in logical_files:
if lf.type == 'D':
suffix = "" if data_idx == 0 else f"_{data_idx}"
ext = "mdf" if data_idx == 0 else "ndf"
new_path = f"{base}{sep}{db_name}{suffix}.{ext}"
data_idx += 1
elif lf.type == 'L':
suffix = "" if log_idx == 0 else f"_{log_idx}"
new_path = f"{base}{sep}{db_name}_log{suffix}.ldf"
log_idx += 1
else:
# No descartar otros tipos: moverlos preservando el nombre lógico.
safe = "".join(c if c.isalnum() else "_" for c in lf.logical_name)
new_path = f"{base}{sep}{db_name}_{safe}"
clauses.append(f"MOVE N'{lf.logical_name}' TO N'{new_path}'")
return clauses
def database_exists(self, db_name: str) -> bool: def database_exists(self, db_name: str) -> bool:
""" """

1
app/transfer/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Transferencia de archivos hacia los servidores SQL remotos (SMB)."""

201
app/transfer/sftp_copy.py Normal file
View File

@@ -0,0 +1,201 @@
"""
Transferencia del .bak al servidor SQL externo vía SFTP/SSH (paramiko).
Los servidores SQL destino son máquinas externas independientes (no comparten red
local con CloudRestoreAS), por lo que el .bak se sube por SFTP al servidor y el SQL
Server restaura desde su disco local (remote_inbox_path). Tras el job, el .bak del
servidor se elimina siempre (G10).
Nota de seguridad: se usa AutoAddPolicy para las host keys (TOFU). Para endurecer en
producción conviene fijar/known_hosts las claves de cada servidor.
"""
import ntpath
import posixpath
import time
from pathlib import Path
from typing import Optional
import paramiko
from ..utils.logger import app_logger
# Timeout de conexión SSH en segundos.
SSH_TIMEOUT = 30
# Verificación de la subida: tras escribir los bytes se confirma el tamaño remoto con
# reintentos. Tolera stats flaky (buffering/AV/locking, típico en OpenSSH/Windows) que de
# otro modo producirían un FALSO FALLO aunque el archivo sí se entregó.
VERIFY_ATTEMPTS = 3
VERIFY_DELAY_SECONDS = 1.0
class SFTPCopyError(Exception):
"""Error al transferir o limpiar el .bak en el servidor remoto vía SFTP."""
def _sftp_path(remote_inbox_path: str, filename: str) -> str:
"""
Ruta estilo POSIX para SFTP a partir de la carpeta destino (que puede venir en
formato Windows, p. ej. C:\\RestoreInbox). OpenSSH en Windows acepta 'C:/...'.
"""
posix_dir = remote_inbox_path.replace("\\", "/").rstrip("/")
return f"{posix_dir}/{filename}"
def windows_restore_path(remote_inbox_path: str, filename: str) -> str:
"""Ruta Windows que usará RESTORE DATABASE en el servidor (C:\\RestoreInbox\\x.bak)."""
return ntpath.join(remote_inbox_path, filename)
def _connect(cfg: dict) -> paramiko.SSHClient:
"""Abre una conexión SSH con las credenciales del servidor (cfg del PANEL)."""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
hostname=cfg["ssh_host"],
port=int(cfg.get("ssh_port") or 22),
username=cfg["ssh_username"],
password=cfg["ssh_password"],
timeout=SSH_TIMEOUT,
allow_agent=False,
look_for_keys=False,
)
except Exception as e:
raise SFTPCopyError(
f"No se pudo conectar por SSH a {cfg.get('ssh_host')}:{cfg.get('ssh_port')}: {e}"
) from e
return client
def upload_to_remote(local_bak: str, cfg: dict) -> str:
"""
Sube `local_bak` al servidor remoto vía SFTP, a remote_inbox_path/filename.
Args:
local_bak: ruta local del .bak ya extraído.
cfg: dict con ssh_host, ssh_port, ssh_username, ssh_password, remote_inbox_path.
Returns:
Ruta SFTP (POSIX) del .bak en el servidor, para limpieza posterior.
Raises:
SFTPCopyError: si el origen no existe o falla la conexión/transferencia.
"""
remote_folder = cfg.get("remote_inbox_path") or ""
return upload_file_to_folder(local_bak, cfg, remote_folder)
def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str:
"""
Sube un archivo local a remote_folder/filename vía SFTP.
Args:
local_file: ruta local del archivo.
cfg: dict con ssh_host, ssh_port, ssh_username, ssh_password.
remote_folder: carpeta destino en el servidor remoto (Windows o POSIX).
Returns:
Ruta SFTP (POSIX) del archivo en el servidor.
Raises:
SFTPCopyError: si el origen no existe o falla la transferencia.
"""
src = Path(local_file)
if not src.is_file():
raise SFTPCopyError(f"El archivo local no existe: {local_file}")
if not remote_folder or not str(remote_folder).strip():
raise SFTPCopyError("La carpeta remota destino está vacía")
remote_sftp = _sftp_path(str(remote_folder).strip(), src.name)
expected_size = src.stat().st_size
client = _connect(cfg)
try:
sftp = client.open_sftp()
try:
app_logger.info(f"Subiendo archivo por SFTP a {cfg['ssh_host']}: {remote_sftp}")
# confirm=False: la verificación de paramiko hace un stat inmediato que en
# OpenSSH/Windows suele fallar por buffering aunque el archivo sí se escribió.
# Se verifica el tamaño aparte, con reintentos (ver _verify_remote_size).
sftp.put(str(src), remote_sftp, confirm=False)
_verify_remote_size(sftp, remote_sftp, expected_size)
finally:
sftp.close()
except SFTPCopyError:
raise
except Exception as e:
raise SFTPCopyError(f"Fallo al subir archivo por SFTP ({remote_sftp}): {e}") from e
finally:
client.close()
return remote_sftp
def _verify_remote_size(sftp, remote_path: str, expected_size: int) -> None:
"""Confirma que el archivo remoto tiene el tamaño esperado, con reintentos.
Un stat transitoriamente fallido o con tamaño aún incompleto (flush/AV en curso) se
reintenta; solo tras agotar los intentos se considera fallo genuino de entrega.
"""
last_error: Optional[str] = None
for attempt in range(VERIFY_ATTEMPTS):
try:
remote_size = sftp.stat(remote_path).st_size
if remote_size == expected_size:
return
last_error = f"tamaño remoto {remote_size} != esperado {expected_size}"
except Exception as e: # noqa: BLE001 — se reintenta y, si persiste, se eleva abajo
last_error = str(e)
if attempt < VERIFY_ATTEMPTS - 1:
time.sleep(VERIFY_DELAY_SECONDS)
raise SFTPCopyError(
f"No se pudo verificar la subida de {remote_path}: {last_error}"
)
def upload_zip_parts(local_paths: list[str], cfg: dict, remote_folder: str) -> list[str]:
"""
Sube uno o más archivos ZIP (incl. multipart) al input_folder del destino.
Ante un fallo, adjunta a la excepción la lista de partes ya subidas (atributo
``uploaded``) para que el llamador pueda limpiar el envío parcial en el destino.
Returns:
Lista de rutas SFTP subidas.
"""
uploaded: list[str] = []
for local_path in local_paths:
try:
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
except SFTPCopyError as e:
e.uploaded = uploaded
raise
return uploaded
def cleanup_remote(cfg: dict, remote_sftp_path: Optional[str]) -> None:
"""
Elimina el .bak del servidor remoto vía SFTP. Best-effort: registra pero no lanza,
para no enmascarar el resultado real del job. Se invoca siempre en `finally`.
"""
if not remote_sftp_path:
return
try:
client = _connect(cfg)
except SFTPCopyError as e:
app_logger.error(f"No se pudo conectar para limpiar el .bak remoto: {e}")
return
try:
sftp = client.open_sftp()
try:
sftp.remove(remote_sftp_path)
app_logger.info(f"Inbox remoto limpiado: {remote_sftp_path}")
except FileNotFoundError:
pass
finally:
sftp.close()
except Exception as e:
app_logger.error(f"No se pudo limpiar el .bak remoto ({remote_sftp_path}): {e}")
finally:
client.close()

View File

@@ -1,15 +1,18 @@
"""Tab de configuración.""" """Tab de configuración."""
import sys
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
QFormLayout, QLineEdit, QPushButton, QSpinBox, QFormLayout, QLineEdit, QPushButton, QSpinBox,
QCheckBox, QFileDialog, QMessageBox, QScrollArea QCheckBox, QFileDialog, QMessageBox, QScrollArea, QComboBox
) )
from pathlib import Path from pathlib import Path
from ..utils.crypto import encrypt_password, decrypt_password from ..utils.crypto import encrypt_password, decrypt_password
from ..extract.seven_zip import SevenZipExtractor from ..extract.seven_zip import SevenZipExtractor
from ..sql.sql_manager import SQLServerManager from ..sql.sql_manager import SQLServerManager
from ..panel import panel_client
class ConfigTab(QWidget): class ConfigTab(QWidget):
@@ -20,6 +23,7 @@ class ConfigTab(QWidget):
super().__init__() super().__init__()
self.engine = engine self.engine = engine
self.config = {} self.config = {}
self._catalog_names: list[str] = []
self._setup_ui() self._setup_ui()
def _setup_ui(self): def _setup_ui(self):
@@ -100,7 +104,6 @@ class ConfigTab(QWidget):
self.sql_windows_auth_checkbox = QCheckBox() self.sql_windows_auth_checkbox = QCheckBox()
self.sql_windows_auth_checkbox.setChecked(True) self.sql_windows_auth_checkbox.setChecked(True)
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox) sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox)
self.sql_username_input = QLineEdit() self.sql_username_input = QLineEdit()
@@ -112,6 +115,18 @@ class ConfigTab(QWidget):
self.sql_password_input.setEnabled(False) self.sql_password_input.setEnabled(False)
sql_layout.addRow("Contraseña SQL:", self.sql_password_input) sql_layout.addRow("Contraseña SQL:", self.sql_password_input)
# Conectar el handler DESPUÉS de crear usuario/contraseña: en Linux se
# desmarca el checkbox (abajo), lo que dispararía _on_auth_changed antes de
# que existan esos campos si se conectara arriba.
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
if sys.platform != "win32":
# Windows Auth no aplica en Linux: forzar SQL Auth y habilitar campos.
self.sql_windows_auth_checkbox.setChecked(False)
self.sql_windows_auth_checkbox.setEnabled(False)
self.sql_windows_auth_checkbox.setToolTip(
"Windows Auth no está disponible en Linux; use SQL Auth."
)
test_sql_btn = QPushButton("🔌 Probar Conexión") test_sql_btn = QPushButton("🔌 Probar Conexión")
test_sql_btn.clicked.connect(self._test_sql_connection) test_sql_btn.clicked.connect(self._test_sql_connection)
sql_layout.addRow("", test_sql_btn) sql_layout.addRow("", test_sql_btn)
@@ -119,6 +134,37 @@ class ConfigTab(QWidget):
sql_group.setLayout(sql_layout) sql_group.setLayout(sql_layout)
layout.addWidget(sql_group) layout.addWidget(sql_group)
# Grupo: PANEL de Control (servidor de restauración activo)
panel_group = QGroupBox("PANEL de Control")
panel_layout = QFormLayout()
self.panel_url_input = QLineEdit()
self.panel_url_input.setPlaceholderText("http://ip-del-panel:3000")
panel_layout.addRow("URL del PANEL:", self.panel_url_input)
self.panel_token_input = QLineEdit()
self.panel_token_input.setEchoMode(QLineEdit.EchoMode.Password)
panel_layout.addRow("API Token:", self.panel_token_input)
self.panel_instance_combo = QComboBox()
self.panel_instance_combo.setToolTip(
"Nombre de este servidor en el panel. El CRA restaurará aquí lo que le "
"corresponda y reenviará el resto por SFTP automáticamente."
)
self.refresh_instance_btn = QPushButton("Actualizar lista de servidores")
self.refresh_instance_btn.clicked.connect(self._refresh_instance_combo)
instance_row = QHBoxLayout()
instance_row.addWidget(self.panel_instance_combo, stretch=1)
instance_row.addWidget(self.refresh_instance_btn)
panel_layout.addRow("Instancia (servidor):", instance_row)
test_panel_btn = QPushButton("🔌 Probar Conexión al PANEL")
test_panel_btn.clicked.connect(self._test_panel_connection)
panel_layout.addRow("", test_panel_btn)
panel_group.setLayout(panel_layout)
layout.addWidget(panel_group)
# Grupo: Concurrencia # Grupo: Concurrencia
concurrency_group = QGroupBox("Concurrencia") concurrency_group = QGroupBox("Concurrencia")
concurrency_layout = QFormLayout() concurrency_layout = QFormLayout()
@@ -270,6 +316,41 @@ class ConfigTab(QWidget):
self.auto_scan_checkbox.setChecked(features.get("auto_scan_enabled", True)) self.auto_scan_checkbox.setChecked(features.get("auto_scan_enabled", True))
self.scan_interval_spinbox.setValue(features.get("scan_interval_seconds", 30)) self.scan_interval_spinbox.setValue(features.get("scan_interval_seconds", 30))
# PANEL de Control
panel = config.get("panel", {})
self.panel_url_input.setText(panel.get("api_url", ""))
self.panel_token_input.setText(panel.get("api_token", ""))
self._refresh_instance_combo()
def _refresh_instance_combo(self):
"""Recarga los nombres de servidores desde el catálogo del panel."""
current = self.panel_instance_combo.currentText().strip()
saved_key = (self.config.get("panel", {}).get("instance_key") or "").strip()
preserve = current or saved_key
api_url = self.panel_url_input.text().strip()
api_token = self.panel_token_input.text().strip()
names: list[str] = []
if api_url and api_token:
names = panel_client.list_restore_target_names(api_url, api_token)
self._catalog_names = names
items = [""]
for name in names:
if name not in items:
items.append(name)
if preserve and preserve not in items:
items.append(preserve)
self.panel_instance_combo.blockSignals(True)
self.panel_instance_combo.clear()
self.panel_instance_combo.addItems(items)
idx = self.panel_instance_combo.findText(preserve)
self.panel_instance_combo.setCurrentIndex(idx if idx >= 0 else 0)
self.panel_instance_combo.blockSignals(False)
def _save_config(self): def _save_config(self):
"""Guarda la configuración.""" """Guarda la configuración."""
# Construir config # Construir config
@@ -307,9 +388,31 @@ class ConfigTab(QWidget):
"dry_run_mode": self.dry_run_checkbox.isChecked(), "dry_run_mode": self.dry_run_checkbox.isChecked(),
"auto_scan_enabled": self.auto_scan_checkbox.isChecked(), "auto_scan_enabled": self.auto_scan_checkbox.isChecked(),
"scan_interval_seconds": self.scan_interval_spinbox.value() "scan_interval_seconds": self.scan_interval_spinbox.value()
},
"panel": {
"api_url": self.panel_url_input.text().strip(),
"api_token": self.panel_token_input.text().strip(),
"instance_key": self.panel_instance_combo.currentText().strip(),
} }
} }
panel_url = self.panel_url_input.text().strip()
panel_token = self.panel_token_input.text().strip()
instance_key = self.panel_instance_combo.currentText().strip()
if panel_url and panel_token and not instance_key:
QMessageBox.warning(
self,
"Advertencia",
"Con panel configurado debe seleccionar la instancia (servidor).",
)
if panel_url and instance_key and self._catalog_names and instance_key not in self._catalog_names:
QMessageBox.warning(
self,
"Advertencia",
f'La instancia "{instance_key}" no está en el catálogo del panel. '
"Verifique el nombre o cree el servidor en Servidores de Restauración.",
)
# Cifrar password si no usa Windows Auth # Cifrar password si no usa Windows Auth
if not self.sql_windows_auth_checkbox.isChecked(): if not self.sql_windows_auth_checkbox.isChecked():
password = self.sql_password_input.text() password = self.sql_password_input.text()
@@ -333,12 +436,17 @@ class ConfigTab(QWidget):
line_edit.setText(folder) line_edit.setText(folder)
def _browse_seven_zip(self): def _browse_seven_zip(self):
"""Abre el diálogo para seleccionar 7z.exe.""" """Abre el diálogo para seleccionar el ejecutable de 7-Zip."""
if sys.platform == "win32":
title = "Seleccionar 7z.exe"
default_dir = "C:\\Program Files\\7-Zip"
file_filter = "Ejecutables (*.exe)"
else:
title = "Seleccionar 7z / 7zz"
default_dir = ""
file_filter = "Ejecutables (*)"
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, self, title, default_dir, file_filter
"Seleccionar 7z.exe",
"C:\\Program Files\\7-Zip",
"Ejecutables (*.exe)"
) )
if file_path: if file_path:
self.seven_zip_input.setText(file_path) self.seven_zip_input.setText(file_path)
@@ -356,8 +464,30 @@ class ConfigTab(QWidget):
"No se pudo auto-detectar 7-Zip. Por favor, selecciona manualmente." "No se pudo auto-detectar 7-Zip. Por favor, selecciona manualmente."
) )
def _test_panel_connection(self):
"""Prueba la conexión al PANEL y muestra el servidor de restauración activo."""
api_url = self.panel_url_input.text().strip()
api_token = self.panel_token_input.text().strip()
if not api_url:
QMessageBox.warning(self, "Error", "Debe especificar la URL del PANEL.")
return
success, info = panel_client.test_connection(api_url, api_token)
if success:
self._refresh_instance_combo()
QMessageBox.information(
self, "Éxito", f"Conexión al PANEL exitosa.\n{info or ''}"
)
else:
QMessageBox.critical(self, "Error", f"No se pudo conectar al PANEL:\n{info}")
def _on_auth_changed(self, checked: bool): def _on_auth_changed(self, checked: bool):
"""Maneja el cambio en el tipo de autenticación.""" """Maneja el cambio en el tipo de autenticación."""
# Guard: la señal puede dispararse durante la construcción de la UI.
if not hasattr(self, "sql_username_input"):
return
self.sql_username_input.setEnabled(not checked) self.sql_username_input.setEnabled(not checked)
self.sql_password_input.setEnabled(not checked) self.sql_password_input.setEnabled(not checked)

View File

@@ -33,6 +33,14 @@ class DashboardTab(QWidget):
title.setFont(title_font) title.setFont(title_font)
layout.addWidget(title) layout.addWidget(title)
self.panel_warning_label = QLabel("")
self.panel_warning_label.setWordWrap(True)
self.panel_warning_label.setStyleSheet(
"background-color: #fff3cd; color: #856404; padding: 8px; border-radius: 4px;"
)
self.panel_warning_label.hide()
layout.addWidget(self.panel_warning_label)
# Grid de estadísticas # Grid de estadísticas
stats_group = QGroupBox("Estadísticas de Jobs") stats_group = QGroupBox("Estadísticas de Jobs")
stats_layout = QGridLayout() stats_layout = QGridLayout()
@@ -94,6 +102,18 @@ class DashboardTab(QWidget):
times_group.setLayout(times_layout) times_group.setLayout(times_layout)
layout.addWidget(times_group) layout.addWidget(times_group)
self.engine_status_badge = QLabel("Detenido")
badge_font = QFont()
badge_font.setPointSize(12)
badge_font.setBold(True)
self.engine_status_badge.setFont(badge_font)
self.engine_status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.engine_status_badge.setStyleSheet(
"background-color: #e9ecef; color: #495057; padding: 6px 14px; "
"border-radius: 4px;"
)
layout.addWidget(self.engine_status_badge)
# Estado del motor # Estado del motor
motor_group = QGroupBox("Estado del Motor") motor_group = QGroupBox("Estado del Motor")
motor_layout = QHBoxLayout() motor_layout = QHBoxLayout()
@@ -136,6 +156,7 @@ class DashboardTab(QWidget):
def _load_initial_stats(self): def _load_initial_stats(self):
"""Carga las estadísticas iniciales.""" """Carga las estadísticas iniciales."""
self.update_motor_status()
stats = self.engine.get_stats() stats = self.engine.get_stats()
self.update_stats(stats) self.update_stats(stats)
self._refresh_recent_jobs() self._refresh_recent_jobs()
@@ -193,3 +214,40 @@ class DashboardTab(QWidget):
self.recent_table.setItem(i, 4, status_item) self.recent_table.setItem(i, 4, status_item)
self.recent_table.resizeColumnsToContents() self.recent_table.resizeColumnsToContents()
def update_motor_status(self) -> None:
"""Actualiza badge y etiqueta según el estado actual del motor."""
if not self.engine.is_running():
text = "Detenido"
badge_style = (
"background-color: #e9ecef; color: #495057; padding: 6px 14px; "
"border-radius: 4px;"
)
label_style = "color: red;"
elif self.engine.is_paused():
text = "Pausado"
badge_style = (
"background-color: #fff3cd; color: #856404; padding: 6px 14px; "
"border-radius: 4px;"
)
label_style = "color: orange;"
else:
text = "Activo"
badge_style = (
"background-color: #d4edda; color: #155724; padding: 6px 14px; "
"border-radius: 4px;"
)
label_style = "color: green;"
self.engine_status_badge.setText(f"Motor: {text}")
self.engine_status_badge.setStyleSheet(badge_style)
self.motor_status_label.setText(text)
self.motor_status_label.setStyleSheet(label_style)
def set_panel_warning(self, message: str) -> None:
"""Muestra aviso cuando falta configuración del servicio de bases en config/.env."""
if message:
self.panel_warning_label.setText(message)
self.panel_warning_label.show()
else:
self.panel_warning_label.hide()

View File

@@ -1,6 +1,7 @@
"""Tab de logs y soporte.""" """Tab de logs y soporte."""
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -91,10 +92,15 @@ class LogsTab(QWidget):
self.logs_text.setPlainText("\n".join(log_lines)) self.logs_text.setPlainText("\n".join(log_lines))
def _open_logs_folder(self): def _open_logs_folder(self):
"""Abre la carpeta de logs en el explorador.""" """Abre la carpeta de logs en el explorador de archivos del sistema."""
try: try:
if LOGS_DIR.exists(): if LOGS_DIR.exists():
subprocess.run(["explorer", str(LOGS_DIR)]) if sys.platform == "win32":
subprocess.run(["explorer", str(LOGS_DIR)])
elif sys.platform == "darwin":
subprocess.run(["open", str(LOGS_DIR)])
else:
subprocess.run(["xdg-open", str(LOGS_DIR)])
else: else:
QMessageBox.warning( QMessageBox.warning(
self, self,

View File

@@ -2,16 +2,19 @@
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QTabWidget, QMainWindow, QWidget, QVBoxLayout, QTabWidget,
QSystemTrayIcon, QMenu QSystemTrayIcon, QMenu, QMessageBox, QApplication,
) )
from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QIcon, QCloseEvent, QAction from PySide6.QtGui import QCloseEvent, QAction
from .dashboard_tab import DashboardTab from .dashboard_tab import DashboardTab
from .jobs_tab import JobsTab from .jobs_tab import JobsTab
from .nodes_tab import NodesTab 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 .. 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
@@ -19,58 +22,68 @@ from ..utils.logger import app_logger
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
"""Ventana principal de la aplicación.""" """Ventana principal de la aplicación."""
# Señal para minimizar a tray
minimize_to_tray = Signal() minimize_to_tray = Signal()
def __init__(self): def __init__(
self,
minimized: bool = False,
start_engine: bool = False,
panel_configured: bool = True,
):
"""Inicializa la ventana principal.""" """Inicializa la ventana principal."""
super().__init__() super().__init__()
# Motor de restauración self._start_on_load = start_engine
self._minimized_on_load = minimized
self._panel_configured = panel_configured
self._force_quit = False
self._tray_hint_shown = False
self.engine = RestoreEngine() self.engine = RestoreEngine()
# Configurar ventana
self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server") self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server")
self.setMinimumSize(1200, 800) self.setMinimumSize(1200, 800)
self.setWindowIcon(load_tray_icon())
# System tray self.tray_icon: QSystemTrayIcon | None = None
self.tray_icon: QSystemTrayIcon = None
self._setup_tray() self._setup_tray()
# UI
self._setup_ui() self._setup_ui()
# Conectar señales del motor
self._connect_engine_signals() self._connect_engine_signals()
# Timer para actualizar stats
self.stats_timer = QTimer() self.stats_timer = QTimer()
self.stats_timer.timeout.connect(self._update_stats) self.stats_timer.timeout.connect(self._update_stats)
self.stats_timer.start(5000) # Cada 5 segundos self.stats_timer.start(5000)
if not self._panel_configured:
self.dashboard_tab.set_panel_warning(
"Configure la conexión al servicio de bases en config/.env "
"(CLOUDRESTORE_PANEL_API_URL, TOKEN e INSTANCE_KEY)."
)
if self._start_on_load:
QTimer.singleShot(500, self._start_engine)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Ventana principal inicializada") app_logger.info("Ventana principal inicializada")
def _setup_ui(self): def _setup_ui(self):
"""Configura la interfaz de usuario.""" """Configura la interfaz de usuario."""
# Widget central
central_widget = QWidget() central_widget = QWidget()
self.setCentralWidget(central_widget) self.setCentralWidget(central_widget)
# Layout principal
layout = QVBoxLayout(central_widget) layout = QVBoxLayout(central_widget)
layout.setContentsMargins(10, 10, 10, 10) layout.setContentsMargins(10, 10, 10, 10)
# Tabs
self.tabs = QTabWidget() self.tabs = QTabWidget()
# Crear tabs
self.dashboard_tab = DashboardTab(self.engine) self.dashboard_tab = DashboardTab(self.engine)
self.jobs_tab = JobsTab(self.engine) self.jobs_tab = JobsTab(self.engine)
self.nodes_tab = NodesTab(self.engine) self.nodes_tab = NodesTab(self.engine)
self.config_tab = ConfigTab(self.engine) self.config_tab = ConfigTab(self.engine)
self.logs_tab = LogsTab(self.engine) self.logs_tab = LogsTab(self.engine)
# Agregar tabs
self.tabs.addTab(self.dashboard_tab, "📊 Dashboard") self.tabs.addTab(self.dashboard_tab, "📊 Dashboard")
self.tabs.addTab(self.jobs_tab, "📋 Jobs") self.tabs.addTab(self.jobs_tab, "📋 Jobs")
self.tabs.addTab(self.nodes_tab, "🔗 Nodos") self.tabs.addTab(self.nodes_tab, "🔗 Nodos")
@@ -78,15 +91,18 @@ class MainWindow(QMainWindow):
self.tabs.addTab(self.logs_tab, "📝 Logs") self.tabs.addTab(self.logs_tab, "📝 Logs")
layout.addWidget(self.tabs) layout.addWidget(self.tabs)
# Menu bar
self._setup_menu() self._setup_menu()
def _setup_menu(self): def _setup_menu(self):
"""Configura el menú.""" """Configura el menú."""
menubar = self.menuBar() menubar = self.menuBar()
# Menú Motor file_menu = menubar.addMenu("Archivo")
quit_action = QAction("Salir", self)
quit_action.triggered.connect(self._quit_application)
file_menu.addAction(quit_action)
motor_menu = menubar.addMenu("Motor") motor_menu = menubar.addMenu("Motor")
self.start_action = QAction("▶️ Iniciar", self) self.start_action = QAction("▶️ Iniciar", self)
@@ -114,20 +130,32 @@ 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)
# Menú Ayuda 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)
about_action.triggered.connect(self._show_about) about_action.triggered.connect(self._show_about)
help_menu.addAction(about_action) help_menu.addAction(about_action)
def _setup_tray(self): def _setup_tray(self, _attempt: int = 0):
"""Configura el icono de bandeja del sistema.""" """Configura el icono de bandeja del sistema (reintenta si aún no está listo)."""
# Crear icono (usar un icono por defecto o crear uno simple) if not QSystemTrayIcon.isSystemTrayAvailable():
if _attempt < 20:
# La bandeja puede no estar lista justo tras el login: reintentar ~10s.
QTimer.singleShot(500, lambda: self._setup_tray(_attempt + 1))
else:
app_logger.warning(
"Bandeja del sistema no disponible tras varios reintentos"
)
return
self.tray_icon = QSystemTrayIcon(self) self.tray_icon = QSystemTrayIcon(self)
self.tray_icon.setIcon(load_tray_icon())
self.tray_icon.setToolTip("CloudRestoreAS") self.tray_icon.setToolTip("CloudRestoreAS")
# Menú del tray
tray_menu = QMenu() tray_menu = QMenu()
show_action = QAction("Mostrar", self) show_action = QAction("Mostrar", self)
@@ -150,16 +178,12 @@ class MainWindow(QMainWindow):
tray_menu.addSeparator() tray_menu.addSeparator()
quit_action = QAction("Salir", self) quit_tray_action = QAction("Salir", self)
quit_action.triggered.connect(self._quit_application) quit_tray_action.triggered.connect(self._quit_application)
tray_menu.addAction(quit_action) tray_menu.addAction(quit_tray_action)
self.tray_icon.setContextMenu(tray_menu) self.tray_icon.setContextMenu(tray_menu)
# Doble clic en tray para mostrar/ocultar
self.tray_icon.activated.connect(self._on_tray_activated) self.tray_icon.activated.connect(self._on_tray_activated)
# Mostrar tray
self.tray_icon.show() self.tray_icon.show()
def _connect_engine_signals(self): def _connect_engine_signals(self):
@@ -169,13 +193,27 @@ class MainWindow(QMainWindow):
self.engine.signals.config_loaded.connect(self._on_config_loaded) self.engine.signals.config_loaded.connect(self._on_config_loaded)
def closeEvent(self, event: QCloseEvent): def closeEvent(self, event: QCloseEvent):
"""Maneja el evento de cierre de ventana (minimizar a tray).""" """Nunca cierra la app: minimiza a la bandeja (o a la barra de tareas si no hay bandeja)."""
if self.tray_icon.isVisible(): if self._force_quit:
self.hide()
event.ignore()
app_logger.info("Ventana minimizada a bandeja del sistema")
else:
event.accept() event.accept()
return
event.ignore()
if self.tray_icon and self.tray_icon.isVisible():
self.hide()
app_logger.info("Ventana minimizada a bandeja del sistema")
if not self._tray_hint_shown:
self.tray_icon.showMessage(
"CloudRestoreAS sigue en ejecución",
"La aplicación está en la bandeja. Use clic derecho → Salir para cerrar.",
QSystemTrayIcon.MessageIcon.Information,
4000,
)
self._tray_hint_shown = True
else:
# Sin bandeja disponible: minimizar a la barra de tareas, nunca cerrar.
self.showMinimized()
app_logger.info("Ventana minimizada a la barra de tareas (sin bandeja)")
def _on_tray_activated(self, reason): def _on_tray_activated(self, reason):
"""Maneja la activación del icono de tray.""" """Maneja la activación del icono de tray."""
@@ -191,12 +229,29 @@ class MainWindow(QMainWindow):
self.activateWindow() self.activateWindow()
self.raise_() self.raise_()
def _motor_status_text(self) -> str:
if not self.engine.is_running():
return "Detenido"
if self.engine.is_paused():
return "Pausado"
return "Activo"
def _update_tray_status(self):
"""Actualiza tooltip del icono de bandeja según estado del motor."""
if not self.tray_icon:
return
status = self._motor_status_text()
self.tray_icon.setToolTip(f"CloudRestoreAS — Motor: {status}")
def _start_engine(self): def _start_engine(self):
"""Inicia el motor.""" """Inicia el motor."""
self.engine.start() self.engine.start()
self.start_action.setEnabled(False) self.start_action.setEnabled(False)
self.pause_action.setEnabled(True) self.pause_action.setEnabled(True)
self.stop_action.setEnabled(True) self.stop_action.setEnabled(True)
self.resume_action.setEnabled(False)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor iniciado desde UI") app_logger.info("Motor iniciado desde UI")
def _pause_engine(self): def _pause_engine(self):
@@ -204,6 +259,8 @@ class MainWindow(QMainWindow):
self.engine.pause() self.engine.pause()
self.pause_action.setEnabled(False) self.pause_action.setEnabled(False)
self.resume_action.setEnabled(True) self.resume_action.setEnabled(True)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor pausado desde UI") app_logger.info("Motor pausado desde UI")
def _resume_engine(self): def _resume_engine(self):
@@ -211,6 +268,8 @@ class MainWindow(QMainWindow):
self.engine.resume() self.engine.resume()
self.resume_action.setEnabled(False) self.resume_action.setEnabled(False)
self.pause_action.setEnabled(True) self.pause_action.setEnabled(True)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor reanudado desde UI") app_logger.info("Motor reanudado desde UI")
def _stop_engine(self): def _stop_engine(self):
@@ -220,6 +279,8 @@ class MainWindow(QMainWindow):
self.pause_action.setEnabled(False) self.pause_action.setEnabled(False)
self.resume_action.setEnabled(False) self.resume_action.setEnabled(False)
self.start_action.setEnabled(True) self.start_action.setEnabled(True)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor detenido desde UI") app_logger.info("Motor detenido desde UI")
def _scan_now(self): def _scan_now(self):
@@ -227,8 +288,15 @@ 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.dashboard_tab.update_motor_status()
if self.engine.is_running(): if self.engine.is_running():
stats = self.engine.get_stats() stats = self.engine.get_stats()
self.dashboard_tab.update_stats(stats) self.dashboard_tab.update_stats(stats)
@@ -248,20 +316,21 @@ class MainWindow(QMainWindow):
def _show_about(self): def _show_about(self):
"""Muestra el diálogo Acerca de.""" """Muestra el diálogo Acerca de."""
from PySide6.QtWidgets import QMessageBox
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"
"© 2026 Aduanasoft" "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"
"© 2026 Aduanasoft",
) )
def _quit_application(self): def _quit_application(self):
"""Sale de la aplicación completamente.""" """Sale de la aplicación completamente."""
app_logger.info("Saliendo de la aplicación") app_logger.info("Saliendo de la aplicación")
self._force_quit = True
self.engine.stop() self.engine.stop()
self.tray_icon.hide() if self.tray_icon:
self.close() self.tray_icon.hide()
from PySide6.QtWidgets import QApplication
QApplication.quit() QApplication.quit()

View File

@@ -4,7 +4,7 @@ from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QWidget, QVBoxLayout, QHBoxLayout, QTableWidget,
QTableWidgetItem, QPushButton, QDialog, QFormLayout, QTableWidgetItem, QPushButton, QDialog, QFormLayout,
QLineEdit, QTextEdit, QCheckBox, QDialogButtonBox, QLineEdit, QTextEdit, QCheckBox, QDialogButtonBox,
QMessageBox QMessageBox, QLabel
) )
from PySide6.QtCore import Qt from PySide6.QtCore import Qt

55
app/ui/tray_assets.py Normal file
View File

@@ -0,0 +1,55 @@
"""Icono de bandeja del sistema."""
import sys
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
_ASSETS_DIR = Path(__file__).resolve().parent.parent.parent / "packaging" / "assets"
def _generated_tray_icon() -> QIcon:
"""Icono simple en memoria si no hay archivo empaquetado."""
size = 32
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setBrush(QColor(0, 120, 215))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(2, 2, size - 4, size - 4, 6, 6)
painter.setPen(QColor(255, 255, 255))
font = painter.font()
font.setBold(True)
font.setPointSize(14)
painter.setFont(font)
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "C")
painter.end()
return QIcon(pixmap)
def load_tray_icon() -> QIcon:
"""Carga icono de bandeja desde assets empaquetados o genera uno por defecto."""
candidates: list[Path] = []
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
base = Path(meipass)
candidates.extend(
[
base / "packaging" / "assets" / "tray-icon.png",
base / "packaging" / "assets" / "tray-icon.ico",
]
)
candidates.extend(
[
_ASSETS_DIR / "tray-icon.png",
_ASSETS_DIR / "tray-icon.ico",
]
)
for path in candidates:
if path.is_file():
icon = QIcon(str(path))
if not icon.isNull():
return icon
return _generated_tray_icon()

View File

@@ -1,59 +1,68 @@
"""Utilidades de cifrado para passwords usando DPAPI en Windows.""" """Utilidades de cifrado para passwords (DPAPI en Windows, Fernet en Linux)."""
import base64 import base64
import win32crypt import sys
from pathlib import Path
from typing import Optional from typing import Optional
from ..constants import DATA_DIR
_IS_WINDOWS = sys.platform == "win32"
_SECRET_FILE = DATA_DIR / ".secret"
def _get_fernet():
from cryptography.fernet import Fernet
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not _SECRET_FILE.exists():
key = Fernet.generate_key()
_SECRET_FILE.write_bytes(key)
try:
_SECRET_FILE.chmod(0o600)
except OSError:
pass
else:
key = _SECRET_FILE.read_bytes()
return Fernet(key)
def encrypt_password(password: str) -> str: def encrypt_password(password: str) -> str:
""" """Cifra una contraseña."""
Cifra una contraseña usando DPAPI de Windows.
Args:
password: Contraseña en texto plano
Returns:
Contraseña cifrada en base64
"""
if not password: if not password:
return "" return ""
try: try:
encrypted_bytes = win32crypt.CryptProtectData( if _IS_WINDOWS:
password.encode('utf-8'), import win32crypt
None,
None, encrypted_bytes = win32crypt.CryptProtectData(
None, password.encode("utf-8"), None, None, None, None, 0
None, )
0 return base64.b64encode(encrypted_bytes).decode("ascii")
)
return base64.b64encode(encrypted_bytes).decode('ascii') fernet = _get_fernet()
return fernet.encrypt(password.encode("utf-8")).decode("ascii")
except Exception as e: except Exception as e:
raise RuntimeError(f"Error cifrando contraseña: {e}") raise RuntimeError(f"Error cifrando contraseña: {e}") from e
def decrypt_password(encrypted_password: str) -> str: def decrypt_password(encrypted_password: str) -> str:
""" """Descifra una contraseña."""
Descifra una contraseña usando DPAPI de Windows.
Args:
encrypted_password: Contraseña cifrada en base64
Returns:
Contraseña en texto plano
"""
if not encrypted_password: if not encrypted_password:
return "" return ""
try: try:
encrypted_bytes = base64.b64decode(encrypted_password) if _IS_WINDOWS:
decrypted_bytes = win32crypt.CryptUnprotectData( import win32crypt
encrypted_bytes,
None, encrypted_bytes = base64.b64decode(encrypted_password)
None, decrypted_bytes = win32crypt.CryptUnprotectData(
None, encrypted_bytes, None, None, None, 0
0 )[1]
)[1] return decrypted_bytes.decode("utf-8")
return decrypted_bytes.decode('utf-8')
fernet = _get_fernet()
return fernet.decrypt(encrypted_password.encode("ascii")).decode("utf-8")
except Exception as e: except Exception as e:
raise RuntimeError(f"Error descifrando contraseña: {e}") raise RuntimeError(f"Error descifrando contraseña: {e}") from e

170
build-all.sh Executable file
View File

@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# =============================================================================
# CloudRestoreAS — build de TODO en una sola tarea (ejecutar desde WSL).
#
# Linux : build en Docker (ubuntu:22.04) -> dist/CloudRestoreAS (autocontenido)
# Windows: build vía powershell.exe -> build.ps1 -> dist/CloudRestoreAS.exe
# Paquete: dist/release/CloudRestoreAS-<version>-{linux,win}-<arch>.{tar.gz,zip}
# + SHA256SUMS + release.json
# Publica: paquetes genéricos de Gitea (fuente de verdad que consume el PANEL)
#
# Uso:
# ./build-all.sh # ambos + empaquetado
# ./build-all.sh --linux-only # solo Linux
# ./build-all.sh --windows-only # solo Windows
# ./build-all.sh --no-package # sin generar .tar.gz/.zip
# ./build-all.sh --clean # rebuild desde cero (borra venvs/bundled/dist)
# ./build-all.sh --publish # además publica en Gitea (requiere GITEA_TOKEN)
# ./build-all.sh --publish --notify-panel # y avisa al PANEL para que sincronice
# ./build-all.sh --publish --force-publish # reemplaza una versión ya publicada
# =============================================================================
set -uo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT"
DO_LINUX=1
DO_WINDOWS=1
DO_PACKAGE=1
DO_CLEAN=0
DO_PUBLISH=0
PUBLISH_ARGS=()
for arg in "$@"; do
case "$arg" in
--linux-only) DO_WINDOWS=0 ;;
--windows-only) DO_LINUX=0 ;;
--no-package) DO_PACKAGE=0 ;;
--clean) DO_CLEAN=1 ;;
--publish) DO_PUBLISH=1 ;;
--notify-panel) PUBLISH_ARGS+=(--notify-panel) ;;
--force-publish) PUBLISH_ARGS+=(--force) ;;
-h|--help)
grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Opción desconocida: $arg" >&2; exit 2 ;;
esac
done
# --- helpers -----------------------------------------------------------------
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; }
# SECONDS es incremental de bash; lo usamos para medir cada etapa.
stage_start() { STAGE_T0=$SECONDS; }
stage_end() { echo -e " (${1} en $((SECONDS - STAGE_T0))s)"; }
WIN_OK=0
LIN_OK=0
# --- limpieza opcional -------------------------------------------------------
if [[ "$DO_CLEAN" -eq 1 ]]; then
say "Limpieza (--clean): borrando venvs, bundled y dist"
# bundled/ y venv-linux los crea Docker como root; borrar vía contenedor.
if command -v docker >/dev/null 2>&1; then
docker run --rm -v "$ROOT:/app" ubuntu:22.04 bash -c \
'rm -rf /app/packaging/bundled/linux /app/venv-linux /app/dist/CloudRestoreAS /app/dist/release' \
>/dev/null 2>&1 || true
fi
rm -rf "$ROOT/venv-windows" "$ROOT/packaging/bundled/windows" \
"$ROOT/dist/CloudRestoreAS.exe" 2>/dev/null || true
ok "limpieza completada"
fi
# --- Build Linux (Docker) ----------------------------------------------------
if [[ "$DO_LINUX" -eq 1 ]]; then
say "Build Linux (Docker, autocontenido)"
stage_start
if ! command -v docker >/dev/null 2>&1; then
err "docker no disponible; no se puede construir Linux"
[[ "$DO_WINDOWS" -eq 0 ]] && exit 1
elif bash "$ROOT/packaging/scripts/docker-build-linux.sh"; then
if [[ -f "$ROOT/dist/CloudRestoreAS" ]]; then
LIN_OK=1; ok "dist/CloudRestoreAS"
else
err "el build Linux terminó sin binario"
fi
else
err "falló el build Linux"
fi
stage_end "Linux"
fi
# --- Build Windows (PowerShell -> build.ps1) ---------------------------------
if [[ "$DO_WINDOWS" -eq 1 ]]; then
say "Build Windows (PowerShell + build.ps1)"
stage_start
WIN_PATH="$(wslpath -w "$ROOT" 2>/dev/null || true)"
if command -v powershell.exe >/dev/null 2>&1 && [[ -n "$WIN_PATH" ]]; then
# build.ps1 auto-detecta el Python de Windows (Python313/312/311) y arma venv-windows.
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \
"Set-Location -LiteralPath '$WIN_PATH'; & .\\build.ps1"
PS_RC=$?
if [[ "$PS_RC" -eq 0 && -f "$ROOT/dist/CloudRestoreAS.exe" ]]; then
WIN_OK=1; ok "dist/CloudRestoreAS.exe"
else
err "falló el build Windows (código $PS_RC)"
[[ "$DO_LINUX" -eq 0 ]] && { stage_end "Windows"; exit 1; }
fi
else
warn "powershell.exe no disponible desde WSL; se omite el build Windows."
warn "Ejecuta build.ps1 en Windows para generar el .exe."
[[ "$DO_LINUX" -eq 0 ]] && exit 1
fi
stage_end "Windows"
fi
# --- Empaquetado -------------------------------------------------------------
if [[ "$DO_PACKAGE" -eq 1 ]]; then
say "Empaquetado (.tar.gz Linux / .zip Windows)"
stage_start
bash "$ROOT/packaging/scripts/package-release.sh" || warn "empaquetado con incidencias"
stage_end "Empaquetado"
fi
# --- Resumen -----------------------------------------------------------------
echo
say "Resumen"
[[ "$DO_LINUX" -eq 1 ]] && { [[ "$LIN_OK" -eq 1 ]] && ok "Linux regenerado" || err "Linux NO generado"; }
[[ "$DO_WINDOWS" -eq 1 ]] && { [[ "$WIN_OK" -eq 1 ]] && ok "Windows regenerado" || warn "Windows NO generado"; }
if [[ -d "$ROOT/dist/release" ]]; then
echo "Artefactos en dist/release/:"
ls -lh "$ROOT/dist/release" | awk 'NR>1 {print " " $9 " " $5}'
fi
# Éxito si todo lo solicitado se generó.
FAIL=0
[[ "$DO_LINUX" -eq 1 && "$LIN_OK" -ne 1 ]] && FAIL=1
[[ "$DO_WINDOWS" -eq 1 && "$WIN_OK" -ne 1 ]] && FAIL=1
# --- Publicación a Gitea -----------------------------------------------------
# Solo con el build completo: publicar una versión a la que le falta una plataforma
# dejaría en el PANEL un release que no se le puede instalar a la mitad de los
# servidores. Los paquetes genéricos son inmutables, así que corregirlo obligaría a
# quemar el número de versión.
if [[ "$DO_PUBLISH" -eq 1 ]]; then
echo
if [[ "$FAIL" -eq 1 ]]; then
err "no se publica: el build no terminó bien"
elif [[ "$DO_PACKAGE" -eq 0 ]]; then
err "no se publica: --publish necesita el empaquetado (quita --no-package)"
FAIL=1
elif [[ "$DO_LINUX" -eq 0 || "$DO_WINDOWS" -eq 0 ]]; then
err "no se publica: se requieren ambas plataformas (quita --linux-only/--windows-only)"
FAIL=1
else
say "Publicando en Gitea"
stage_start
if bash "$ROOT/packaging/scripts/publish-release.sh" "${PUBLISH_ARGS[@]+"${PUBLISH_ARGS[@]}"}"; then
ok "publicado en Gitea"
else
err "falló la publicación en Gitea"
FAIL=1
fi
stage_end "Publicación"
fi
fi
exit $FAIL

500
build-linux.log Normal file
View File

@@ -0,0 +1,500 @@
debconf: delaying package configuration, since apt-utils is not installed
===============================================
CloudRestoreAS - Build Linux (onefile)
===============================================
==> 7-Zip Linux (7zz)
7zz ya existe
==> ODBC Driver 18 + unixODBC
ODBC ya existe
Listo: /app/packaging/bundled/linux
Ejecutando PyInstaller (onefile)...
46 INFO: PyInstaller: 6.20.0, contrib hooks: 2026.6
46 INFO: Python: 3.10.12
47 INFO: Platform: Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.35
47 INFO: Python environment: /app/venv-linux
49 INFO: Removing temporary files and cleaning cache in /root/.cache/pyinstaller
77 WARNING: Failed to collect submodules for 'PySide6.scripts.deploy_lib' because importing 'PySide6.scripts.deploy_lib' raised: ModuleNotFoundError: No module named 'project_lib'
604 INFO: Module search paths (PYTHONPATH):
['/app',
'/usr/lib/python310.zip',
'/usr/lib/python3.10',
'/usr/lib/python3.10/lib-dynload',
'/app/venv-linux/lib/python3.10/site-packages',
'/app']
684 INFO: Appending 'binaries' from .spec
688 INFO: Appending 'datas' from .spec
745 INFO: checking Analysis
745 INFO: Building Analysis because Analysis-00.toc is non existent
745 INFO: Looking for Python shared library...
753 INFO: Using Python shared library: /lib/x86_64-linux-gnu/libpython3.10.so.1.0
753 INFO: Running Analysis Analysis-00.toc
753 INFO: Target bytecode optimization level: 0
753 INFO: Initializing module dependency graph...
753 INFO: Initializing module graph hook caches...
759 INFO: Analyzing modules for base_library.zip ...
989 INFO: Processing standard module hook 'hook-heapq.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
1019 INFO: Processing standard module hook 'hook-encodings.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
1674 INFO: Processing standard module hook 'hook-pickle.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2243 INFO: Caching module dependency graph...
2266 INFO: Analyzing /app/runner.py
2280 INFO: Processing standard module hook 'hook-PySide6.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2378 INFO: Processing standard module hook 'hook-shiboken6.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2455 INFO: Processing standard module hook 'hook-PySide6.QtNetwork.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2973 INFO: Processing standard module hook 'hook-PySide6.QtCore.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
3365 INFO: Processing standard module hook 'hook-PySide6.QtWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
3866 INFO: Processing standard module hook 'hook-PySide6.QtGui.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
4329 INFO: Processing standard module hook 'hook-sqlite3.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
4522 INFO: Processing standard module hook 'hook-platform.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
4540 INFO: Processing standard module hook 'hook-cryptography.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
4807 INFO: hook-cryptography: cryptography does not seem to be using dynamically linked OpenSSL.
4855 INFO: Processing standard module hook 'hook-pyodbc.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
4893 INFO: Processing standard module hook 'hook-urllib3.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
4961 INFO: Processing pre-safe-import-module hook 'hook-backports.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/pre_safe_import_module'
4962 INFO: SetuptoolsInfo: initializing cached setuptools info...
5130 INFO: Processing pre-safe-import-module hook 'hook-typing_extensions.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/pre_safe_import_module'
5213 INFO: Processing standard module hook 'hook-multiprocessing.util.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
5241 INFO: Processing standard module hook 'hook-xml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
5365 INFO: Processing standard module hook 'hook-_ctypes.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
5819 INFO: Processing standard module hook 'hook-certifi.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
5839 INFO: Processing standard module hook 'hook-charset_normalizer.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
6028 INFO: Processing standard module hook 'hook-bcrypt.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
6348 INFO: Processing standard module hook 'hook-difflib.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
6488 INFO: Processing standard module hook 'hook-nacl.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
6645 INFO: Analyzing hidden import 'PySide6.Qt3DAnimation'
6658 INFO: Processing standard module hook 'hook-PySide6.Qt3DAnimation.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
6777 INFO: Processing standard module hook 'hook-PySide6.Qt3DCore.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
6965 INFO: Processing standard module hook 'hook-PySide6.Qt3DRender.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8337 INFO: Processing standard module hook 'hook-PySide6.QtOpenGL.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8430 INFO: Analyzing hidden import 'PySide6.Qt3DExtras'
8463 INFO: Processing standard module hook 'hook-PySide6.Qt3DExtras.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8542 INFO: Analyzing hidden import 'PySide6.Qt3DInput'
8552 INFO: Processing standard module hook 'hook-PySide6.Qt3DInput.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8628 INFO: Analyzing hidden import 'PySide6.Qt3DLogic'
8629 INFO: Processing standard module hook 'hook-PySide6.Qt3DLogic.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8708 INFO: Analyzing hidden import 'PySide6.QtAsyncio'
8724 INFO: Analyzing hidden import 'PySide6.QtBluetooth'
8756 INFO: Processing standard module hook 'hook-PySide6.QtBluetooth.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8838 INFO: Analyzing hidden import 'PySide6.QtCanvasPainter'
8909 INFO: Processing standard module hook 'hook-PySide6.QtQuick.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
9019 INFO: Processing standard module hook 'hook-PySide6.QtQml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
--- Logging error ---
Traceback (most recent call last):
File "/usr/lib/python3.10/logging/__init__.py", line 1100, in emit
msg = self.format(record)
File "/usr/lib/python3.10/logging/__init__.py", line 943, in format
return fmt.format(record)
File "/usr/lib/python3.10/logging/__init__.py", line 678, in format
record.message = record.getMessage()
File "/usr/lib/python3.10/logging/__init__.py", line 368, in getMessage
msg = msg % self.args
TypeError: not enough arguments for format string
Call stack:
File "/usr/lib/python3.10/runpy.py", line 196, in _run_module_as_main
return _run_code(code, main_globals, None,
File "/usr/lib/python3.10/runpy.py", line 86, in _run_code
exec(code, run_globals)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/__main__.py", line 321, in <module>
run()
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/__main__.py", line 215, in run
run_build(pyi_config, spec_file, **vars(args))
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/__main__.py", line 70, in run_build
PyInstaller.building.build_main.main(pyi_config, spec_file, **kwargs)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/building/build_main.py", line 1275, in main
build(specfile, distpath, workpath, clean_build)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/building/build_main.py", line 1213, in build
exec(code, spec_namespace)
File "packaging/CloudRestoreAS.spec", line 59, in <module>
a = Analysis(
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/building/build_main.py", line 584, in __init__
self.__postinit__()
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/building/datastruct.py", line 184, in __postinit__
self.assemble()
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/building/build_main.py", line 719, in assemble
self.graph.add_hiddenimports(self.hiddenimports)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/depend/analysis.py", line 770, in add_hiddenimports
nodes = self.import_hook(modnm)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 1279, in import_hook
submodule = self._safe_import_module(head, mname, submodule)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/depend/analysis.py", line 539, in _safe_import_module
return super()._safe_import_module(module_basename, module_name, parent_package)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 1826, in _safe_import_module
self._process_imports(n)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 2654, in _process_imports
target_modules = self._safe_import_hook(*import_info, **kwargs)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/depend/analysis.py", line 477, in _safe_import_hook
ret_modules = super()._safe_import_hook(
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 2103, in _safe_import_hook
target_modules = self.import_hook(
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 1279, in import_hook
submodule = self._safe_import_module(head, mname, submodule)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/depend/analysis.py", line 539, in _safe_import_module
return super()._safe_import_module(module_basename, module_name, parent_package)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 1826, in _safe_import_module
self._process_imports(n)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 2654, in _process_imports
target_modules = self._safe_import_hook(*import_info, **kwargs)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/depend/analysis.py", line 477, in _safe_import_hook
ret_modules = super()._safe_import_hook(
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 2103, in _safe_import_hook
target_modules = self.import_hook(
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 1279, in import_hook
submodule = self._safe_import_module(head, mname, submodule)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/depend/analysis.py", line 539, in _safe_import_module
return super()._safe_import_module(module_basename, module_name, parent_package)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 1826, in _safe_import_module
self._process_imports(n)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/lib/modulegraph/modulegraph.py", line 2654, in _process_imports
target_modules = self._safe_import_hook(*import_info, **kwargs)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/depend/analysis.py", line 385, in _safe_import_hook
excluded_imports = self._find_all_excluded_imports(source_module.identifier)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/depend/analysis.py", line 373, in _find_all_excluded_imports
excluded_imports.update(module_hook.excludedimports)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/depend/imphook.py", line 343, in __getattr__
self._load_hook_module()
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/depend/imphook.py", line 422, in _load_hook_module
self._hook_module = importlib_load_source(self.hook_module_name, self.hook_filename)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/compat.py", line 566, in importlib_load_source
mod_loader.exec_module(mod)
File "<frozen importlib._bootstrap_external>", line 883, in exec_module
File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/hook-PySide6.QtQml.py", line 15, in <module>
qml_binaries, qml_datas = pyside6_library_info.collect_qtqml_files()
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/utils/hooks/qt/__init__.py", line 963, in collect_qtqml_files
plugin_binaries, plugin_datas = self._process_qml_plugin(qmldir_file)
File "/app/venv-linux/lib/python3.10/site-packages/PyInstaller/utils/hooks/qt/__init__.py", line 1014, in _process_qml_plugin
logger.warn("%s: QML plugin binary %r does not exist!", str(plugin_file))
Message: '%s: QML plugin binary %r does not exist!'
Arguments: ('/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/Qt/labs/assetdownloader/libqmlassetdownloaderprivateplugin.so',)
10247 INFO: Analyzing hidden import 'PySide6.QtCharts'
10302 INFO: Processing standard module hook 'hook-PySide6.QtCharts.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10383 INFO: Analyzing hidden import 'PySide6.QtConcurrent'
10385 INFO: Processing standard module hook 'hook-PySide6.QtConcurrent.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10457 INFO: Analyzing hidden import 'PySide6.QtDBus'
10474 INFO: Processing standard module hook 'hook-PySide6.QtDBus.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10549 INFO: Analyzing hidden import 'PySide6.QtDataVisualization'
10610 INFO: Processing standard module hook 'hook-PySide6.QtDataVisualization.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10687 INFO: Analyzing hidden import 'PySide6.QtDesigner'
10701 INFO: Processing standard module hook 'hook-PySide6.QtDesigner.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10809 INFO: Analyzing hidden import 'PySide6.QtGraphs'
10882 INFO: Processing standard module hook 'hook-PySide6.QtGraphs.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10963 INFO: Analyzing hidden import 'PySide6.QtGraphsWidgets'
10985 INFO: Processing standard module hook 'hook-PySide6.QtGraphsWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11060 INFO: Processing standard module hook 'hook-PySide6.QtQuickWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11138 INFO: Analyzing hidden import 'PySide6.QtHelp'
11145 INFO: Processing standard module hook 'hook-PySide6.QtHelp.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11233 INFO: Analyzing hidden import 'PySide6.QtHttpServer'
11239 INFO: Processing standard module hook 'hook-PySide6.QtHttpServer.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11318 INFO: Analyzing hidden import 'PySide6.QtLocation'
11345 INFO: Processing standard module hook 'hook-PySide6.QtLocation.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11461 INFO: Processing standard module hook 'hook-PySide6.QtPositioning.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11557 INFO: Analyzing hidden import 'PySide6.QtMultimedia'
11595 INFO: Processing standard module hook 'hook-PySide6.QtMultimedia.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11688 INFO: Analyzing hidden import 'PySide6.QtMultimediaWidgets'
11690 INFO: Processing standard module hook 'hook-PySide6.QtMultimediaWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11769 INFO: Analyzing hidden import 'PySide6.QtNetworkAuth'
11781 INFO: Processing standard module hook 'hook-PySide6.QtNetworkAuth.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11861 INFO: Analyzing hidden import 'PySide6.QtNfc'
11870 INFO: Processing standard module hook 'hook-PySide6.QtNfc.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11945 INFO: Analyzing hidden import 'PySide6.QtOpenGLWidgets'
11947 INFO: Processing standard module hook 'hook-PySide6.QtOpenGLWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12070 INFO: Analyzing hidden import 'PySide6.QtPdf'
12103 INFO: Processing standard module hook 'hook-PySide6.QtPdf.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12211 INFO: Analyzing hidden import 'PySide6.QtPdfWidgets'
12214 INFO: Processing standard module hook 'hook-PySide6.QtPdfWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12294 INFO: Analyzing hidden import 'PySide6.QtPrintSupport'
12302 INFO: Processing standard module hook 'hook-PySide6.QtPrintSupport.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12389 INFO: Analyzing hidden import 'PySide6.QtQuick3D'
12395 INFO: Processing standard module hook 'hook-PySide6.QtQuick3D.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12474 INFO: Analyzing hidden import 'PySide6.QtQuickControls2'
12475 INFO: Processing standard module hook 'hook-PySide6.QtQuickControls2.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12555 INFO: Analyzing hidden import 'PySide6.QtQuickTest'
12556 INFO: Analyzing hidden import 'PySide6.QtRemoteObjects'
12566 INFO: Processing standard module hook 'hook-PySide6.QtRemoteObjects.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12642 INFO: Analyzing hidden import 'PySide6.QtScxml'
12650 INFO: Processing standard module hook 'hook-PySide6.QtScxml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12739 INFO: Analyzing hidden import 'PySide6.QtSensors'
12753 INFO: Processing standard module hook 'hook-PySide6.QtSensors.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12847 INFO: Analyzing hidden import 'PySide6.QtSerialBus'
12864 INFO: Processing standard module hook 'hook-PySide6.QtSerialBus.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12975 INFO: Analyzing hidden import 'PySide6.QtSerialPort'
12979 INFO: Processing standard module hook 'hook-PySide6.QtSerialPort.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13071 INFO: Analyzing hidden import 'PySide6.QtSpatialAudio'
13081 INFO: Processing standard module hook 'hook-PySide6.QtSpatialAudio.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13194 INFO: Analyzing hidden import 'PySide6.QtSql'
13212 INFO: Processing standard module hook 'hook-PySide6.QtSql.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13330 INFO: Analyzing hidden import 'PySide6.QtStateMachine'
13338 INFO: Processing standard module hook 'hook-PySide6.QtStateMachine.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13425 INFO: Analyzing hidden import 'PySide6.QtSvg'
13429 INFO: Processing standard module hook 'hook-PySide6.QtSvg.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13515 INFO: Analyzing hidden import 'PySide6.QtSvgWidgets'
13517 INFO: Processing standard module hook 'hook-PySide6.QtSvgWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13604 INFO: Analyzing hidden import 'PySide6.QtTest'
13615 INFO: Processing standard module hook 'hook-PySide6.QtTest.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13693 INFO: Analyzing hidden import 'PySide6.QtTextToSpeech'
13698 INFO: Processing standard module hook 'hook-PySide6.QtTextToSpeech.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13795 INFO: Analyzing hidden import 'PySide6.QtUiTools'
13796 INFO: Processing standard module hook 'hook-PySide6.QtUiTools.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13886 INFO: Analyzing hidden import 'PySide6.QtWebChannel'
13888 INFO: Processing standard module hook 'hook-PySide6.QtWebChannel.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13970 INFO: Analyzing hidden import 'PySide6.QtWebEngineCore'
14022 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineCore.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14173 INFO: Analyzing hidden import 'PySide6.QtWebEngineQuick'
14176 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineQuick.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14316 INFO: Analyzing hidden import 'PySide6.QtWebEngineWidgets'
14320 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14411 INFO: Analyzing hidden import 'PySide6.QtWebSockets'
14416 INFO: Processing standard module hook 'hook-PySide6.QtWebSockets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14492 INFO: Analyzing hidden import 'PySide6.QtWebView'
14495 INFO: Analyzing hidden import 'PySide6.QtXml'
14505 INFO: Processing standard module hook 'hook-PySide6.QtXml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14580 INFO: Analyzing hidden import 'PySide6._config'
14581 INFO: Analyzing hidden import 'PySide6._git_pyside_version'
14581 INFO: Analyzing hidden import 'PySide6.scripts'
14582 INFO: Analyzing hidden import 'PySide6.scripts.android_deploy'
14584 INFO: Analyzing hidden import 'PySide6.scripts.deploy'
14587 INFO: Analyzing hidden import 'PySide6.scripts.metaobjectdump'
14593 INFO: Analyzing hidden import 'PySide6.scripts.project'
14599 INFO: Analyzing hidden import 'PySide6.scripts.project_lib'
14629 INFO: Processing standard module hook 'hook-xml.etree.cElementTree.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14633 INFO: Processing pre-safe-import-module hook 'hook-tomli.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/pre_safe_import_module'
14654 INFO: Analyzing hidden import 'PySide6.scripts.pyside_tool'
14666 INFO: Processing standard module hook 'hook-sysconfig.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14670 INFO: Analyzing hidden import 'PySide6.scripts.qml'
14673 INFO: Analyzing hidden import 'PySide6.scripts.qtpy2cpp'
14675 INFO: Analyzing hidden import 'PySide6.support'
14675 INFO: Analyzing hidden import 'PySide6.support.deprecated'
14675 INFO: Analyzing hidden import 'PySide6.support.generate_pyi'
14677 INFO: Processing module hooks (post-graph stage)...
14758 INFO: Performing binary vs. data reclassification (3662 entries)
15617 INFO: Looking for ctypes DLLs
15620 INFO: Analyzing run-time hooks ...
15623 INFO: Including run-time hook 'pyi_rth_inspect.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
15624 INFO: Including run-time hook 'pyi_rth_pkgutil.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
15625 INFO: Including run-time hook 'pyi_rth_multiprocessing.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
15625 INFO: Including run-time hook 'pyi_rth_cryptography_openssl.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/rthooks'
15626 INFO: Including run-time hook 'pyi_rth_pyside6.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
15627 INFO: Processing pre-find-module-path hook 'hook-_pyi_rth_utils.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/pre_find_module_path'
15628 INFO: Processing standard module hook 'hook-_pyi_rth_utils.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15694 INFO: Creating base_library.zip...
15704 INFO: Looking for dynamic libraries
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6FFmpegStub-crypto.so.3'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6FFmpegStub-ssl.so.3'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6FFmpegStub-va-drm.so.2'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6FFmpegStub-va-x11.so.2'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6FFmpegStub-va.so.2'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6Multimedia.so.6'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6MultimediaQuick.so.6'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6MultimediaWidgets.so.6'.
20131 WARNING: Library not found: could not resolve 'libpcsclite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6Nfc.so.6'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6Quick3DSpatialAudio.so.6'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6SpatialAudio.so.6'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6TextToSpeech.so.6'.
20131 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20132 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20132 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20132 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20132 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-icccm.so.4', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-keysyms.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-util.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-image.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-render.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-cursor.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-render-util.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxkbcommon-x11.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-shape.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavcodec.so.61'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavcodec.so.61.19.101'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavformat.so.61'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavformat.so.61.7.100'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavutil.so.59'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavutil.so.59.39.100'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswresample.so.5'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswresample.so.5.3.100'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswscale.so.8'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswscale.so.8.3.100'.
20132 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavcodec.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavformat.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavutil.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswresample.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswscale.so'.
20133 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libQt6EglFsKmsGbmSupport.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/egldeviceintegrations/libqeglfs-kms-integration.so'.
20133 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/multimedia/libffmpegmediaplugin.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/multimedia/libffmpegmediaplugin.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-icccm.so.4', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-keysyms.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-util.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-image.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-render.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-cursor.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-render-util.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxkbcommon-x11.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-shape.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libgtk-3.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libcairo.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libpango-1.0.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libgdk-3.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libharfbuzz.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libgdk_pixbuf-2.0.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libpangocairo-1.0.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libatk-1.0.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libcairo-gobject.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libcups.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/printsupport/libcupsprintersupport.so'.
20133 WARNING: Library not found: could not resolve 'libfbclient.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/sqldrivers/libqsqlibase.so'.
20133 WARNING: Library not found: could not resolve 'libmimerapi.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/sqldrivers/libqsqlmimer.so'.
20133 WARNING: Library not found: could not resolve 'libmysqlclient.so.21', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/sqldrivers/libqsqlmysql.so'.
20133 WARNING: Library not found: could not resolve 'libclntsh.so.23.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/sqldrivers/libqsqloci.so'.
20133 WARNING: Library not found: could not resolve 'libpq.so.5', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/sqldrivers/libqsqlpsql.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/texttospeech/libqtexttospeech_mock.so'.
20133 WARNING: Library not found: could not resolve 'libspeechd.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/texttospeech/libqtexttospeech_speechd.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/texttospeech/libqtexttospeech_speechd.so'.
20133 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-icccm.so.4', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-keysyms.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-util.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-image.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-render.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-cursor.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-render-util.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxkbcommon-x11.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-shape.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-icccm.so.4', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-keysyms.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-util.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-image.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-render.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-cursor.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-render-util.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxkbcommon-x11.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-shape.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtMultimedia/libquickmultimediaplugin.so'.
20134 WARNING: Library not found: could not resolve 'libQt6QuickShapesDesignHelpers.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtQuick/Shapes/DesignHelpers/libqtquickshapesdesignhelpersplugin.so'.
20134 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtQuick/VirtualKeyboard/Components/libqtvkbcomponentsplugin.so'.
20134 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtQuick3D/SpatialAudio/libquick3dspatialaudioplugin.so'.
20134 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtTextToSpeech/libtexttospeechqmlplugin.so'.
20134 WARNING: Library not found: could not resolve 'libQt6WaylandCompositorIviapplication.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWayland/Compositor/IviApplication/libwaylandcompositoriviapplicationplugin.so'.
20134 WARNING: Library not found: could not resolve 'libQt6WaylandCompositorPresentationTime.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWayland/Compositor/PresentationTime/libwaylandcompositorpresentationtimeplugin.so'.
20134 WARNING: Library not found: could not resolve 'libQt6WaylandCompositorWLShell.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWayland/Compositor/WlShell/libwaylandcompositorwlshellplugin.so'.
20134 WARNING: Library not found: could not resolve 'libQt6WaylandCompositorXdgShell.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWayland/Compositor/XdgShell/libwaylandcompositorxdgshellplugin.so'.
20134 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtTextToSpeech.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtSpatialAudio.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libpcsclite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtNfc.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtMultimediaWidgets.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtMultimedia.abi3.so'.
20194 INFO: Warnings written to /tmp/cloudrestore-build/CloudRestoreAS/warn-CloudRestoreAS.txt
20218 INFO: Graph cross-reference written to /tmp/cloudrestore-build/CloudRestoreAS/xref-CloudRestoreAS.html
20316 INFO: checking PYZ
20316 INFO: Building PYZ because PYZ-00.toc is non existent
20316 INFO: Building PYZ (ZlibArchive) /tmp/cloudrestore-build/CloudRestoreAS/PYZ-00.pyz
20520 INFO: Building PYZ (ZlibArchive) /tmp/cloudrestore-build/CloudRestoreAS/PYZ-00.pyz completed successfully.
20524 WARNING: Ignoring icon; supported only on Windows and macOS!
20569 INFO: checking PKG
20569 INFO: Building PKG because PKG-00.toc is non existent
20569 INFO: Building PKG (CArchive) CloudRestoreAS.pkg
88430 INFO: Building PKG (CArchive) CloudRestoreAS.pkg completed successfully.
88476 INFO: Bootloader /app/venv-linux/lib/python3.10/site-packages/PyInstaller/bootloader/Linux-64bit-intel/run
88476 INFO: checking EXE
88476 INFO: Building EXE because EXE-00.toc is non existent
88476 INFO: Building EXE from EXE-00.toc
88476 INFO: Copying bootloader EXE to /app/dist/CloudRestoreAS
88477 INFO: Appending PKG archive to custom ELF section in EXE
90592 INFO: Building EXE from EXE-00.toc completed successfully.
90652 INFO: Build complete! The results are available in: /app/dist
Build OK: /app/dist/CloudRestoreAS
Distribuir solo el binario; al ejecutar crea config/ automaticamente.
===LINUX_BUILD_EXIT=0===

343
build-windows.log Normal file
View File

@@ -0,0 +1,343 @@
===============================================
CloudRestoreAS - Build Windows (onefile)
===============================================
==> 7-Zip Extra (26.01)
7-Zip ya existe
==> ODBC Driver 18 (extraer DLLs del MSI)
ODBC ya existe
Listo: \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\packaging\bundled\windows
Python: C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\python.exe
[notice] A new release of pip is available: 25.0.1 -> 26.1.2
[notice] To update, run: \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Scripts\python.exe -m pip install --upgrade pip
Ejecutando PyInstaller (onefile)...
Workpath: C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build
937 INFO: PyInstaller: 6.20.0, contrib hooks: 2026.6
937 INFO: Python: 3.13.3
963 INFO: Platform: Windows-11-10.0.26200-SP0
963 INFO: Python environment: \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows
971 INFO: Removing temporary files and cleaning cache in C:\Users\Hugo Reyes\AppData\Local\pyinstaller
1653 WARNING: Failed to collect submodules for 'PySide6.scripts.deploy_lib' because importing 'PySide6.scripts.deploy_lib' raised: ModuleNotFoundError: No module named 'project_lib'
41112 INFO: Module search paths (PYTHONPATH):
['\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS',
'C:\\Users\\Hugo '
'Reyes\\AppData\\Local\\Programs\\Python\\Python313\\python313.zip',
'C:\\Users\\Hugo Reyes\\AppData\\Local\\Programs\\Python\\Python313\\DLLs',
'C:\\Users\\Hugo Reyes\\AppData\\Local\\Programs\\Python\\Python313\\Lib',
'C:\\Users\\Hugo Reyes\\AppData\\Local\\Programs\\Python\\Python313',
'\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows',
'\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages',
'\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\win32',
'\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\win32\\lib',
'\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\pythonwin',
'\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS']
42264 INFO: Appending 'binaries' from .spec
43050 INFO: Appending 'datas' from .spec
51557 INFO: checking Analysis
51557 INFO: Building Analysis because Analysis-00.toc is non existent
51557 INFO: Looking for Python shared library...
51557 INFO: Using Python shared library: C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\python313.dll
51557 INFO: Running Analysis Analysis-00.toc
51557 INFO: Target bytecode optimization level: 0
51557 INFO: Initializing module dependency graph...
51558 INFO: Initializing module graph hook caches...
51674 INFO: Analyzing modules for base_library.zip ...
54725 INFO: Processing standard module hook 'hook-encodings.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
56081 INFO: Processing standard module hook 'hook-heapq.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
58920 INFO: Processing standard module hook 'hook-pickle.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
63112 INFO: Caching module dependency graph...
63151 INFO: Analyzing \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\runner.py
63182 INFO: Processing standard module hook 'hook-PySide6.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
63885 INFO: Processing standard module hook 'hook-shiboken6.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
64039 INFO: Processing standard module hook 'hook-PySide6.QtNetwork.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
68821 INFO: Processing standard module hook 'hook-PySide6.QtCore.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
73034 INFO: Processing standard module hook 'hook-PySide6.QtWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
77556 INFO: Processing standard module hook 'hook-PySide6.QtGui.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
106235 INFO: Processing standard module hook 'hook-sqlite3.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
108350 INFO: Processing standard module hook 'hook-platform.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
108409 INFO: Processing standard module hook 'hook-_ctypes.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
108712 INFO: Processing standard module hook 'hook-cryptography.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
112516 INFO: hook-cryptography: cryptography does not seem to be using dynamically linked OpenSSL.
112922 INFO: Processing standard module hook 'hook-pyodbc.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
113246 INFO: Processing standard module hook 'hook-urllib3.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
113483 INFO: Processing pre-safe-import-module hook 'hook-backports.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
113491 INFO: SetuptoolsInfo: initializing cached setuptools info...
117037 INFO: Setuptools: 'backports' appears to be a full setuptools-vendored copy - creating alias to 'setuptools._vendor.backports'!
117060 INFO: Processing standard module hook 'hook-setuptools.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
117128 INFO: Processing pre-safe-import-module hook 'hook-distutils.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
117267 INFO: Processing standard module hook 'hook-sysconfig.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
117294 INFO: Processing pre-safe-import-module hook 'hook-jaraco.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
117301 INFO: Setuptools: 'jaraco' appears to be a full setuptools-vendored copy - creating alias to 'setuptools._vendor.jaraco'!
117334 INFO: Processing pre-safe-import-module hook 'hook-more_itertools.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
117337 INFO: Setuptools: 'more_itertools' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.more_itertools'!
117657 INFO: Processing pre-safe-import-module hook 'hook-typing_extensions.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
117854 INFO: Processing pre-safe-import-module hook 'hook-packaging.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
118406 INFO: Processing standard module hook 'hook-setuptools._vendor.jaraco.text.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
118438 INFO: Processing pre-safe-import-module hook 'hook-importlib_resources.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
119734 INFO: Processing pre-safe-import-module hook 'hook-importlib_metadata.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
119737 INFO: Setuptools: 'importlib_metadata' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.importlib_metadata'!
119800 INFO: Processing standard module hook 'hook-setuptools._vendor.importlib_metadata.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
120023 INFO: Processing pre-safe-import-module hook 'hook-zipp.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
120028 INFO: Setuptools: 'zipp' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.zipp'!
120782 INFO: Processing pre-safe-import-module hook 'hook-tomli.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
120786 INFO: Setuptools: 'tomli' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.tomli'!
121992 INFO: Processing pre-safe-import-module hook 'hook-wheel.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
121995 INFO: Setuptools: 'wheel' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.wheel'!
123153 INFO: Processing standard module hook 'hook-certifi.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
123371 INFO: Processing standard module hook 'hook-charset_normalizer.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
124596 INFO: Processing standard module hook 'hook-bcrypt.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
126239 INFO: Processing standard module hook 'hook-difflib.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
126586 INFO: Processing standard module hook 'hook-multiprocessing.util.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
126737 INFO: Processing standard module hook 'hook-xml.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
128497 INFO: Processing standard module hook 'hook-nacl.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
130237 INFO: Analyzing hidden import 'PySide6.Qt3DAnimation'
130274 INFO: Processing standard module hook 'hook-PySide6.Qt3DAnimation.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
131678 INFO: Processing standard module hook 'hook-PySide6.Qt3DCore.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
133194 INFO: Processing standard module hook 'hook-PySide6.Qt3DRender.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
144160 INFO: Processing standard module hook 'hook-PySide6.QtOpenGL.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
148253 INFO: Analyzing hidden import 'PySide6.Qt3DExtras'
148333 INFO: Processing standard module hook 'hook-PySide6.Qt3DExtras.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
150312 INFO: Analyzing hidden import 'PySide6.Qt3DInput'
150345 INFO: Processing standard module hook 'hook-PySide6.Qt3DInput.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
151944 INFO: Analyzing hidden import 'PySide6.Qt3DLogic'
151963 INFO: Processing standard module hook 'hook-PySide6.Qt3DLogic.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
153091 INFO: Analyzing hidden import 'PySide6.QtAsyncio'
153174 INFO: Analyzing hidden import 'PySide6.QtAxContainer'
153200 INFO: Processing standard module hook 'hook-PySide6.QtAxContainer.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
154345 INFO: Analyzing hidden import 'PySide6.QtBluetooth'
154413 INFO: Processing standard module hook 'hook-PySide6.QtBluetooth.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
155394 INFO: Analyzing hidden import 'PySide6.QtCanvasPainter'
155709 INFO: Processing standard module hook 'hook-PySide6.QtQuick.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
159855 INFO: Processing standard module hook 'hook-PySide6.QtQml.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
--- Logging error ---
Traceback (most recent call last):
File "C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\Lib\logging\__init__.py", line 1150, in emit
msg = self.format(record)
File "C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\Lib\logging\__init__.py", line 998, in format
return fmt.format(record)
~~~~~~~~~~^^^^^^^^
File "C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\Lib\logging\__init__.py", line 711, in format
record.message = record.getMessage()
~~~~~~~~~~~~~~~~~^^
File "C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\Lib\logging\__init__.py", line 400, in getMessage
msg = msg % self.args
~~~~^~~~~~~~~~~
TypeError: not enough arguments for format string
Call stack:
File "<frozen runpy>", line 198, in _run_module_as_main
File "<frozen runpy>", line 88, in _run_code
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\__main__.py", line 321, in <module>
run()
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\__main__.py", line 215, in run
run_build(pyi_config, spec_file, **vars(args))
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\__main__.py", line 70, in run_build
PyInstaller.building.build_main.main(pyi_config, spec_file, **kwargs)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\building\build_main.py", line 1275, in main
build(specfile, distpath, workpath, clean_build)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\building\build_main.py", line 1213, in build
exec(code, spec_namespace)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\packaging\CloudRestoreAS.spec", line 59, in <module>
a = Analysis(
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\building\build_main.py", line 584, in __init__
self.__postinit__()
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\building\datastruct.py", line 184, in __postinit__
self.assemble()
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\building\build_main.py", line 719, in assemble
self.graph.add_hiddenimports(self.hiddenimports)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\depend\analysis.py", line 770, in add_hiddenimports
nodes = self.import_hook(modnm)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 1279, in import_hook
submodule = self._safe_import_module(head, mname, submodule)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\depend\analysis.py", line 539, in _safe_import_module
return super()._safe_import_module(module_basename, module_name, parent_package)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 1826, in _safe_import_module
self._process_imports(n)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 2654, in _process_imports
target_modules = self._safe_import_hook(*import_info, **kwargs)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\depend\analysis.py", line 477, in _safe_import_hook
ret_modules = super()._safe_import_hook(
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 2103, in _safe_import_hook
target_modules = self.import_hook(
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 1279, in import_hook
submodule = self._safe_import_module(head, mname, submodule)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\depend\analysis.py", line 539, in _safe_import_module
return super()._safe_import_module(module_basename, module_name, parent_package)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 1826, in _safe_import_module
self._process_imports(n)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 2654, in _process_imports
target_modules = self._safe_import_hook(*import_info, **kwargs)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\depend\analysis.py", line 477, in _safe_import_hook
ret_modules = super()._safe_import_hook(
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 2103, in _safe_import_hook
target_modules = self.import_hook(
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 1279, in import_hook
submodule = self._safe_import_module(head, mname, submodule)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\depend\analysis.py", line 539, in _safe_import_module
return super()._safe_import_module(module_basename, module_name, parent_package)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 1826, in _safe_import_module
self._process_imports(n)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\lib\modulegraph\modulegraph.py", line 2654, in _process_imports
target_modules = self._safe_import_hook(*import_info, **kwargs)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\depend\analysis.py", line 385, in _safe_import_hook
excluded_imports = self._find_all_excluded_imports(source_module.identifier)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\depend\analysis.py", line 373, in _find_all_excluded_imports
excluded_imports.update(module_hook.excludedimports)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\depend\imphook.py", line 343, in __getattr__
self._load_hook_module()
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\depend\imphook.py", line 422, in _load_hook_module
self._hook_module = importlib_load_source(self.hook_module_name, self.hook_filename)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\compat.py", line 566, in importlib_load_source
mod_loader.exec_module(mod)
File "<frozen importlib._bootstrap_external>", line 1026, in exec_module
File "<frozen importlib._bootstrap>", line 488, in _call_with_frames_removed
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\hooks\hook-PySide6.QtQml.py", line 15, in <module>
qml_binaries, qml_datas = pyside6_library_info.collect_qtqml_files()
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\utils\hooks\qt\__init__.py", line 963, in collect_qtqml_files
plugin_binaries, plugin_datas = self._process_qml_plugin(qmldir_file)
File "\\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\utils\hooks\qt\__init__.py", line 1014, in _process_qml_plugin
logger.warn("%s: QML plugin binary %r does not exist!", str(plugin_file))
Message: '%s: QML plugin binary %r does not exist!'
Arguments: ('\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\qml\\Qt\\labs\\assetdownloader\\qmlassetdownloaderprivateplugin.dll',)
230199 INFO: Analyzing hidden import 'PySide6.QtCharts'
230330 INFO: Processing standard module hook 'hook-PySide6.QtCharts.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
232540 INFO: Analyzing hidden import 'PySide6.QtConcurrent'
232556 INFO: Processing standard module hook 'hook-PySide6.QtConcurrent.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
233299 INFO: Analyzing hidden import 'PySide6.QtDBus'
233342 INFO: Processing standard module hook 'hook-PySide6.QtDBus.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
234446 INFO: Analyzing hidden import 'PySide6.QtDataVisualization'
234568 INFO: Processing standard module hook 'hook-PySide6.QtDataVisualization.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
236233 INFO: Analyzing hidden import 'PySide6.QtDesigner'
236274 INFO: Processing standard module hook 'hook-PySide6.QtDesigner.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
241104 INFO: Analyzing hidden import 'PySide6.QtGraphs'
241324 INFO: Processing standard module hook 'hook-PySide6.QtGraphs.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
243536 INFO: Analyzing hidden import 'PySide6.QtGraphsWidgets'
243565 INFO: Processing standard module hook 'hook-PySide6.QtGraphsWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
244563 INFO: Processing standard module hook 'hook-PySide6.QtQuickWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
246244 INFO: Analyzing hidden import 'PySide6.QtHelp'
246273 INFO: Processing standard module hook 'hook-PySide6.QtHelp.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
247638 INFO: Analyzing hidden import 'PySide6.QtHttpServer'
247669 INFO: Processing standard module hook 'hook-PySide6.QtHttpServer.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
249385 INFO: Analyzing hidden import 'PySide6.QtLocation'
249581 INFO: Processing standard module hook 'hook-PySide6.QtLocation.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
252459 INFO: Processing standard module hook 'hook-PySide6.QtPositioning.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
254747 INFO: Analyzing hidden import 'PySide6.QtMultimedia'
254834 INFO: Processing standard module hook 'hook-PySide6.QtMultimedia.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
258359 INFO: Analyzing hidden import 'PySide6.QtMultimediaWidgets'
258375 INFO: Processing standard module hook 'hook-PySide6.QtMultimediaWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
260168 INFO: Analyzing hidden import 'PySide6.QtNetworkAuth'
260207 INFO: Processing standard module hook 'hook-PySide6.QtNetworkAuth.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
261657 INFO: Analyzing hidden import 'PySide6.QtNfc'
261690 INFO: Processing standard module hook 'hook-PySide6.QtNfc.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
262702 INFO: Analyzing hidden import 'PySide6.QtOpenGLWidgets'
262719 INFO: Processing standard module hook 'hook-PySide6.QtOpenGLWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
264327 INFO: Analyzing hidden import 'PySide6.QtPdf'
264355 INFO: Processing standard module hook 'hook-PySide6.QtPdf.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
265791 INFO: Analyzing hidden import 'PySide6.QtPdfWidgets'
265809 INFO: Processing standard module hook 'hook-PySide6.QtPdfWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
267252 INFO: Analyzing hidden import 'PySide6.QtPrintSupport'
267279 INFO: Processing standard module hook 'hook-PySide6.QtPrintSupport.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
269442 INFO: Analyzing hidden import 'PySide6.QtQuick3D'
269489 INFO: Processing standard module hook 'hook-PySide6.QtQuick3D.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
271627 INFO: Analyzing hidden import 'PySide6.QtQuickControls2'
271640 INFO: Processing standard module hook 'hook-PySide6.QtQuickControls2.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
273064 INFO: Analyzing hidden import 'PySide6.QtQuickTest'
273078 INFO: Analyzing hidden import 'PySide6.QtRemoteObjects'
273107 INFO: Processing standard module hook 'hook-PySide6.QtRemoteObjects.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
274340 INFO: Analyzing hidden import 'PySide6.QtScxml'
274392 INFO: Processing standard module hook 'hook-PySide6.QtScxml.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
275886 INFO: Analyzing hidden import 'PySide6.QtSensors'
275927 INFO: Processing standard module hook 'hook-PySide6.QtSensors.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
277621 INFO: Analyzing hidden import 'PySide6.QtSerialBus'
277722 INFO: Processing standard module hook 'hook-PySide6.QtSerialBus.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
281072 INFO: Analyzing hidden import 'PySide6.QtSerialPort'
281104 INFO: Processing standard module hook 'hook-PySide6.QtSerialPort.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
282168 INFO: Analyzing hidden import 'PySide6.QtSpatialAudio'
282208 INFO: Processing standard module hook 'hook-PySide6.QtSpatialAudio.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
283945 INFO: Analyzing hidden import 'PySide6.QtSql'
284080 INFO: Processing standard module hook 'hook-PySide6.QtSql.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
288123 INFO: Analyzing hidden import 'PySide6.QtStateMachine'
288150 INFO: Processing standard module hook 'hook-PySide6.QtStateMachine.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
289986 INFO: Analyzing hidden import 'PySide6.QtSvg'
290006 INFO: Processing standard module hook 'hook-PySide6.QtSvg.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
291280 INFO: Analyzing hidden import 'PySide6.QtSvgWidgets'
291297 INFO: Processing standard module hook 'hook-PySide6.QtSvgWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
292814 INFO: Analyzing hidden import 'PySide6.QtTest'
292847 INFO: Processing standard module hook 'hook-PySide6.QtTest.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
294027 INFO: Analyzing hidden import 'PySide6.QtTextToSpeech'
294048 INFO: Processing standard module hook 'hook-PySide6.QtTextToSpeech.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
296163 INFO: Analyzing hidden import 'PySide6.QtUiTools'
296177 INFO: Processing standard module hook 'hook-PySide6.QtUiTools.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
297703 INFO: Analyzing hidden import 'PySide6.QtWebChannel'
297732 INFO: Processing standard module hook 'hook-PySide6.QtWebChannel.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
299086 INFO: Analyzing hidden import 'PySide6.QtWebEngineCore'
299287 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineCore.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
302818 INFO: Analyzing hidden import 'PySide6.QtWebEngineQuick'
302842 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineQuick.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
304326 INFO: Analyzing hidden import 'PySide6.QtWebEngineWidgets'
304347 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
306188 INFO: Analyzing hidden import 'PySide6.QtWebSockets'
306220 INFO: Processing standard module hook 'hook-PySide6.QtWebSockets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
307657 INFO: Analyzing hidden import 'PySide6.QtWebView'
307698 INFO: Analyzing hidden import 'PySide6.QtXml'
307731 INFO: Processing standard module hook 'hook-PySide6.QtXml.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
309023 INFO: Analyzing hidden import 'PySide6._config'
309037 INFO: Analyzing hidden import 'PySide6._git_pyside_version'
309066 INFO: Analyzing hidden import 'PySide6.scripts'
309077 INFO: Analyzing hidden import 'PySide6.scripts.deploy'
309198 INFO: Analyzing hidden import 'PySide6.scripts.metaobjectdump'
309272 INFO: Analyzing hidden import 'PySide6.scripts.project'
309432 INFO: Analyzing hidden import 'PySide6.scripts.project_lib'
309645 INFO: Processing standard module hook 'hook-xml.etree.cElementTree.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
309737 INFO: Analyzing hidden import 'PySide6.scripts.pyside_tool'
309757 INFO: Analyzing hidden import 'PySide6.scripts.qml'
309783 INFO: Analyzing hidden import 'PySide6.scripts.qtpy2cpp'
309805 INFO: Analyzing hidden import 'PySide6.support'
309816 INFO: Analyzing hidden import 'PySide6.support.deprecated'
309829 INFO: Analyzing hidden import 'PySide6.support.generate_pyi'
309853 INFO: Processing module hooks (post-graph stage)...
311602 INFO: Performing binary vs. data reclassification (3706 entries)
374194 INFO: Looking for ctypes DLLs
374269 INFO: Analyzing run-time hooks ...
374273 INFO: Including run-time hook 'pyi_rth_inspect.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks'
374295 INFO: Including run-time hook 'pyi_rth_setuptools.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks'
374315 INFO: Including run-time hook 'pyi_rth_pkgutil.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks'
374345 INFO: Including run-time hook 'pyi_rth_multiprocessing.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks'
374370 INFO: Including run-time hook 'pyi_rth_cryptography_openssl.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks'
374387 INFO: Including run-time hook 'pyi_rth_pyside6.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks'
374409 INFO: Processing pre-find-module-path hook 'hook-_pyi_rth_utils.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_find_module_path'
374429 INFO: Processing standard module hook 'hook-_pyi_rth_utils.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
374573 INFO: Creating base_library.zip...
374601 INFO: Looking for dynamic libraries
388194 INFO: Extra DLL search directories (AddDllDirectory): ['\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\shiboken6']
388194 INFO: Extra DLL search directories (PATH): ['\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6']
550512 WARNING: Library not found: could not resolve 'fbclient.dll', dependency of '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\plugins\\sqldrivers\\qsqlibase.dll'.
550537 WARNING: Library not found: could not resolve 'MIMAPI64.dll', dependency of '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\plugins\\sqldrivers\\qsqlmimer.dll'.
550564 WARNING: Library not found: could not resolve 'OCI.dll', dependency of '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\plugins\\sqldrivers\\qsqloci.dll'.
550599 WARNING: Library not found: could not resolve 'LIBPQ.dll', dependency of '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\plugins\\sqldrivers\\qsqlpsql.dll'.
551377 WARNING: Library not found: could not resolve 'Qt6QuickShapesDesignHelpers.dll', dependency of '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\qml\\QtQuick\\Shapes\\DesignHelpers\\qtquickshapesdesignhelpersplugin.dll'.
557267 INFO: Warnings written to C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\warn-CloudRestoreAS.txt
557345 INFO: Graph cross-reference written to C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\xref-CloudRestoreAS.html
557674 INFO: checking PYZ
557674 INFO: Building PYZ because PYZ-00.toc is non existent
557674 INFO: Building PYZ (ZlibArchive) C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\PYZ-00.pyz
558391 INFO: Building PYZ (ZlibArchive) C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\PYZ-00.pyz completed successfully.
558559 INFO: checking PKG
558559 INFO: Building PKG because PKG-00.toc is non existent
558559 INFO: Building PKG (CArchive) CloudRestoreAS.pkg
770535 INFO: Building PKG (CArchive) CloudRestoreAS.pkg completed successfully.
770717 INFO: Bootloader \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\bootloader\Windows-64bit-intel\runw.exe
770717 INFO: checking EXE
770717 INFO: Building EXE because EXE-00.toc is non existent
770717 INFO: Building EXE from EXE-00.toc
770723 INFO: Copying bootloader EXE to \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\dist\CloudRestoreAS.exe
770978 INFO: Copying icon to EXE
771258 INFO: Copying 0 resources to EXE
771258 INFO: Embedding manifest in EXE
771481 INFO: Appending PKG archive to EXE
774801 INFO: Fixing EXE headers
1021508 INFO: Building EXE from EXE-00.toc completed successfully.
1021631 INFO: Build complete! The results are available in: \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\dist
Build OK: \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\dist\CloudRestoreAS.exe
Distribuir solo el .exe; al ejecutar crea config/ automaticamente.
===WINDOWS_BUILD_EXIT=0===

113
build.ps1
View File

@@ -1,72 +1,63 @@
# Script de build para PyInstaller # Build CloudRestoreAS — PyInstaller onefile portable
# Genera un ejecutable único de la aplicación $ErrorActionPreference = "Stop"
$Root = $PSScriptRoot
Write-Host "===============================================" -ForegroundColor Cyan Write-Host "===============================================" -ForegroundColor Cyan
Write-Host "CloudRestoreAS - Build Script" -ForegroundColor Cyan Write-Host "CloudRestoreAS - Build Windows (onefile)" -ForegroundColor Cyan
Write-Host "===============================================" -ForegroundColor Cyan Write-Host "===============================================" -ForegroundColor Cyan
Write-Host ""
# Verificar que PyInstaller esté instalado & "$Root\packaging\scripts\download-bundled-deps.ps1"
Write-Host "Verificando PyInstaller..." -ForegroundColor Yellow
$pyinstallerCheck = pip show pyinstaller 2>$null # Python de Windows (evitar venv creado desde WSL)
if (-not $pyinstallerCheck) { $PythonCandidates = @(
Write-Host "PyInstaller no está instalado. Instalando..." -ForegroundColor Yellow "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe",
pip install pyinstaller "$env:LOCALAPPDATA\Programs\Python\Python312\python.exe",
"$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
)
$Python = $PythonCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $Python) {
throw "No se encontró Python 3.11+ en Windows. Instale desde python.org"
} }
Write-Host "Python: $Python" -ForegroundColor Gray
# Crear directorio de build $venvDir = Join-Path $Root "venv-windows"
$buildDir = "build" $venvPython = Join-Path $venvDir "Scripts\python.exe"
$distDir = "dist" if (-not (Test-Path $venvPython)) {
if (Test-Path $venvDir) { Remove-Item -Recurse -Force $venvDir }
Write-Host "" & $Python -m venv $venvDir
Write-Host "Limpiando builds anteriores..." -ForegroundColor Yellow
if (Test-Path $buildDir) {
Remove-Item -Recurse -Force $buildDir
}
if (Test-Path $distDir) {
Remove-Item -Recurse -Force $distDir
}
if (Test-Path "*.spec") {
Remove-Item -Force *.spec
} }
& $venvPython -m pip install --upgrade pip -q
& $venvPython -m pip install -r (Join-Path $Root "requirements-windows.txt") -q
Write-Host "" $distDir = Join-Path $Root "dist"
Write-Host "Ejecutando PyInstaller..." -ForegroundColor Yellow $isUncRoot = $Root -like "\\*"
Write-Host "" # PyInstaller no puede usar workpath en UNC (WSL); usar carpeta local en Windows
if ($isUncRoot) {
# Ejecutar PyInstaller $buildDir = Join-Path $env:LOCALAPPDATA "Temp\cloudrestore-build"
pyinstaller --name CloudRestoreAS `
--onefile `
--windowed `
--icon=NONE `
--add-data "app;app" `
--hidden-import PySide6 `
--hidden-import pyodbc `
--hidden-import win32crypt `
--collect-all PySide6 `
runner.py
if ($LASTEXITCODE -eq 0) {
Write-Host ""
Write-Host "===============================================" -ForegroundColor Green
Write-Host "Build completado exitosamente!" -ForegroundColor Green
Write-Host "===============================================" -ForegroundColor Green
Write-Host ""
Write-Host "El ejecutable se encuentra en:" -ForegroundColor Cyan
Write-Host " $PWD\dist\CloudRestoreAS.exe" -ForegroundColor White
Write-Host ""
Write-Host "IMPORTANTE:" -ForegroundColor Yellow
Write-Host " - Copia las carpetas 'data' y 'logs' junto al .exe" -ForegroundColor White
Write-Host " - Asegúrate de tener 7-Zip instalado" -ForegroundColor White
Write-Host " - Asegúrate de tener ODBC Driver 17 for SQL Server" -ForegroundColor White
Write-Host ""
} else { } else {
Write-Host "" $buildDir = Join-Path $Root "build"
Write-Host "===============================================" -ForegroundColor Red }
Write-Host "Error durante el build" -ForegroundColor Red if (Test-Path $buildDir) {
Write-Host "===============================================" -ForegroundColor Red Remove-Item -Recurse -Force $buildDir -ErrorAction SilentlyContinue
Write-Host "" }
New-Item -ItemType Directory -Force -Path $distDir, $buildDir | Out-Null
if (Test-Path (Join-Path $distDir "CloudRestoreAS.exe")) {
Remove-Item -Force (Join-Path $distDir "CloudRestoreAS.exe") -ErrorAction SilentlyContinue
} }
# Pausar para ver resultados Write-Host "Ejecutando PyInstaller (onefile)..." -ForegroundColor Yellow
Read-Host "Presiona Enter para continuar..." Write-Host "Workpath: $buildDir" -ForegroundColor Gray
& $venvPython -m PyInstaller (Join-Path $Root "packaging\CloudRestoreAS.spec") `
--clean --noconfirm `
--distpath $distDir `
--workpath $buildDir
$exe = Join-Path $Root "dist\CloudRestoreAS.exe"
if (Test-Path $exe) {
Write-Host ""
Write-Host "Build OK: $exe" -ForegroundColor Green
Write-Host "Distribuir solo el .exe; al ejecutar crea config/ automaticamente." -ForegroundColor Cyan
} else {
Write-Host "Error: no se genero el ejecutable" -ForegroundColor Red
exit 1
}

43
build.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Build CloudRestoreAS — PyInstaller onefile portable (Linux)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT"
echo "==============================================="
echo "CloudRestoreAS - Build Linux (onefile)"
echo "==============================================="
bash "$ROOT/packaging/scripts/download-bundled-deps.sh"
VENV="$ROOT/venv-linux"
if [[ ! -x "$VENV/bin/python" ]]; then
rm -rf "$VENV"
python3 -m venv "$VENV"
fi
"$VENV/bin/pip" install --upgrade pip -q
"$VENV/bin/pip" install -r requirements.txt -q
BUILD_DIR="${CLOUDRESTORE_BUILD_DIR:-/tmp/cloudrestore-build}"
DIST_DIR="${CLOUDRESTORE_DIST_DIR:-$ROOT/dist}"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR" "$DIST_DIR"
rm -f "$DIST_DIR/CloudRestoreAS"
echo "Ejecutando PyInstaller (onefile)..."
"$VENV/bin/python" -m PyInstaller packaging/CloudRestoreAS.spec \
--clean --noconfirm \
--distpath "$DIST_DIR" \
--workpath "$BUILD_DIR"
BIN="$ROOT/dist/CloudRestoreAS"
if [[ -f "$BIN" ]]; then
chmod +x "$BIN"
echo ""
echo "Build OK: $BIN"
echo "Distribuir solo el binario; al ejecutar crea config/ automaticamente."
else
echo "Error: no se genero el binario" >&2
exit 1
fi

View File

@@ -1,124 +1,236 @@
# 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.
#
# Correr como SYSTEM es además lo que evita aquí el problema de propiedad que en Linux
# sí hay que resolver: allá el instalador crea el árbol como root pero el unit corre
# como un usuario común, que no podría leer su config/.env ni escribir su base local
# (de ahí el `chown -R` de install.sh). SYSTEM tiene control total sobre el sistema de
# archivos local y este script no restringe ninguna ACL del prefijo, así que hereda de
# su carpeta padre y puede leer y escribir todo lo que creó el Administrador. No hace
# falta un arreglo simétrico; si algún día se cambia el principal a una cuenta común,
# entonces sí habría que ajustar los permisos del árbol.
$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..."

497
install.sh Executable file
View File

@@ -0,0 +1,497 @@
#!/usr/bin/env bash
# Instalador Linux de CloudRestoreAS.
#
# NO instala ni descarga NADA del sistema: el binario es 100% autocontenido (Qt/xcb,
# driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo coloca el
# binario, marca permisos, hace el bootstrap de config/, opcionalmente siembra las
# credenciales del PANEL, y registra el arranque automático (servicio systemd headless
# o autostart de escritorio).
#
# Uso:
# sudo ./install.sh --service # 24/7 headless vía systemd (recomendado en servidor)
# ./install.sh --user-service # 24/7 headless SIN root: unit de systemd de usuario
# ./install.sh --update-in-place # actualiza en su sitio, SIN privilegios
# ./install.sh --desktop # autostart de escritorio (requiere sesión gráfica)
# ./install.sh # solo instala; sin arranque automático
#
# --update-in-place actualiza una instalación EXISTENTE sin privilegios, dejando el unit de
# systemd como está. Se apoya en dos hechos: `install` desvincula el destino antes de crearlo (a
# diferencia de `cp`, que da ETXTBSY), así que reemplazar el binario solo exige escritura en el
# DIRECTORIO; y el unit trae Restart=always, así que basta señalizar al proceso —desde la cuenta
# que lo corre— para que systemd lo relevante con el binario nuevo. No toca /etc ni systemctl.
# Se niega a actuar si hay una restauración en curso.
#
# --user-service no necesita privilegios: instala donde apunte PREFIX (que debe ser escribible
# por el usuario, típicamente bajo su home), registra el unit en ~/.config/systemd/user/ y lo
# arranca con `systemctl --user`. Para que sobreviva al cierre de sesión intenta habilitar
# lingering; si el destino no lo permite, cae a una entrada @reboot en el crontab del usuario
# más un vigilante que lo rearranca. Es la vía cuando la cuenta SSH no es root ni tiene sudo
# sin contraseña: al panel le bastan entonces el usuario y la contraseña que ya tiene guardados.
#
# 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:
# PREFIX=/opt/cloudrestoreas # carpeta destino (default)
# SERVICE_USER=<usuario> # usuario del servicio systemd (default: quien invoca)
set -euo pipefail
MODE="none"
PANEL_ENV_FILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--service) MODE="service"; shift ;;
--user-service) MODE="user-service"; shift ;;
--update-in-place) MODE="update-in-place"; shift ;;
--desktop) MODE="desktop"; shift ;;
--panel-env-file)
[[ $# -ge 2 ]] || { echo "ERROR: --panel-env-file requiere una ruta" >&2; exit 2; }
PANEL_ENV_FILE="$2"; shift 2 ;;
--panel-env-file=*) PANEL_ENV_FILE="${1#*=}"; shift ;;
-h|--help)
grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Opción desconocida: $1" >&2; exit 2 ;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PREFIX="${PREFIX:-/opt/cloudrestoreas}"
BIN_NAME="CloudRestoreAS"
UNIT_NAME="cloudrestoreas.service"
# Patrón para localizar el agente vivo. El ancla ^ NO es opcional: sin ella, la propia
# línea de cron del vigilante contiene esta ruta, así que el `sh -c` que la ejecuta hace
# match consigo mismo, el vigilante cree que el agente corre y no lo rearranca nunca.
PGREP_PAT="^$PREFIX/$BIN_NAME"
# Usuario del servicio. Se resuelve AQUÍ y no dentro del case de --service porque el chown de
# propiedad tiene que correr antes, justo después del bootstrap. Precedencia: la variable
# explícita, luego quien invocó el sudo, luego el usuario efectivo.
SERVICE_USER="${SERVICE_USER:-${SUDO_USER:-$(id -un)}}"
# Localizar el binario: dist/CloudRestoreAS, dist/CloudRestoreAS-linux, o junto al script.
SRC=""
for cand in \
"$SCRIPT_DIR/dist/$BIN_NAME" \
"$SCRIPT_DIR/dist/${BIN_NAME}-linux" \
"$SCRIPT_DIR/$BIN_NAME" \
"$SCRIPT_DIR/${BIN_NAME}-linux"; do
if [[ -f "$cand" ]]; then SRC="$cand"; break; fi
done
if [[ -z "$SRC" ]]; then
echo "ERROR: no se encontró el binario ($BIN_NAME). Ejecuta ./build.sh primero" >&2
exit 1
fi
echo "==============================================="
echo "CloudRestoreAS - Instalación Linux"
echo " Binario : $SRC"
echo " Destino : $PREFIX"
echo " Modo : $MODE"
echo "==============================================="
# --- Detener el servicio si está corriendo ------------------------------------
# Ojo con la razón, que estuvo mal escrita mucho tiempo: `install` NO sufre ETXTBSY. A
# diferencia de `cp` —que abre con O_TRUNC—, `install` desvincula el destino antes de crearlo
# (coreutils fija unlink_dest_before_opening), que es justo por lo que `make install` funciona
# sobre binarios en ejecución. Comprobado: `cp` sobre un ELF corriendo da "Text file busy" y
# `install` no. Lo que hace falta para reemplazarlo es permiso de escritura en el DIRECTORIO.
#
# Se detiene igual en los modos de servicio porque conviene un apagado ordenado, no porque la
# copia lo exija. Se recuerda si estaba activo para volver a levantarlo al final.
WAS_ACTIVE=0
# En modo usuario el unit vive en la instancia de systemd del propio usuario, que necesita
# XDG_RUNTIME_DIR: un `exec` de SSH no es una sesión de login y no siempre lo trae.
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
SYSTEMCTL_USER=(systemctl --user)
# En modo en-sitio NO se detiene nada: `install` reemplaza el binario con el proceso corriendo, y
# matarlo antes de tener el binario nuevo abriría una ventana en la que systemd relevanta el VIEJO.
if [[ "$MODE" != "update-in-place" ]] && command -v systemctl >/dev/null 2>&1; then
if [[ "$MODE" == "user-service" ]]; then
if "${SYSTEMCTL_USER[@]}" is-active --quiet "$UNIT_NAME" 2>/dev/null; then
WAS_ACTIVE=1
echo "Deteniendo $UNIT_NAME (usuario) para reemplazar el binario..."
"${SYSTEMCTL_USER[@]}" stop "$UNIT_NAME" >/dev/null 2>&1 || \
echo "AVISO: no se pudo detener $UNIT_NAME; la copia puede fallar." >&2
fi
# Sin systemd de usuario disponible el arranque pudo haber quedado por cron: el binario
# sigue vivo y sobrescribirlo daría ETXTBSY, así que se termina igual.
if [[ "$WAS_ACTIVE" -eq 0 ]] && pgrep -f "$PGREP_PAT" >/dev/null 2>&1; then
WAS_ACTIVE=1
echo "Terminando el proceso en curso para reemplazar el binario..."
pkill -f "$PGREP_PAT" >/dev/null 2>&1 || true
sleep 2
fi
elif 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
# Una restauración en curso no se interrumpe. El agente no atiende SIGTERM, así que matarlo a
# media restauración deja el job atascado —ese ZIP queda vetado en cada escaneo posterior— y puede
# dejar la base en SINGLE_USER. Cualquier subcarpeta de Temp/ es un job en vuelo.
# Código 75 (EX_TEMPFAIL) y no 1: le dice al panel "reintenta luego", no "falló la instalación".
if [[ "$MODE" == "update-in-place" ]] && [[ -d "$PREFIX/Temp" ]]; then
if [[ -n "$(ls -A "$PREFIX/Temp" 2>/dev/null)" ]]; then
echo "ERROR: hay una restauración en curso ($PREFIX/Temp no está vacío)." >&2
echo " No se actualiza para no dejarla a medias. Reintenta cuando termine." >&2
exit 75
fi
fi
# Respaldo para poder volver atrás. Una actualización en sitio reemplaza el binario de un
# servidor en producción sin red de seguridad: si el nuevo no arranca, sin esto el servidor queda
# sin agente y sin forma de recuperarlo salvo entrando a mano.
BACKUP=""
if [[ "$MODE" == "update-in-place" && -f "$PREFIX/$BIN_NAME" ]]; then
BACKUP="$PREFIX/.$BIN_NAME.prev"
if ! cp -p "$PREFIX/$BIN_NAME" "$BACKUP"; then
echo "ERROR: no se pudo respaldar el binario actual en $BACKUP" >&2
echo " Se aborta: actualizar sin poder revertir no es aceptable." >&2
exit 1
fi
echo "Respaldo del binario actual en $BACKUP"
fi
mkdir -p "$PREFIX"
if ! install -m 0755 "$SRC" "$PREFIX/$BIN_NAME"; then
echo "ERROR: no se pudo instalar el binario en $PREFIX/$BIN_NAME" >&2
# `install` desvincula el destino antes de crearlo, así que un binario EN USO no es el
# problema (eso es cosa de `cp`). Lo que falta casi siempre es permiso en el DIRECTORIO.
echo " Se necesita permiso de escritura en el directorio $PREFIX." >&2
echo " Dueño actual: $(stat -c '%U:%G %a' "$PREFIX" 2>/dev/null || echo 'desconocido')" >&2
echo " Usuario actual: $(id -un)" >&2
exit 1
fi
echo "Binario instalado en $PREFIX/$BIN_NAME"
# Bootstrap de config/: el propio binario crea config/, .env y carpetas de trabajo
# al inicio de su arranque. Se corre una vez en modo offscreen (sin display) y se
# corta con timeout; así el usuario ya puede editar config/.env antes de habilitar
# el servicio.
# En una actualización en sitio se OMITE: el binario hace ensure_runtime_layout() en cada arranque,
# así que es redundante, y correr una segunda instancia junto a la viva es peligroso —al arrancar,
# el motor purga todas las subcarpetas de Temp, que son de la instancia en curso—.
if [[ "$MODE" != "update-in-place" ]]; then
echo "Inicializando config/ (bootstrap)..."
# `</dev/null` no es cosmético: es el único hijo que heredaría el stdin del canal SSH cuando el
# instalador corre en remoto. Cerrárselo hace estructural —y no accidental— que nada de lo que
# venga por ese canal pueda ser consumido aquí.
( cd "$PREFIX" && QT_QPA_PLATFORM=offscreen timeout 20 "$PREFIX/$BIN_NAME" --headless </dev/null >/dev/null 2>&1 || true )
fi
if [[ -f "$PREFIX/config/.env" ]]; then
echo "config/.env creado."
else
echo "NOTA: config/.env se creará en la primera ejecución."
fi
# --- Siembra de credenciales del PANEL ----------------------------------------
# Fusión replace-or-append: respeta el resto de config/.env y es idempotente, así que
# reinstalar no duplica claves ni pierde ajustes locales. Se escribe vía archivo y no
# por argumentos para que el token no quede visible en `ps` ni en el historial.
merge_env_keys() {
local target="$1"; shift
local -a pairs=("$@")
local tmp line key pair matched
tmp="$(mktemp)"
if [[ -f "$target" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
matched=""
for pair in "${pairs[@]}"; do
key="${pair%%=*}"
if [[ "$line" =~ ^[[:space:]]*"$key"[[:space:]]*= ]]; then
matched="$pair"
break
fi
done
if [[ -n "$matched" ]]; then
printf '%s\n' "$matched" >> "$tmp"
else
printf '%s\n' "$line" >> "$tmp"
fi
done < "$target"
fi
for pair in "${pairs[@]}"; do
key="${pair%%=*}"
if ! grep -qE "^[[:space:]]*${key}[[:space:]]*=" "$tmp" 2>/dev/null; then
printf '%s\n' "$pair" >> "$tmp"
fi
done
# cat > preserva el inodo y los permisos 0600 del .env; mv los reemplazaría.
cat "$tmp" > "$target"
rm -f "$tmp"
}
if [[ -n "$PANEL_ENV_FILE" ]]; then
if [[ ! -f "$PANEL_ENV_FILE" ]]; then
echo "ERROR: no existe el archivo indicado en --panel-env-file: $PANEL_ENV_FILE" >&2
exit 1
fi
ENV_PATH="$PREFIX/config/.env"
touch "$ENV_PATH"
chmod 0600 "$ENV_PATH" 2>/dev/null || true
# Lista blanca: el archivo llega por la red y no debe poder inyectar otras claves.
ALLOWED_KEYS="CLOUDRESTORE_PANEL_API_URL CLOUDRESTORE_PANEL_API_TOKEN"
ALLOWED_KEYS="$ALLOWED_KEYS CLOUDRESTORE_PANEL_INSTANCE_KEY CLOUDRESTORE_PANEL_VERIFY_SSL"
PAIRS=()
while IFS= read -r raw || [[ -n "$raw" ]]; do
raw="${raw%$'\r'}" # tolera CRLF si vino de Windows
raw="${raw#"${raw%%[![:space:]]*}"}" # recorta espacios a la izquierda
if [[ -z "$raw" || "${raw:0:1}" == "#" || "$raw" != *=* ]]; then
continue
fi
key="${raw%%=*}"
key="${key//[[:space:]]/}"
value="${raw#*=}"
for allowed in $ALLOWED_KEYS; do
if [[ "$key" == "$allowed" ]]; then
PAIRS+=("${key}=${value}")
break
fi
done
done < "$PANEL_ENV_FILE"
if [[ "${#PAIRS[@]}" -gt 0 ]]; then
merge_env_keys "$ENV_PATH" "${PAIRS[@]}"
echo "${#PAIRS[@]} clave(s) del PANEL escritas en config/.env"
else
echo "AVISO: --panel-env-file no traía claves CLOUDRESTORE_PANEL_* válidas." >&2
fi
# El archivo trae el token en claro: se borra en cuanto se consumió.
rm -f "$PANEL_ENV_FILE"
fi
# --- Propiedad del árbol -------------------------------------------------------
# Corriendo con sudo, el bootstrap crea config/, config/data/app.db, config/logs/, Entrada/,
# Procesados/, Fallados/ y Temp/ como ROOT, y la siembra escribe config/.env en 0600 de root.
# Pero el unit se registra con User=$SERVICE_USER, así que el agente arrancaría sin poder leer su
# propia configuración ni escribir su base local: falla en bucle, y la sonda del panel lo pinta
# verde porque comprueba existencia y no lectura. Devolver la propiedad es lo que cierra eso.
#
# Solo el usuario, no el grupo: basta para que el agente escriba y evita sorpresas con el grupo
# primario del destino. Y `chown` no toca los modos, así que el 0600 del .env sobrevive.
chown_tree_is_safe() {
local path="$1"
# Un chown -R sobre una ruta de sistema sería catastrófico. El panel ya valida la ruta, pero
# este script también se corre a mano.
case "$path" in
/|/opt|/usr|/etc|/var|/home|/srv|/root|/bin|/sbin|/lib|/lib64|/tmp) return 1 ;;
esac
# Al menos dos componentes: /algo/otro.
case "${path#/}" in
*/*) return 0 ;;
*) return 1 ;;
esac
}
if [[ "$MODE" != "update-in-place" && "$(id -u)" -eq 0 && "$SERVICE_USER" != "root" ]]; then
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
echo "AVISO: el usuario '$SERVICE_USER' no existe en este servidor; no se cambia la" >&2
echo " propiedad de $PREFIX. Revisa SERVICE_USER." >&2
elif ! chown_tree_is_safe "$PREFIX"; then
echo "AVISO: $PREFIX es una ruta de sistema; no se hace chown -R sobre ella." >&2
echo " Ajústala a mano si el servicio corre como '$SERVICE_USER'." >&2
elif chown -R "$SERVICE_USER" "$PREFIX"; then
echo "Propiedad de $PREFIX asignada a $SERVICE_USER."
else
# No se asume que un comando de endurecimiento tuvo éxito: si falla, el agente no arranca
# y es mejor decirlo ahora que dejar un servicio reiniciándose en silencio.
echo "ERROR: no se pudo asignar la propiedad de $PREFIX a $SERVICE_USER." >&2
echo " El servicio corre como ese usuario y no podría leer config/.env ni escribir" >&2
echo " su base local. Corrígelo antes de arrancarlo:" >&2
echo " chown -R $SERVICE_USER $PREFIX" >&2
exit 1
fi
fi
case "$MODE" in
service)
UNIT_SRC="$SCRIPT_DIR/packaging/linux/$UNIT_NAME"
UNIT_DST="/etc/systemd/system/$UNIT_NAME"
if [[ ! -w "$(dirname "$UNIT_DST")" ]]; then
echo "ERROR: se requiere sudo para instalar el servicio systemd" >&2
exit 1
fi
sed -e "s|__USER__|$SERVICE_USER|g" \
-e "s|__WORKDIR__|$PREFIX|g" \
-e "s|__EXEC__|$PREFIX/$BIN_NAME|g" \
"$UNIT_SRC" > "$UNIT_DST"
systemctl daemon-reload
systemctl enable --now "$UNIT_NAME"
echo "Servicio systemd instalado y arrancado (usuario: $SERVICE_USER)."
echo " Estado : systemctl status cloudrestoreas"
echo " Logs : journalctl -u cloudrestoreas -f"
;;
user-service)
# Sin root: unit en el home y arranque con la instancia de systemd del propio usuario.
# Requisito previo: PREFIX escribible por este usuario (el panel lo apunta a su home).
UNIT_SRC="$SCRIPT_DIR/packaging/linux/cloudrestoreas-user.service"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
UNIT_DST="$UNIT_DIR/$UNIT_NAME"
if [[ ! -f "$UNIT_SRC" ]]; then
echo "ERROR: falta la plantilla del unit de usuario ($UNIT_SRC)" >&2
exit 1
fi
mkdir -p "$UNIT_DIR"
# Sin __USER__: un unit de usuario no admite User=, ya corre como su dueño.
sed -e "s|__WORKDIR__|$PREFIX|g" \
-e "s|__EXEC__|$PREFIX/$BIN_NAME|g" \
"$UNIT_SRC" > "$UNIT_DST"
STARTED=""
if "${SYSTEMCTL_USER[@]}" daemon-reload >/dev/null 2>&1 && \
"${SYSTEMCTL_USER[@]}" enable --now "$UNIT_NAME" >/dev/null 2>&1; then
STARTED="systemd-usuario"
# Sin lingering, la instancia de usuario muere al cerrar la última sesión y con ella el
# restaurador. Muchos polkit permiten habilitarlo para uno mismo; si no, no es fatal:
# se complementa con cron más abajo.
if loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then
echo "Lingering habilitado: el servicio sobrevive al cierre de sesión."
else
echo "AVISO: no se pudo habilitar lingering; se agrega respaldo por cron." >&2
STARTED="systemd-usuario+cron"
fi
else
echo "AVISO: systemd de usuario no disponible; se usará cron." >&2
fi
# Respaldo (o sustituto) por cron: @reboot para el arranque y un vigilante cada 5 min que
# cubre lo que en systemd hace Restart=always. El crontab del usuario no requiere root.
if [[ "$STARTED" != "systemd-usuario" ]]; then
if command -v crontab >/dev/null 2>&1; then
CRON_CMD="cd $PREFIX && QT_QPA_PLATFORM=offscreen $PREFIX/$BIN_NAME --start-engine --headless >/dev/null 2>&1 &"
CRON_TAG="# cloudrestoreas-autostart"
# Se reescribe el crontab completo filtrando las entradas propias anteriores, para que
# reinstalar no acumule duplicados.
{
crontab -l 2>/dev/null | grep -v "$CRON_TAG" || true
echo "@reboot $CRON_CMD $CRON_TAG"
echo "*/5 * * * * pgrep -f '$PGREP_PAT' >/dev/null || ($CRON_CMD) $CRON_TAG"
} | crontab -
STARTED="${STARTED:+$STARTED+}cron"
# El vigilante tarda hasta 5 min en actuar; se arranca ya para no dejar el hueco.
if ! pgrep -f "$PGREP_PAT" >/dev/null 2>&1; then
( cd "$PREFIX" && QT_QPA_PLATFORM=offscreen "$PREFIX/$BIN_NAME" --start-engine --headless </dev/null >/dev/null 2>&1 & )
fi
else
echo "ERROR: sin systemd de usuario y sin crontab no hay forma de dejarlo arrancado." >&2
echo " Instala cron, habilita lingering, o usa --service con privilegios." >&2
exit 1
fi
fi
echo "Servicio de usuario instalado y arrancado (mecanismo: $STARTED)."
echo " Estado : systemctl --user status cloudrestoreas"
echo " Logs : journalctl --user -u cloudrestoreas -f"
;;
update-in-place)
# El unit ya está registrado y no cambia: no se toca /etc, ni daemon-reload, ni enable.
# El binario nuevo ya está en su sitio; falta que el proceso lo tome.
if ! pgrep -f "$PGREP_PAT" >/dev/null 2>&1; then
echo "El agente no estaba corriendo; queda actualizado y systemd lo levantará."
else
# Segundo chequeo, inmediatamente antes de señalizar. El primero fue antes de copiar el
# binario y de sembrar el .env; en esos segundos pudo entrar una restauración, y matarla
# deja el respaldo vetado para siempre. Aquí ya no hay 270 MB de por medio: es barato.
if [[ -d "$PREFIX/Temp" ]] && [[ -n "$(ls -A "$PREFIX/Temp" 2>/dev/null)" ]]; then
echo "ERROR: entró una restauración mientras se actualizaba." >&2
echo " El binario nuevo YA está instalado y se activará en el próximo reinicio" >&2
echo " del agente; no se fuerza ahora para no interrumpirla." >&2
exit 75
fi
# `|| true` obligatorio: con pipefail, un pgrep sin resultados hace fallar la
# sustitución y `set -e` abortaría el script justo en el caso que hay que manejar.
OLD_PIDS="$(pgrep -f "$PGREP_PAT" | tr '\n' ' ' || true)"
echo "Señalizando al agente (PIDs: $OLD_PIDS) para que systemd lo relevante..."
# SIGTERM y no SIGKILL: si algún día el agente aprende a atender la señal, este camino ya
# le da la oportunidad de cerrar limpio.
pkill -TERM -f "$PGREP_PAT" >/dev/null 2>&1 || true
# Restart=always + RestartSec=5. Se espera con margen y se confirma que volvió: si el unit
# no tuviera Restart, matarlo lo dejaría muerto y eso NO puede pasar por bueno.
RESTARTED=0
for _ in $(seq 1 15); do
sleep 2
NEW_PID="$(pgrep -f "$PGREP_PAT" | head -1 || true)"
if [[ -n "$NEW_PID" ]] && [[ " $OLD_PIDS " != *" $NEW_PID "* ]]; then
RESTARTED=1
echo "Agente relevantado por systemd (PID $NEW_PID)."
break
fi
done
if [[ "$RESTARTED" -eq 0 ]]; then
# No volvió. Dejar el servidor sin agente no es una opción: se revierte al binario que
# sí funcionaba y se le da otra oportunidad a systemd.
echo "AVISO: el agente no volvió tras la señal; revirtiendo al binario anterior..." >&2
UNIT_STATE="$(systemctl is-active "$UNIT_NAME" 2>/dev/null || true)"
UNIT_RESTART="$(systemctl show -p Restart --value "$UNIT_NAME" 2>/dev/null || true)"
REVERTED=0
if [[ -n "$BACKUP" && -f "$BACKUP" ]]; then
if install -m 0755 "$BACKUP" "$PREFIX/$BIN_NAME"; then
for _ in $(seq 1 10); do
sleep 2
if pgrep -f "$PGREP_PAT" >/dev/null 2>&1; then REVERTED=1; break; fi
done
fi
fi
echo "ERROR: la actualización no dejó al agente corriendo." >&2
echo " Estado del unit: ${UNIT_STATE:-desconocido} (Restart=${UNIT_RESTART:-desconocido})" >&2
if [[ "${UNIT_RESTART}" != "always" ]]; then
echo " El unit no tiene Restart=always: nadie lo relevanta al terminar." >&2
elif [[ "$UNIT_STATE" == "failed" ]]; then
echo " systemd lo marcó como failed; puede haber agotado StartLimitBurst." >&2
echo " Reintentar con: systemctl reset-failed $UNIT_NAME && systemctl start $UNIT_NAME" >&2
fi
if [[ "$REVERTED" -eq 1 ]]; then
echo " REVERTIDO: el binario anterior está corriendo de nuevo. El servidor" >&2
echo " quedó como estaba; la versión nueva NO se aplicó." >&2
exit 1
fi
echo " NO se pudo revertir. El binario nuevo está en $PREFIX/$BIN_NAME y el" >&2
echo " anterior en ${BACKUP:-(sin respaldo)}. Hay que revisar el servidor a mano." >&2
exit 1
fi
# Solo tras confirmar que el agente nuevo corre se descarta el respaldo.
[[ -n "$BACKUP" && -f "$BACKUP" ]] && rm -f "$BACKUP" || true
fi
;;
desktop)
echo "Modo escritorio: la app registra su autostart .desktop al iniciarse."
echo "Ejecuta '$PREFIX/$BIN_NAME' en tu sesión gráfica."
;;
none)
echo "Instalación sin arranque automático."
echo "Ejecuta: QT_QPA_PLATFORM=offscreen $PREFIX/$BIN_NAME --start-engine --headless"
;;
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" && "$MODE" != "user-service" \
&& "$MODE" != "update-in-place" ]]; then
echo "Reiniciando $UNIT_NAME (estaba activo antes de la actualización)..."
systemctl start "$UNIT_NAME" >/dev/null 2>&1 || \
echo "AVISO: no se pudo reiniciar $UNIT_NAME; hazlo a mano." >&2
fi
echo ""
echo "Siguiente paso: edita $PREFIX/config/.env (CLOUDRESTORE_PANEL_*) y reinicia."
[[ "$MODE" == "service" ]] && echo " Tras editar: sudo systemctl restart cloudrestoreas"
[[ "$MODE" == "user-service" ]] && echo " Tras editar: systemctl --user restart cloudrestoreas"
echo "Listo."

View File

@@ -0,0 +1,209 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec onefile portable (ejecutar en Windows o Linux según destino)."""
import re
import sys
from pathlib import Path
block_cipher = None
ROOT = Path(SPECPATH).resolve().parent
platform = "windows" if sys.platform == "win32" else "linux"
bundled = ROOT / "packaging" / "bundled" / platform
def _read_app_version() -> str:
"""
Lee __version__ de app/__init__.py sin importar el paquete: el spec corre en el
intérprete de PyInstaller y no debe cargar las dependencias de la app.
"""
text = (ROOT / "app" / "__init__.py").read_text(encoding="utf-8")
match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
if not match:
raise SystemExit("No se pudo leer __version__ de app/__init__.py")
return match.group(1)
APP_VERSION = _read_app_version()
_assets = ROOT / "packaging" / "assets"
datas = [
(str(ROOT / "packaging" / "templates" / "env.default"), "packaging/templates"),
# bundled-versions.json va embebido para que el bootstrap sepa qué versiones de
# 7-Zip/ODBC trae este build y re-despliegue config/ cuando cambien.
(str(ROOT / "packaging" / "bundled-versions.json"), "packaging"),
]
if _assets.is_dir():
for _icon in _assets.iterdir():
if _icon.is_file() and _icon.suffix.lower() in (".png", ".ico"):
datas.append((str(_icon), "packaging/assets"))
binaries = []
seven_dir = bundled / "7zip"
if seven_dir.is_dir():
for item in seven_dir.iterdir():
if item.is_file():
binaries.append((str(item), "bundled/7zip"))
odbc_dir = bundled / "odbc"
if odbc_dir.is_dir():
for item in odbc_dir.rglob("*"):
if not item.is_file():
continue
if item.suffix.lower() in (".msi", ".deb"):
continue
rel = item.relative_to(odbc_dir)
dest = f"bundled/odbc/{rel.parent}".replace("\\", "/")
if str(rel.parent) == ".":
dest = "bundled/odbc"
binaries.append((str(item), dest))
# Libs de sistema Qt (cluster xcb/X11 + EGL) que el plugin xcb carga por dlopen.
# Van JUNTO a las librerías Qt (PySide6/Qt/lib): libQt6XcbQpa.so.6 tiene RPATH
# $ORIGIN, así que resuelve libxcb-cursor.so.0 y el resto en su propio directorio
# sin depender de LD_LIBRARY_PATH. (El stack ODBC va con el driver en bundled/odbc,
# con RPATH=$ORIGIN aplicado por download-bundled-deps.sh.) Solo aplica en Linux.
qt_sys_lib = bundled / "qt" / "lib"
if qt_sys_lib.is_dir():
for item in qt_sys_lib.iterdir():
if item.is_file() and not item.is_symlink():
binaries.append((str(item), "PySide6/Qt/lib"))
hiddenimports = [
"PySide6",
"pyodbc",
"requests",
"paramiko",
"dotenv",
"cryptography.fernet",
]
if platform == "windows":
hiddenimports.append("win32crypt")
from PyInstaller.utils.hooks import collect_all
pyside_datas, pyside_binaries, pyside_hidden = collect_all("PySide6")
datas += pyside_datas
binaries += pyside_binaries
hiddenimports += pyside_hidden
a = Analysis(
[str(ROOT / "runner.py")],
pathex=[str(ROOT)],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
# Icono del ejecutable (solo aplica en Windows; en Linux se ignora sin error).
_icon_file = ROOT / "packaging" / "assets" / "tray-icon.ico"
_icon = str(_icon_file) if _icon_file.is_file() else None
def _version_quad(version: str) -> tuple:
"""VSVersionInfo exige exactamente 4 enteros; se rellena o recorta lo que haga falta."""
parts = []
for chunk in version.split(".")[:4]:
digits = "".join(c for c in chunk if c.isdigit())
parts.append(int(digits) if digits else 0)
while len(parts) < 4:
parts.append(0)
return tuple(parts)
def _version_info_dir():
"""
Directorio donde escribir el recurso de versión.
Se prefiere `workpath`, el directorio de trabajo que PyInstaller inyecta en el namespace
del spec, por dos razones concretas de este proyecto:
1. build.ps1 lo redirige a %LOCALAPPDATA%\\Temp cuando el repo está en una ruta UNC
(\\\\wsl.localhost\\...), que es el caso al compilar Windows desde WSL.
2. `build/` en la raíz del repo lo crea el build de Linux DENTRO DE DOCKER, así que
queda propiedad de root; el Python de Windows escribe por SMB como el usuario
normal y recibe PermissionError.
"""
candidate = globals().get("workpath")
if candidate:
return Path(candidate)
return ROOT / "build"
def _write_version_info(version: str) -> str:
"""
Genera el recurso de versión de Windows. Sin esto el .exe sale sin metadatos y las
Propiedades del archivo no muestran nada, lo que complica auditar qué versión está
instalada en un servidor.
"""
quad = _version_quad(version)
out = _version_info_dir() / "version_info.txt"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(
f"""VSVersionInfo(
ffi=FixedFileInfo(
filevers={quad},
prodvers={quad},
mask=0x3f,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0),
),
kids=[
StringFileInfo([
StringTable('080904B0', [
StringStruct('CompanyName', 'Aduanasoft'),
StringStruct('FileDescription', 'CloudRestoreAS - Restauracion automatica SQL Server'),
StringStruct('FileVersion', '{version}'),
StringStruct('InternalName', 'CloudRestoreAS'),
StringStruct('LegalCopyright', 'Aduanasoft'),
StringStruct('OriginalFilename', 'CloudRestoreAS.exe'),
StringStruct('ProductName', 'CloudRestoreAS'),
StringStruct('ProductVersion', '{version}'),
])
]),
VarFileInfo([VarStruct('Translation', [0x0809, 1200])]),
],
)
""",
encoding="utf-8",
)
return str(out)
# El recurso de versión solo existe en PE/Windows; en Linux PyInstaller lo ignoraría.
_version_info = _write_version_info(APP_VERSION) if platform == "windows" else None
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="CloudRestoreAS",
icon=_icon,
version=_version_info,
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

40
packaging/LEEME.txt Normal file
View File

@@ -0,0 +1,40 @@
CloudRestoreAS — despliegue portable
=====================================
El ejecutable es AUTOCONTENIDO: no requiere Python, ni 7-Zip, ni driver ODBC, ni
librerias Qt instaladas en el equipo destino. Todo va embebido dentro del binario.
Windows: copie CloudRestoreAS.exe a la carpeta deseada.
Linux: copie CloudRestoreAS y ejecute chmod +x CloudRestoreAS si hace falta.
1. Ejecute el programa (doble clic o ./CloudRestoreAS).
- Se abre la ventana principal y un icono en la bandeja del sistema.
- El motor arranca automaticamente (CLOUDRESTORE_AUTO_START=true por defecto).
2. Se crean automaticamente: config/, Entrada/, Procesados/, Fallados/, Temp/
3. Edite config/.env con la URL, token e instancia del servicio de bases.
4. Reinicie la aplicacion si cambio la configuracion.
Cerrar la ventana (X) minimiza a la bandeja; la app sigue en ejecucion.
Para salir por completo: clic derecho en el icono de bandeja -> Salir,
o menu Archivo -> Salir.
-------------------------------------
Linux en SERVIDOR SIN ESCRITORIO (headless)
-------------------------------------
En un servidor sin pantalla (sin DISPLAY), la app arranca automaticamente en modo
"offscreen": no muestra ventana pero el motor de restauracion trabaja igual. No hay
que instalar nada del sistema.
Opcion A - servicio 24/7 (recomendado en servidor SQL):
sudo ./install.sh --service
(instala una unit systemd que corre el binario con --start-engine --headless;
ver estado: systemctl status cloudrestoreas ; logs: journalctl -u cloudrestoreas -f)
Opcion B - manual:
QT_QPA_PLATFORM=offscreen ./CloudRestoreAS --start-engine --headless
Opcion C - escritorio Linux (con pantalla):
./install.sh --desktop (registra autostart .desktop; requiere sesion grafica)
El servidor SQL destino y su carpeta de datos (data_folder) se toman del PANEL;
con SQL Server sobre Linux use rutas POSIX (p. ej. /var/opt/mssql/data).

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,9 @@
{
"seven_zip": "26.01",
"seven_zip_windows_extra": "https://github.com/ip7z/7zip/releases/download/26.01/7z2601-extra.7z",
"seven_zip_windows_7zr": "https://github.com/ip7z/7zip/releases/download/26.01/7zr.exe",
"seven_zip_linux_x64": "https://github.com/ip7z/7zip/releases/download/26.01/7z2601-linux-x64.tar.xz",
"msodbcsql18_msi": "https://go.microsoft.com/fwlink/?linkid=2281204",
"msodbcsql18_deb": "https://packages.microsoft.com/ubuntu/22.04/prod/pool/main/m/msodbcsql18/msodbcsql18_18.5.1.1-1_amd64.deb",
"unixodbc_deb": "http://archive.ubuntu.com/ubuntu/pool/main/u/unixodbc/unixodbc_2.3.12-1ubuntu0.24.04.1_amd64.deb"
}

View File

@@ -0,0 +1,22 @@
[Unit]
Description=CloudRestoreAS - Restauración automática SQL Server (servicio de usuario)
# Sin After=mssql-server.service: un unit de usuario no puede ordenarse respecto de units del
# sistema. El agente reintenta la conexión a SQL Server, así que arrancar antes no lo rompe.
After=network-online.target
[Service]
Type=simple
# Sin User=: en un unit de usuario lo prohíbe systemd (ya corre como el dueño de la sesión).
# Los marcadores de abajo los sustituye install.sh al instalar; no nombrarlos en los comentarios,
# porque el sed los reemplazaría aquí también y dejaría rutas absolutas en un texto sin sentido.
WorkingDirectory=__WORKDIR__
# Servidor headless: plataforma Qt offscreen (sin display) y motor auto-inicio.
Environment=QT_QPA_PLATFORM=offscreen
ExecStart=__EXEC__ --start-engine --headless
Restart=always
RestartSec=5
# El binario es autocontenido (Qt, ODBC, 7-Zip embebidos): no necesita libs del sistema.
[Install]
# default.target, no multi-user.target: en la instancia de usuario ese es el objetivo de arranque.
WantedBy=default.target

View File

@@ -0,0 +1,19 @@
[Unit]
Description=CloudRestoreAS - Restauración automática SQL Server
After=network-online.target mssql-server.service
Wants=network-online.target
[Service]
Type=simple
# __USER__ y __EXEC__ los sustituye install.sh al instalar.
User=__USER__
WorkingDirectory=__WORKDIR__
# Servidor headless: plataforma Qt offscreen (sin display) y motor auto-inicio.
Environment=QT_QPA_PLATFORM=offscreen
ExecStart=__EXEC__ --start-engine --headless
Restart=always
RestartSec=5
# El binario es autocontenido (Qt, ODBC, 7-Zip embebidos): no necesita libs del sistema.
[Install]
WantedBy=multi-user.target

6
packaging/scripts/build-all.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Wrapper de compatibilidad: el orquestador canónico vive en la raíz del repo.
# Ejecuta ./build-all.sh (desde WSL) para generar Windows + Linux + paquetes.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
exec bash "$ROOT/build-all.sh" "$@"

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Build Linux onefile dentro de Docker con dependencias Qt del sistema.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
docker run --rm \
-v "$ROOT:/app" \
-w /app \
ubuntu:22.04 bash -c '
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
# Herramientas de build + libs de sistema para que PyInstaller EMBEBA el cierre
# completo de dependencias (plugin Qt xcb y driver MS ODBC). Cinturón y tirantes
# junto con el bundle explícito de download-bundled-deps.sh.
apt-get install -y -qq \
python3 python3-venv python3-pip curl dpkg-dev binutils apt-utils patchelf \
unixodbc libodbcinst2 libltdl7 libkrb5-3 libgssapi-krb5-2 libssl3 \
libglib2.0-0 libdbus-1-3 libfontconfig1 libfreetype6 \
libxkbcommon0 libxkbcommon-x11-0 libegl1 libgl1 libglvnd0 \
libx11-6 libx11-xcb1 libxext6 libxrender1 libxi6 libxfixes3 libxtst6 \
libxcomposite1 libxdamage1 libxrandr2 libxkbfile1 \
libxcb1 libxcb-cursor0 libxcb-icccm4 libxcb-util1 libxcb-image0 \
libxcb-keysyms1 libxcb-render-util0 libxcb-render0 libxcb-shape0 \
libxcb-xkb1 libxcb-glx0 libxcb-randr0 libxcb-shm0 libxcb-sync1 \
> /dev/null
chmod +x build.sh packaging/scripts/download-bundled-deps.sh
./build.sh
'

View File

@@ -0,0 +1,93 @@
# Descarga 7-Zip Extra y ODBC para empaquetar en CloudRestoreAS.exe
$ErrorActionPreference = "Stop"
$Root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$Versions = Get-Content (Join-Path $Root "packaging\bundled-versions.json") | ConvertFrom-Json
$Bundled = Join-Path $Root "packaging\bundled\windows"
$Seven = Join-Path $Bundled "7zip"
$Odbc = Join-Path $Bundled "odbc"
New-Item -ItemType Directory -Force -Path $Seven, $Odbc | Out-Null
Write-Host "==> 7-Zip Extra ($($Versions.seven_zip))" -ForegroundColor Cyan
if (-not (Test-Path (Join-Path $Seven "7z.exe"))) {
$tmp = Join-Path $env:TEMP "cloudrestore-7z-$(Get-Random)"
New-Item -ItemType Directory -Path $tmp | Out-Null
$sevenR = Join-Path $tmp "7zr.exe"
$extra = Join-Path $tmp "7z-extra.7z"
Invoke-WebRequest -Uri $Versions.seven_zip_windows_7zr -OutFile $sevenR
Invoke-WebRequest -Uri $Versions.seven_zip_windows_extra -OutFile $extra
& $sevenR x $extra ("-o" + $Seven) -y | Out-Null
$x64Exe = Join-Path $Seven "x64\7za.exe"
$x64Dll = Join-Path $Seven "x64\7za.dll"
if (Test-Path $x64Exe) {
Copy-Item $x64Exe (Join-Path $Seven "7z.exe") -Force
Copy-Item $x64Exe (Join-Path $Seven "7za.exe") -Force
}
if (Test-Path $x64Dll) {
Copy-Item $x64Dll (Join-Path $Seven "7z.dll") -Force
Copy-Item $x64Dll (Join-Path $Seven "7za.dll") -Force
}
Remove-Item -Recurse -Force $tmp
Write-Host " 7z.exe en $Seven"
} else {
Write-Host " 7-Zip ya existe"
}
Write-Host "==> ODBC Driver 18 (extraer DLLs del MSI)" -ForegroundColor Cyan
$Msi = Join-Path $Odbc "msodbcsql18.msi"
if (-not (Test-Path (Join-Path $Odbc "msodbcsql18.dll"))) {
if (-not (Test-Path $Msi)) {
Invoke-WebRequest -Uri $Versions.msodbcsql18_msi -OutFile $Msi
}
$extract = Join-Path $Odbc "_msi_extract"
if (Test-Path $extract) { Remove-Item -Recurse -Force $extract }
New-Item -ItemType Directory -Path $extract | Out-Null
$msiArgs = @("/a", "`"$Msi`"", "/qn", "TARGETDIR=`"$extract`"", "IACCEPTMSODBCSQLLICENSETERMS=YES")
$proc = Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -PassThru
if ($proc.ExitCode -ne 0) {
Write-Host " msiexec /a fallo (codigo $($proc.ExitCode)); intentando instalacion silenciosa local..." -ForegroundColor Yellow
$local = Join-Path $Odbc "driver_install"
New-Item -ItemType Directory -Force -Path $local | Out-Null
Start-Process msiexec.exe -ArgumentList "/i", "`"$Msi`"", "/qn", "IACCEPTMSODBCSQLLICENSETERMS=YES", "ADDLOCAL=ALL" -Wait
$sysOdbc = "${env:ProgramFiles}\Microsoft SQL Server\Client SDK\ODBC\170\Tools\Binn"
if (Test-Path $sysOdbc) {
Get-ChildItem $sysOdbc -Filter "*.dll" | Copy-Item -Destination $Odbc -Force
}
$driverDir = "${env:ProgramFiles}\Microsoft SQL Server\Client SDK\ODBC\180\Tools\Binn"
if (Test-Path $driverDir) {
Get-ChildItem $driverDir -Filter "*.dll" | Copy-Item -Destination $Odbc -Force
}
foreach ($dll in @("msodbcsql18.dll", "msodbcsql17.dll")) {
$sys = Join-Path $env:SystemRoot "System32\$dll"
if (Test-Path $sys) { Copy-Item $sys $Odbc -Force }
}
} else {
Get-ChildItem -Path $extract -Recurse -Filter "msodbcsql*.dll" | ForEach-Object {
Copy-Item $_.FullName $Odbc -Force
}
Get-ChildItem -Path $extract -Recurse -Filter "*.dll" | Where-Object {
$_.Name -match "mso|odbc|ssl|crypto|bcp"
} | ForEach-Object { Copy-Item $_.FullName $Odbc -Force -ErrorAction SilentlyContinue }
Remove-Item -Recurse -Force $extract -ErrorAction SilentlyContinue
}
Write-Host " DLLs ODBC en $Odbc"
} else {
Write-Host " ODBC ya existe"
}
if (-not (Test-Path (Join-Path $Odbc "odbcinst.ini"))) {
@"
[ODBC Driver 18 for SQL Server]
Description=Microsoft ODBC Driver 18 for SQL Server
Driver=msodbcsql18.dll
UsageCount=1
[ODBC Driver 17 for SQL Server]
Description=Microsoft ODBC Driver 17 for SQL Server
Driver=msodbcsql17.dll
UsageCount=1
"@ | Set-Content (Join-Path $Odbc "odbcinst.ini") -Encoding ASCII
"[ODBC Data Sources]" | Set-Content (Join-Path $Odbc "odbc.ini") -Encoding ASCII
}
Write-Host "Listo: $Bundled" -ForegroundColor Green

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# Descarga y EMBEBE (build-time) todo lo que el binario Linux necesita en runtime:
# - 7zz (7-Zip para Linux)
# - Driver MS ODBC 18 + unixODBC
# - Libs de sistema de Qt (cluster xcb/X11 + EGL) y del stack ODBC
# (libltdl, Kerberos/GSSAPI, OpenSSL) que PySide6/pyodbc cargan por dlopen
# y que NO vienen dentro del wheel.
# Objetivo: que el onefile corra en un servidor Linux "pelado" sin instalar nada.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
VERSIONS="$ROOT/packaging/bundled-versions.json"
BUNDLED="$ROOT/packaging/bundled/linux"
SEVEN="$BUNDLED/7zip"
ODBC="$BUNDLED/odbc/lib"
QTLIB="$BUNDLED/qt/lib" # libs de sistema extra a embeder en la raíz de _MEIPASS
mkdir -p "$SEVEN" "$ODBC" "$QTLIB"
read_url() {
python3 -c "import json; print(json.load(open('$VERSIONS'))['$1'])"
}
# Descarga uno o más paquetes .deb (y NO sus dependencias), extrae sus .so y los
# copia al directorio destino con el NOMBRE DE SONAME (lo que busca el loader:
# libfoo.so.N), dereferenciando symlinks para no dejar enlaces colgantes.
# Requiere apt-get/dpkg-deb.
fetch_pkg_libs() {
local destdir="$1"; shift
if ! command -v apt-get >/dev/null 2>&1; then
echo " (apt-get no disponible; se omite descarga de $* — el entorno de build debe proveerlas)"
return 0
fi
local tmp; tmp=$(mktemp -d)
(
cd "$tmp"
apt-get download "$@" >/dev/null 2>&1 || {
# Reintento tras refrescar índices (build env con red).
apt-get update -qq >/dev/null 2>&1 || true
apt-get download "$@" >/dev/null 2>&1 || true
}
for deb in *.deb; do
[[ -f "$deb" ]] || continue
dpkg-deb -x "$deb" x
done
[[ -d x ]] || exit 0
# 1) Symlinks SONAME del propio paquete (libfoo.so.N -> libfoo.so.N.M.P):
# copiar el contenido REAL bajo el nombre del symlink (= SONAME exacto).
while IFS= read -r link; do
cp -Lf "$link" "$destdir/$(basename "$link")" 2>/dev/null || true
done < <(find x -type l -name '*.so.*')
# 2) Archivos reales sin symlink: derivar SONAME por truncación de versión
# (libfoo.so.N.M.P -> libfoo.so.N) para no dejarlos con nombre versionado.
while IFS= read -r f; do
local soname
soname=$(basename "$f" | sed -E 's/(\.so\.[0-9]+)\..*/\1/')
[[ -e "$destdir/$soname" ]] || cp -Lf "$f" "$destdir/$soname" 2>/dev/null || true
done < <(find x -type f -name '*.so.*')
)
rm -rf "$tmp"
}
echo "==> 7-Zip Linux (7zz)"
LINUX_7Z_URL="$(read_url seven_zip_linux_x64)"
if [[ ! -f "$SEVEN/7zz" ]]; then
TMP=$(mktemp -d)
curl -fsSL -o "$TMP/7z.tar.xz" "$LINUX_7Z_URL"
tar -xJf "$TMP/7z.tar.xz" -C "$TMP"
cp "$TMP/7zz" "$SEVEN/7zz"
chmod +x "$SEVEN/7zz"
rm -rf "$TMP"
echo " 7zz instalado"
else
echo " 7zz ya existe"
fi
echo "==> ODBC Driver 18 + unixODBC"
MSODBC_DEB="$(read_url msodbcsql18_deb)"
if [[ -z "$(ls -A "$ODBC" 2>/dev/null || true)" ]]; then
TMP=$(mktemp -d)
cd "$TMP"
curl -fsSL -o msodbcsql18.deb "$MSODBC_DEB"
if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq || true
apt-get install -y -qq unixodbc dpkg-dev > /dev/null 2>&1 || true
if dpkg -l unixodbc 2>/dev/null | grep -q ^ii; then
# -L: dereferencia symlinks para no dejar enlaces colgantes en el destino.
find /usr/lib -name 'libodbc*.so*' -exec cp -Lf {} "$ODBC/" \; 2>/dev/null || true
find /usr/lib -name 'libodbcinst*.so*' -exec cp -Lf {} "$ODBC/" \; 2>/dev/null || true
fi
fi
dpkg-deb -x msodbcsql18.deb msodbcsql
# Copiar SOLO el archivo real versionado del driver (evita el symlink -> /opt).
find msodbcsql -type f -name 'libmsodbcsql-18*.so*' -exec cp -Lf {} "$ODBC/" \;
cd "$ROOT"
rm -rf "$TMP"
# Stack de dependencias del driver MS ODBC 18, JUNTO al driver, para que un
# servidor pelado (sin unixODBC/krb5/openssl) pueda cargarlo. El driver se
# patchelf-ea a RPATH=$ORIGIN (más abajo) para resolverlas en su propio dir.
ODBC_DEP_PKGS=(
libltdl7 libkrb5-3 libgssapi-krb5-2 libk5crypto3 libkrb5support0
libcom-err2 libkeyutils1 libssl3 libodbc2 libodbcinst2
)
fetch_pkg_libs "$ODBC" "${ODBC_DEP_PKGS[@]}" || true
fetch_pkg_libs "$ODBC" unixodbc || true
echo " Bibliotecas ODBC + deps en $ODBC"
else
echo " ODBC ya existe"
fi
# RPATH=$ORIGIN en TODAS las .so del cluster ODBC: así el driver y sus deps
# (libkrb5 -> libk5crypto/libkrb5support/libkeyutils, libgssapi, libltdl,
# libodbcinst, openssl) se resuelven entre sí en su propio directorio una vez
# copiados a config/odbc/lib, sin depender de LD_LIBRARY_PATH.
if command -v patchelf >/dev/null 2>&1; then
for so in "$ODBC"/*.so*; do
[[ -f "$so" && ! -L "$so" ]] || continue
patchelf --set-rpath '$ORIGIN' "$so" 2>/dev/null || true
done
echo " RPATH=\$ORIGIN aplicado a todo el cluster ODBC"
else
echo " ADVERTENCIA: patchelf no disponible; el driver ODBC podría no resolver" >&2
echo " sus deps en un servidor sin unixODBC/krb5. Instálalo en el entorno de build." >&2
fi
echo "==> Libs de sistema Qt (cluster xcb/X11 + EGL) para la GUI con display"
if [[ -z "$(ls -A "$QTLIB" 2>/dev/null || true)" ]]; then
# Plataforma Qt xcb + su cierre X11/xcb + EGL. Van junto a las libs Qt en el
# onefile (PySide6/Qt/lib) para que libQt6XcbQpa las encuentre por $ORIGIN.
QT_XCB_PKGS=(
libxcb-cursor0 libxcb-icccm4 libxcb-util1 libxcb-image0 libxcb-keysyms1
libxcb-render-util0 libxcb-render0 libxcb-shape0 libxcb-xkb1
libxkbcommon-x11-0 libxkbcommon0 libxtst6 libxcomposite1 libxdamage1
libxrandr2 libxkbfile1 libxcb1 libx11-6 libx11-xcb1 libxext6 libxrender1
libxi6 libxfixes3 libxcb-glx0 libxcb-randr0 libxcb-shm0 libxcb-sync1
libxcb-present0 libxcb-dri2-0 libxcb-dri3-0 libxcb-xfixes0 libxau6
libxdmcp6 libbsd0 libsm6 libice6
)
QT_GL_PKGS=(libglvnd0 libegl1 libgl1 libopengl0 libglx0 libgles2)
fetch_pkg_libs "$QTLIB" "${QT_XCB_PKGS[@]}"
fetch_pkg_libs "$QTLIB" "${QT_GL_PKGS[@]}"
COUNT=$(find "$QTLIB" -type f -name '*.so*' 2>/dev/null | wc -l)
echo " $COUNT libs Qt embebidas en $QTLIB"
if [[ "$COUNT" -eq 0 ]]; then
echo " ADVERTENCIA: no se embebió ninguna lib Qt. El entorno de build" >&2
echo " (docker-build-linux.sh) debe traerlas para que PyInstaller las incluya." >&2
fi
else
echo " Libs Qt ya existen ($(find "$QTLIB" -type f -name '*.so*' | wc -l) archivos)"
fi
echo "Listo: $BUNDLED"

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Genera el icono de marca de CloudRestoreAS sin dependencias externas.
Dibuja una "C" (anillo con apertura a la derecha) sobre un cuadro redondeado azul
(#0078D7), con anti-aliasing por supersampling. Codifica los PNG a mano (zlib + CRC)
y ensambla un .ico multi-tamano (entradas PNG, soportadas por Windows Vista+).
Salida:
packaging/assets/tray-icon.png (256x256)
packaging/assets/tray-icon.ico (16,32,48,64,128,256)
Uso: python3 packaging/scripts/make-icons.py
Solo usa la libreria estandar; corre con cualquier Python 3.x.
"""
import math
import struct
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
ASSETS = ROOT / "packaging" / "assets"
ICO_SIZES = (16, 32, 48, 64, 128, 256)
PNG_SIZE = 256
# Colores
BG = (0, 120, 215) # azul #0078D7
FG = (255, 255, 255) # blanco
SS = 4 # factor de supersampling (anti-aliasing)
# Geometria (fracciones del lado)
CORNER = 0.22 # radio de esquina del cuadro
RING_OUTER = 0.34 # radio externo de la "C"
RING_INNER = 0.20 # radio interno de la "C"
GAP_HALF_DEG = 40.0 # medio angulo de la apertura de la "C" (a la derecha)
def _render_rgba(size: int) -> bytes:
"""Renderiza el icono a un buffer RGBA de size*size (4 bytes por pixel)."""
cx = cy = size / 2.0
half = size / 2.0
rc = CORNER * size
inner = RING_INNER * size
outer = RING_OUTER * size
cos_gap = math.cos(math.radians(GAP_HALF_DEG))
inv = 1.0 / (SS * SS)
out = bytearray(size * size * 4)
k = 0
for py in range(size):
for px in range(size):
r_acc = g_acc = b_acc = a_acc = 0.0
for sy in range(SS):
y = py + (sy + 0.5) / SS
dyc = y - cy
for sx in range(SS):
x = px + (sx + 0.5) / SS
# Cuadro redondeado (SDF): dentro si d <= 0
bx = abs(x - cx) - (half - rc)
by = abs(y - cy) - (half - rc)
mx = bx if bx > 0.0 else 0.0
my = by if by > 0.0 else 0.0
d = math.hypot(mx, my) + min(max(bx, by), 0.0) - rc
if d > 0.0:
continue # fuera del cuadro -> transparente
# Dentro del cuadro: base azul
dxc = x - cx
rr = math.hypot(dxc, dyc)
in_ring = inner <= rr <= outer
in_gap = dxc > rr * cos_gap # apertura a la derecha
if in_ring and not in_gap:
r_acc += FG[0]; g_acc += FG[1]; b_acc += FG[2]
else:
r_acc += BG[0]; g_acc += BG[1]; b_acc += BG[2]
a_acc += 255.0
out[k] = int(r_acc * inv + 0.5)
out[k + 1] = int(g_acc * inv + 0.5)
out[k + 2] = int(b_acc * inv + 0.5)
out[k + 3] = int(a_acc * inv + 0.5)
k += 4
return bytes(out)
def _png_chunk(tag: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data))
+ tag
+ data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
)
def _encode_png(size: int, rgba: bytes) -> bytes:
"""Codifica un buffer RGBA a bytes PNG (8 bits, color type 6)."""
ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
stride = size * 4
raw = bytearray()
for row in range(size):
raw.append(0) # filtro None por scanline
raw += rgba[row * stride:(row + 1) * stride]
idat = zlib.compress(bytes(raw), 9)
return (
b"\x89PNG\r\n\x1a\n"
+ _png_chunk(b"IHDR", ihdr)
+ _png_chunk(b"IDAT", idat)
+ _png_chunk(b"IEND", b"")
)
def _build_ico(pngs: dict) -> bytes:
"""Ensambla un .ico con entradas PNG. pngs: {size: png_bytes}."""
sizes = sorted(pngs)
count = len(sizes)
header = struct.pack("<HHH", 0, 1, count)
offset = 6 + count * 16
entries = bytearray()
body = bytearray()
for size in sizes:
data = pngs[size]
entries += struct.pack(
"<BBBBHHII",
size if size < 256 else 0, # ancho (0 == 256)
size if size < 256 else 0, # alto
0, # paleta
0, # reservado
1, # planos
32, # bits por pixel
len(data),
offset,
)
body += data
offset += len(data)
return bytes(header + entries + body)
def main() -> None:
ASSETS.mkdir(parents=True, exist_ok=True)
pngs = {}
for size in ICO_SIZES:
rgba = _render_rgba(size)
pngs[size] = _encode_png(size, rgba)
print(f" render {size}x{size} -> {len(pngs[size])} bytes PNG")
png_path = ASSETS / "tray-icon.png"
png_path.write_bytes(pngs[PNG_SIZE] if PNG_SIZE in pngs else _encode_png(PNG_SIZE, _render_rgba(PNG_SIZE)))
print(f"OK {png_path}")
ico_path = ASSETS / "tray-icon.ico"
ico_path.write_bytes(_build_ico(pngs))
print(f"OK {ico_path} ({len(ICO_SIZES)} tamanos)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env bash
# 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
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
RELEASE="$ROOT/dist/release"
mkdir -p "$RELEASE"
RED='\033[31m'; NC='\033[0m'
err() { echo -e "${RED}ERROR:${NC} $*" >&2; }
# --- Versión: fuente única en app/__init__.py --------------------------------------
VERSION="$(
python3 - "$ROOT/app/__init__.py" <<'PY'
import re, sys
text = open(sys.argv[1], encoding="utf-8").read()
m = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.M)
if not m:
sys.exit("No se pudo leer __version__ de app/__init__.py")
version = m.group(1)
# El PANEL ordena versiones comparándolas como tuplas de enteros; un formato distinto
# rompería la detección de "hay versión nueva".
if not re.fullmatch(r"\d+(\.\d+){1,3}", version):
sys.exit(f"__version__ inválida: '{version}' (se espera puntos y números, p.ej. 1.2.0)")
print(version)
PY
)"
# Los builds del org corren en amd64 (Docker para Linux, host de desarrollo para
# Windows); no hay forma de inferir el arch del .exe desde aquí, así que se declara.
ARCH="${CLOUDRESTORE_TARGET_ARCH:-x86_64}"
LINUX_PKG="CloudRestoreAS-${VERSION}-linux-${ARCH}.tar.gz"
WIN_PKG="CloudRestoreAS-${VERSION}-win-${ARCH}.zip"
echo "Empaquetando CloudRestoreAS ${VERSION} (${ARCH})"
# Los paquetes de esta versión se regeneran; los de otras versiones se conservan.
rm -f "$RELEASE/$LINUX_PKG" "$RELEASE/$WIN_PKG" "$RELEASE/SHA256SUMS" "$RELEASE/release.json"
# Las copias crudas se borran SIEMPRE antes de recrearlas. Si no, una corrida anterior deja
# un binario en dist/release/ que se empaqueta como si fuera de esta versión aunque el build
# de ahora haya fallado: así se generó una vez un zip "1.1.0" con un .exe parcial de 4.9 MB,
# con su sha256 y su release.json perfectamente consistentes — el peor tipo de fallo, porque
# la verificación de integridad no lo detecta.
rm -f "$RELEASE/CloudRestoreAS.exe" "$RELEASE/CloudRestoreAS-linux"
# --- Validación de los binarios recién compilados ---------------------------------
# Piso de tamaño: un onefile real lleva Qt + driver ODBC + 7-Zip embebidos, así que pesa
# cientos de MB. Cualquier cosa por debajo de esto es un build truncado, no un binario.
MIN_MB="${CLOUDRESTORE_MIN_BINARY_MB:-25}"
VERSION_FILE="$ROOT/app/__init__.py"
check_binary() {
# $1 = ruta del binario, $2 = etiqueta para los mensajes
local path="$1" label="$2"
[[ -f "$path" ]] || return 1
local size_mb
size_mb=$(( $(stat -c %s "$path") / 1024 / 1024 ))
if [[ "$size_mb" -lt "$MIN_MB" ]]; then
err "el binario $label pesa ${size_mb} MB (mínimo esperado ${MIN_MB} MB): build truncado"
err " $path"
return 2
fi
# Un binario anterior al último cambio de __version__ pertenece a otra versión. Empaquetarlo
# con el nombre de la versión actual publicaría una mentira.
if [[ "$VERSION_FILE" -nt "$path" ]]; then
err "el binario $label es MÁS VIEJO que app/__init__.py: quedó de una versión anterior"
err " recompila esa plataforma antes de empaquetar, o borra $path"
return 2
fi
return 0
}
HAVE_LINUX=0
HAVE_WINDOWS=0
FATAL=0
# El origen de verdad es dist/, que es donde escribe PyInstaller. Nunca dist/release/.
# El `|| rc=$?` es necesario: bajo `set -e`, una función que devuelve != 0 como comando
# suelto aborta el script de inmediato, sin llegar a evaluar FATAL ni a empaquetar la
# plataforma que sí compiló.
rc=0; check_binary "$ROOT/dist/CloudRestoreAS" "Linux" || rc=$?
case "$rc" in
0) HAVE_LINUX=1 ;;
2) FATAL=1 ;;
esac
rc=0; check_binary "$ROOT/dist/CloudRestoreAS.exe" "Windows" || rc=$?
case "$rc" in
0) HAVE_WINDOWS=1 ;;
2) FATAL=1 ;;
esac
if [[ "$FATAL" -eq 1 ]]; then
err "empaquetado abortado para no publicar un artefacto que no corresponde a $VERSION"
exit 1
fi
# --- Copias crudas (verificación local, no se publican) ---------------------------
if [[ "$HAVE_WINDOWS" -eq 1 ]]; then
cp -f "$ROOT/dist/CloudRestoreAS.exe" "$RELEASE/"
fi
if [[ "$HAVE_LINUX" -eq 1 ]]; then
cp -f "$ROOT/dist/CloudRestoreAS" "$RELEASE/CloudRestoreAS-linux"
chmod +x "$RELEASE/CloudRestoreAS-linux"
fi
cp -f "$ROOT/packaging/LEEME.txt" "$RELEASE/"
# --- Paquete Windows ---------------------------------------------------------------
# install.ps1 es el instalador de despliegue (autocontenido). El script de entorno de
# desarrollo vive en scripts/dev-setup.ps1 y NO va en el paquete: antes se empaquetaba
# por error, dejando en el zip un instalador que pedía Python y creaba un venv.
if [[ "$HAVE_WINDOWS" -eq 1 ]]; then
ROOT="$ROOT" RELEASE="$RELEASE" WIN_PKG="$WIN_PKG" python3 <<'PY'
import os, zipfile
from pathlib import Path
root = Path(os.environ["ROOT"])
release = Path(os.environ["RELEASE"])
# El .exe se toma de dist/, que es donde escribe PyInstaller. Tomarlo de dist/release/
# permitía empaquetar la copia de una corrida anterior.
items = [
(root / "dist" / "CloudRestoreAS.exe", "CloudRestoreAS/CloudRestoreAS.exe"),
(root / "install.ps1", "CloudRestoreAS/install.ps1"),
(root / "packaging" / "LEEME.txt", "CloudRestoreAS/LEEME.txt"),
]
missing = [str(src) for src, _ in items if not src.is_file()]
if missing:
raise SystemExit("Faltan archivos para el paquete Windows: " + ", ".join(missing))
out = release / os.environ["WIN_PKG"]
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
for src, arc in items:
z.write(src, arc)
print(f" zip Windows: {out.name}")
PY
fi
# --- Paquete Linux -----------------------------------------------------------------
if [[ "$HAVE_LINUX" -eq 1 ]]; then
# Paquete de despliegue completo: binario + instalador + unit systemd + LEEME, bajo
# una carpeta raíz para que extraiga limpio. install.sh encuentra el binario
# (CloudRestoreAS-linux) y la unit (packaging/linux/) relativos a su ubicación.
PKG="$RELEASE/pkg-linux/CloudRestoreAS"
rm -rf "$RELEASE/pkg-linux"
mkdir -p "$PKG/packaging/linux"
cp -f "$ROOT/dist/CloudRestoreAS" "$PKG/CloudRestoreAS-linux"
chmod +x "$PKG/CloudRestoreAS-linux"
cp -f "$ROOT/install.sh" "$PKG/install.sh"
chmod +x "$PKG/install.sh"
cp -f "$ROOT/packaging/linux/cloudrestoreas.service" "$PKG/packaging/linux/"
cp -f "$ROOT/packaging/LEEME.txt" "$PKG/LEEME.txt"
tar czf "$RELEASE/$LINUX_PKG" -C "$RELEASE/pkg-linux" CloudRestoreAS
rm -rf "$RELEASE/pkg-linux"
echo " tar.gz Linux: $LINUX_PKG"
fi
# --- 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"

View 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"

View File

@@ -0,0 +1,19 @@
# CloudRestoreAS — configuración local (editar y reiniciar la app)
# Arranca el motor al abrir. La ventana SIEMPRE se muestra al iniciar; cerrar (X) la
# minimiza a la bandeja del sistema (START_MINIMIZED ya no oculta la ventana).
CLOUDRESTORE_AUTO_START=true
CLOUDRESTORE_START_MINIMIZED=false
CLOUDRESTORE_REGISTER_AUTOSTART=true
# Servicio PANEL_BASES_ANEXO24
CLOUDRESTORE_PANEL_API_URL=
CLOUDRESTORE_PANEL_API_TOKEN=
CLOUDRESTORE_PANEL_INSTANCE_KEY=
CLOUDRESTORE_PANEL_VERIFY_SSL=false
# Carpetas (se crean automáticamente junto al ejecutable)
CLOUDRESTORE_INPUT_FOLDER={APP_DIR}/Entrada
CLOUDRESTORE_PROCESSED_FOLDER={APP_DIR}/Procesados
CLOUDRESTORE_FAILED_FOLDER={APP_DIR}/Fallados
CLOUDRESTORE_EXTRACT_FOLDER={APP_DIR}/Temp

2
requirements-windows.txt Normal file
View File

@@ -0,0 +1,2 @@
-r requirements.txt
pywin32>=306

View File

@@ -1,13 +1,14 @@
# CloudRestoreAS - Dependencias # CloudRestoreAS - Dependencias base (Windows y Linux)
# PySide6 para la interfaz gráfica
PySide6>=6.6.0 PySide6>=6.6.0
# pyodbc para SQL Server
pyodbc>=5.0.0 pyodbc>=5.0.0
requests>=2.31.0
paramiko>=3.4.0
python-dotenv>=1.0.0
cryptography>=42.0.0
# pywin32 para DPAPI (cifrado de passwords) # Empaquetado (opcional, solo build)
pywin32>=306
# Para empaquetado (opcional)
pyinstaller>=6.0.0 pyinstaller>=6.0.0
# Pruebas (opcional)
pytest>=8.0.0

245
runner.py
View File

@@ -1,53 +1,234 @@
"""Punto de entrada principal de la aplicación.""" """Punto de entrada principal de la aplicación."""
import argparse
import os
import sys import sys
import traceback
from pathlib import Path from pathlib import Path
# Agregar el directorio raíz al path ROOT_DIR = Path(__file__).parent.resolve()
ROOT_DIR = Path(__file__).parent if not getattr(sys, "frozen", False):
sys.path.insert(0, str(ROOT_DIR)) sys.path.insert(0, str(ROOT_DIR))
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from app.ui.main_window import MainWindow
from app.utils.logger import app_logger
from app.constants import DATA_DIR, LOGS_DIR
def main(): def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Función principal.""" parser = argparse.ArgumentParser(description="CloudRestoreAS")
# Crear directorios necesarios parser.add_argument(
DATA_DIR.mkdir(parents=True, exist_ok=True) "--minimized",
LOGS_DIR.mkdir(parents=True, exist_ok=True) action="store_true",
help="Inicia minimizado en la bandeja del sistema",
)
parser.add_argument(
"--start-engine",
action="store_true",
help="Inicia el motor de restauración automáticamente",
)
parser.add_argument(
"--headless",
action="store_true",
help="Fuerza el modo sin interfaz (Qt offscreen) para servidores sin display",
)
parser.add_argument(
"--version",
action="store_true",
help="Imprime la versión, plataforma y arquitectura, y termina",
)
return parser.parse_args(argv)
def _attach_windows_console() -> None:
"""
El .exe se compila con console=False (app de ventana), así que sin consola propia
stdout va al vacío. Cuando se invoca desde una terminal, se engancha a la consola
del proceso padre para que --version sea legible. En Linux no aplica.
"""
if sys.platform != "win32":
return
try:
import ctypes
attach_parent_process = -1
if not ctypes.windll.kernel32.AttachConsole(attach_parent_process):
return # sin consola del padre (doble clic): no hay dónde escribir
sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1)
sys.stderr = open("CONOUT$", "w", encoding="utf-8", buffering=1)
except (OSError, AttributeError):
# Sin consola disponible; el instalador remoto verifica por config/.version.
pass
def _print_version() -> int:
"""
Imprime la versión sin arrancar Qt ni el bootstrap: --version es una consulta.
El instalador remoto del PANEL prefiere leer config/.version (que el bootstrap
escribe), porque en Windows este stdout depende de haber consola del padre.
"""
from app import __version__
from app.constants import APP_ARCH, APP_PLATFORM
_attach_windows_console()
try:
sys.stdout.write(f"CloudRestoreAS {__version__} ({APP_PLATFORM}/{APP_ARCH})\n")
sys.stdout.flush()
except OSError:
pass
return 0
def _ensure_qt_platform(headless: bool = False) -> str:
"""
Selecciona el plugin de plataforma Qt en Linux. En un servidor headless (sin
DISPLAY/WAYLAND_DISPLAY) o con --headless, usa 'offscreen' para que la app
arranque y el motor trabaje sin X; con display usa el default ('xcb'). No toca
nada si el usuario ya fijó QT_QPA_PLATFORM, ni en Windows/macOS.
Devuelve la plataforma forzada ("offscreen") o "" si se deja el default.
"""
if sys.platform in ("win32", "darwin"):
return ""
if os.environ.get("QT_QPA_PLATFORM"):
return os.environ["QT_QPA_PLATFORM"]
has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
if headless or not has_display:
os.environ["QT_QPA_PLATFORM"] = "offscreen"
return "offscreen"
return ""
def _crash_log_targets() -> list[Path]:
"""Ubicaciones candidatas para el crash log, de más a menos accesible."""
targets: list[Path] = []
try:
targets.append(Path(sys.executable).resolve().parent / "CloudRestoreAS-crash.log")
except Exception:
pass
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or os.environ.get("HOME")
if base:
targets.append(Path(base) / "CloudRestoreAS" / "crash.log")
import tempfile
targets.append(Path(tempfile.gettempdir()) / "CloudRestoreAS-crash.log")
return targets
def _write_crash_log(text: str) -> Path | None:
"""Escribe el detalle del crash en la primera ubicación escribible."""
for path in _crash_log_targets():
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
except Exception:
continue
return None
def _show_fatal(exc: BaseException) -> None:
"""Hace VISIBLE un fallo de arranque: crash log + diálogo (o stderr)."""
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
detail = f"CloudRestoreAS no pudo iniciar.\n\n{type(exc).__name__}: {exc}\n\n{tb}"
crash_path = _write_crash_log(detail)
try:
sys.stderr.write(detail)
except Exception:
pass
try:
# Sin display, forzar offscreen para que crear la QApplication no falle
# también aquí (el diálogo no se verá, pero el crash log ya quedó escrito).
_ensure_qt_platform()
from PySide6.QtWidgets import QApplication, QMessageBox
app = QApplication.instance() or QApplication(sys.argv)
box = QMessageBox()
box.setIcon(QMessageBox.Icon.Critical)
box.setWindowTitle("CloudRestoreAS — Error al iniciar")
box.setText("La aplicación no pudo iniciar.")
info = str(exc) or type(exc).__name__
if crash_path:
info += f"\n\nDetalle guardado en:\n{crash_path}"
box.setInformativeText(info)
box.setDetailedText(tb)
box.exec()
except Exception:
# Sin GUI disponible: el crash log y stderr ya quedaron escritos.
pass
def _run() -> int:
"""Arranque real. Los imports están diferidos para que main() capture cualquier fallo."""
# Bootstrap y ODBC antes de importar módulos que usan pyodbc.
from app.config.bootstrap import ensure_runtime_layout
from app.config.odbc_setup import configure_odbc_environment
ensure_runtime_layout()
configure_odbc_environment()
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from app.config import get_launch_options, initialize_env, is_panel_configured
from app.ui.main_window import MainWindow
from app.ui.tray_assets import load_tray_icon
from app.utils.logger import app_logger
args = parse_args()
qt_platform = _ensure_qt_platform(headless=args.headless)
launch = get_launch_options()
initialize_env()
panel_ok = is_panel_configured()
start_engine = args.start_engine or launch.start_engine
app_logger.info("=" * 80) app_logger.info("=" * 80)
app_logger.info("CloudRestoreAS - Iniciando aplicación") app_logger.info("CloudRestoreAS - Iniciando aplicación")
app_logger.info(f"Directorio app: {ROOT_DIR}")
if qt_platform == "offscreen":
app_logger.info("Modo: sin display → plataforma Qt 'offscreen' (motor headless)")
else:
app_logger.info("Modo: ventana siempre visible; cerrar minimiza a bandeja")
if start_engine:
app_logger.info("Modo: motor auto-inicio")
if not panel_ok:
app_logger.warning(
"Servicio PANEL_BASES_ANEXO24 no configurado en config/.env — "
"complete CLOUDRESTORE_PANEL_* para enrutamiento automático."
)
app_logger.info("=" * 80) app_logger.info("=" * 80)
# Configurar aplicación Qt
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("CloudRestoreAS") app.setApplicationName("CloudRestoreAS")
app.setOrganizationName("Aduanasoft") app.setOrganizationName("Aduanasoft")
# Habilitar high DPI scaling
app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling) app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling)
app.setQuitOnLastWindowClosed(False)
app.setWindowIcon(load_tray_icon())
window = MainWindow(
start_engine=start_engine,
panel_configured=panel_ok,
)
# La ventana SIEMPRE se muestra al iniciar; cerrar (X) la manda a la bandeja.
window.show()
window.activateWindow()
window.raise_()
app_logger.info("Ventana principal mostrada")
exit_code = app.exec()
app_logger.info(f"Aplicación finalizada con código: {exit_code}")
return exit_code
def main():
"""Punto de entrada: ejecuta _run() y hace visible cualquier fallo de arranque."""
# --version se atiende antes del bootstrap y de importar Qt/pyodbc: es una consulta
# barata que el instalador remoto usa para verificar el binario recién desplegado.
if "--version" in sys.argv[1:]:
sys.exit(_print_version())
try: try:
# Crear y mostrar ventana principal sys.exit(_run())
window = MainWindow() except SystemExit:
window.show() raise
except BaseException as exc: # capturamos TODO en el arranque: nunca morir en silencio
app_logger.info("Ventana principal mostrada") _show_fatal(exc)
# Ejecutar aplicación
exit_code = app.exec()
app_logger.info(f"Aplicación finalizada con código: {exit_code}")
sys.exit(exit_code)
except Exception as e:
app_logger.critical(f"Error fatal en la aplicación: {e}", exc_info=True)
sys.exit(1) sys.exit(1)

128
scripts/dev-setup.ps1 Normal file
View 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..."

5
tests/conftest.py Normal file
View File

@@ -0,0 +1,5 @@
"""Configuración de pytest: asegura que el paquete `app` sea importable."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

187
tests/test_bootstrap.py Normal file
View File

@@ -0,0 +1,187 @@
"""Pruebas de bootstrap y carga de .env."""
import os
from pathlib import Path
import pytest
from app.config.bootstrap import ensure_runtime_layout
from app.config.env_loader import (
apply_env_overrides,
get_launch_options,
is_panel_configured,
render_env_template,
)
from app.constants import CONFIG_DIR, DEFAULT_CONFIG, DIR_ENTRADA, ENV_PATH
def test_render_env_template_contains_app_dir(tmp_path: Path):
text = render_env_template(tmp_path)
assert str(tmp_path) in text
assert "CLOUDRESTORE_PANEL_API_URL" in text
def test_ensure_runtime_layout_creates_structure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("app.config.bootstrap.APP_DIR", tmp_path)
monkeypatch.setattr("app.config.bootstrap.CONFIG_DIR", tmp_path / "config")
monkeypatch.setattr("app.config.bootstrap.DATA_DIR", tmp_path / "config" / "data")
monkeypatch.setattr("app.config.bootstrap.LOGS_DIR", tmp_path / "config" / "logs")
monkeypatch.setattr("app.config.bootstrap.ODBC_DIR", tmp_path / "config" / "odbc")
monkeypatch.setattr("app.config.bootstrap.SEVEN_ZIP_DIR", tmp_path / "config" / "7zip")
monkeypatch.setattr("app.config.bootstrap.ENV_PATH", tmp_path / "config" / ".env")
monkeypatch.setattr("app.config.bootstrap.DIR_ENTRADA", tmp_path / "Entrada")
monkeypatch.setattr("app.config.bootstrap.DIR_PROCESADOS", tmp_path / "Procesados")
monkeypatch.setattr("app.config.bootstrap.DIR_FALLADOS", tmp_path / "Fallados")
monkeypatch.setattr("app.config.bootstrap.DIR_TEMP", tmp_path / "Temp")
monkeypatch.setattr("app.constants.DB_PATH", tmp_path / "config" / "data" / "app.db")
ensure_runtime_layout()
assert (tmp_path / "config" / ".env").is_file()
assert (tmp_path / "Entrada").is_dir()
assert (tmp_path / "config" / "data" / "app.db").is_file()
def test_is_panel_configured(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("CLOUDRESTORE_PANEL_API_URL", raising=False)
monkeypatch.delenv("CLOUDRESTORE_PANEL_API_TOKEN", raising=False)
monkeypatch.delenv("CLOUDRESTORE_PANEL_INSTANCE_KEY", raising=False)
assert not is_panel_configured()
monkeypatch.setenv("CLOUDRESTORE_PANEL_API_URL", "https://panel:3000")
monkeypatch.setenv("CLOUDRESTORE_PANEL_API_TOKEN", "secret")
monkeypatch.setenv("CLOUDRESTORE_PANEL_INSTANCE_KEY", "srv1")
assert is_panel_configured()
def test_get_launch_options_auto_start_does_not_minimize(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CLOUDRESTORE_AUTO_START", "true")
monkeypatch.delenv("CLOUDRESTORE_START_MINIMIZED", raising=False)
opts = get_launch_options()
assert opts.start_engine is True
assert opts.minimized is False
def test_get_launch_options_start_minimized(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CLOUDRESTORE_START_MINIMIZED", "true")
opts = get_launch_options()
assert opts.minimized is True
def test_render_env_template_documents_start_minimized(tmp_path: Path):
text = render_env_template(tmp_path)
assert "CLOUDRESTORE_START_MINIMIZED=false" in text
def test_apply_env_overrides_paths(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CLOUDRESTORE_INPUT_FOLDER", "/tmp/in")
cfg = apply_env_overrides(DEFAULT_CONFIG.copy())
assert cfg["paths"]["input_folder"] == "/tmp/in"
def _redirect_bootstrap(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Reapunta los globales de bootstrap a un árbol temporal. Devuelve config/."""
for name, rel in (
("APP_DIR", ""),
("CONFIG_DIR", "config"),
("DATA_DIR", "config/data"),
("LOGS_DIR", "config/logs"),
("ODBC_DIR", "config/odbc"),
("SEVEN_ZIP_DIR", "config/7zip"),
("ENV_PATH", "config/.env"),
("DIR_ENTRADA", "Entrada"),
("DIR_PROCESADOS", "Procesados"),
("DIR_FALLADOS", "Fallados"),
("DIR_TEMP", "Temp"),
):
target = tmp_path / rel if rel else tmp_path
monkeypatch.setattr(f"app.config.bootstrap.{name}", target)
monkeypatch.setattr("app.constants.DB_PATH", tmp_path / "config" / "data" / "app.db")
return tmp_path / "config"
def test_bootstrap_escribe_sello_de_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""
El instalador remoto del PANEL lee config/.version por SFTP para verificar el
despliegue: en Windows el .exe se compila con console=False y no hay stdout confiable.
"""
from app import __version__
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
ensure_runtime_layout()
assert (config_dir / ".version").read_text(encoding="utf-8").strip() == __version__
def test_bootstrap_redespliega_deps_cuando_cambia_el_manifiesto(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""
config/7zip y config/odbc se re-copian cuando el build trae otras versiones embebidas.
Antes solo se copiaban si la carpeta estaba vacía, así que una actualización con driver
ODBC nuevo conservaba el viejo indefinidamente.
"""
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
src_7zip = tmp_path / "bundle" / "7zip"
src_odbc = tmp_path / "bundle" / "odbc"
src_7zip.mkdir(parents=True)
src_odbc.mkdir(parents=True)
(src_7zip / "7zz").write_text("7zz v26", encoding="utf-8")
(src_odbc / "odbcinst.ini").write_text("odbc 18.5", encoding="utf-8")
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_7ZIP", src_7zip)
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_ODBC", src_odbc)
# bundled-versions.json se busca bajo BUNDLE_DIR/packaging (va embebido en el onefile).
bundle_root = tmp_path / "bundle_root"
(bundle_root / "packaging").mkdir(parents=True)
manifest = bundle_root / "packaging" / "bundled-versions.json"
manifest.write_text('{"seven_zip": "26.01"}', encoding="utf-8")
monkeypatch.setattr("app.config.bootstrap.BUNDLE_DIR", bundle_root)
ensure_runtime_layout()
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "7zz v26"
stamp_before = (config_dir / ".bundled_deps").read_text(encoding="utf-8").strip()
assert stamp_before.startswith("sha256:")
# Mismo manifiesto: no debe re-copiar (no pisa ajustes locales en cada arranque).
(config_dir / "7zip" / "7zz").write_text("editado a mano", encoding="utf-8")
ensure_runtime_layout()
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "editado a mano"
# Manifiesto distinto (build con deps nuevas): debe re-copiar.
(src_7zip / "7zz").write_text("7zz v27", encoding="utf-8")
(src_odbc / "odbcinst.ini").write_text("odbc 19.0", encoding="utf-8")
manifest.write_text('{"seven_zip": "27.00"}', encoding="utf-8")
ensure_runtime_layout()
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "7zz v27"
assert (config_dir / "odbc" / "odbcinst.ini").read_text(encoding="utf-8") == "odbc 19.0"
assert (config_dir / ".bundled_deps").read_text(encoding="utf-8").strip() != stamp_before
# El re-despliegue SOBRESCRIBE, así que lo que el operador hubiera editado a mano debe
# quedar respaldado: no es reconstruible.
assert (config_dir / "7zip.bak" / "7zz").read_text(encoding="utf-8") == "editado a mano"
assert (config_dir / "odbc.bak" / "odbcinst.ini").read_text(encoding="utf-8") == "odbc 18.5"
def test_bootstrap_no_respalda_cuando_no_hay_refresco(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""Sin cambio de manifiesto no se re-copia, así que tampoco debe crearse el .bak."""
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
src_7zip = tmp_path / "bundle" / "7zip"
src_7zip.mkdir(parents=True)
(src_7zip / "7zz").write_text("7zz v26", encoding="utf-8")
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_7ZIP", src_7zip)
bundle_root = tmp_path / "bundle_root"
(bundle_root / "packaging").mkdir(parents=True)
(bundle_root / "packaging" / "bundled-versions.json").write_text(
'{"seven_zip": "26.01"}', encoding="utf-8"
)
monkeypatch.setattr("app.config.bootstrap.BUNDLE_DIR", bundle_root)
ensure_runtime_layout()
ensure_runtime_layout() # segunda vez: mismo manifiesto, no debe refrescar
assert not (config_dir / "7zip.bak").exists()

View File

@@ -0,0 +1,41 @@
"""
Pruebas de validación modo colocado (target.name vs instance_key).
"""
import pytest
from app.panel import panel_client
def test_validate_colocado_acepta_sin_ssh():
data = {
"id": 1,
"name": "Omega",
"server": "192.168.1.10",
"username": "sa",
"password": "x",
"data_folder": "C:\\SQLData",
}
assert panel_client._validate_target(data, colocated=True) is True
def test_validate_colocado_rechaza_sin_name():
data = {
"id": 1,
"server": "192.168.1.10",
"username": "sa",
"password": "x",
"data_folder": "C:\\SQLData",
}
assert panel_client._validate_target(data, colocated=True) is False
def test_validate_hub_exige_ssh():
data = {
"id": 1,
"name": "Omega",
"server": "192.168.1.10",
"username": "sa",
"password": "x",
"data_folder": "C:\\SQLData",
}
assert panel_client._validate_target(data, colocated=False) is False

View 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"]

View 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")

View 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

451
tests/test_panel_client.py Normal file
View File

@@ -0,0 +1,451 @@
"""
Pruebas del cliente del PANEL (servidor de restauración asignado por base de datos).
Se mockea `requests` para no depender de un PANEL real.
"""
import pytest
from app.panel import panel_client
class FakeResponse:
def __init__(self, status_code, json_data=None, raise_json=False):
self.status_code = status_code
self._json = json_data
self._raise_json = raise_json
def json(self):
if self._raise_json:
raise ValueError("no es JSON")
return self._json
VALID_TARGET = {
"id": 2,
"name": "Omega",
"server": "192.168.1.100,1433",
"username": "sa",
"password": "secreto",
"data_folder": "C:\\SQLData",
"ssh_host": "192.168.1.100",
"ssh_port": 22,
"ssh_username": "Administrator",
"ssh_password": "ssh-secreto",
"remote_inbox_path": "C:\\RestoreInbox",
}
URL = "http://panel:3000"
TOKEN = "tok"
DB = "EMPRESA_DB"
def test_target_url_vacia_devuelve_none():
assert panel_client.get_target_for_database("", TOKEN, DB) is None
def test_target_url_invalida_devuelve_none():
assert panel_client.get_target_for_database("ftp://x", TOKEN, DB) is None
def test_target_token_vacio_devuelve_none():
assert panel_client.get_target_for_database(URL, "", DB) is None
def test_target_db_vacio_devuelve_none():
assert panel_client.get_target_for_database(URL, TOKEN, " ") is None
def test_target_ok(monkeypatch):
captured = {}
# **kwargs porque panel_client también pasa verify=; una firma rígida rompe la prueba
# cada vez que se agrega un kwarg al cliente.
def fake_get(url, headers=None, timeout=None, **kwargs):
captured["url"] = url
return FakeResponse(200, VALID_TARGET)
monkeypatch.setattr(panel_client.requests, "get", fake_get)
target = panel_client.get_target_for_database(URL, TOKEN, DB)
assert target is not None
assert target["name"] == "Omega"
# el db_name viaja como query param url-encoded
assert "database=EMPRESA_DB" in captured["url"]
def test_target_db_con_espacios_se_encodea(monkeypatch):
captured = {}
monkeypatch.setattr(
panel_client.requests, "get",
lambda url, **k: (captured.__setitem__("url", url), FakeResponse(200, VALID_TARGET))[1]
)
panel_client.get_target_for_database(URL, TOKEN, "MI BASE")
assert "MI%20BASE" in captured["url"]
def test_target_falta_campo_requerido(monkeypatch):
incompleto = dict(VALID_TARGET)
del incompleto["password"]
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(200, incompleto))
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
def test_target_campo_vacio_es_invalido(monkeypatch):
vacio = dict(VALID_TARGET, password=" ")
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(200, vacio))
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
@pytest.mark.parametrize("code", [404, 401, 500, 503])
def test_target_codigos_no_200_devuelven_none(monkeypatch, code):
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(code))
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
def test_target_error_red_devuelve_none(monkeypatch):
def boom(*a, **k):
raise panel_client.requests.exceptions.ConnectionError("caído")
monkeypatch.setattr(panel_client.requests, "get", boom)
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
def test_target_json_invalido_devuelve_none(monkeypatch):
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, raise_json=True)
)
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
def test_report_job_result_201_true(monkeypatch):
captured = {}
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
captured["json"] = json
return FakeResponse(201)
monkeypatch.setattr(panel_client.requests, "post", fake_post)
ok = panel_client.report_job_result(
URL, TOKEN, filename="empresa.bak", status="completed",
restore_target_id=2, db_name="EMP", duration_ms=1000
)
assert ok is True
assert captured["json"]["filename"] == "empresa.bak"
assert captured["json"]["status"] == "completed"
def test_report_job_result_otro_codigo_false(monkeypatch):
monkeypatch.setattr(panel_client.requests, "post", lambda *a, **k: FakeResponse(500))
assert panel_client.report_job_result(URL, TOKEN, "x.bak", "failed") is False
def test_report_job_result_error_red_false(monkeypatch):
def boom(*a, **k):
raise panel_client.requests.exceptions.Timeout("timeout")
monkeypatch.setattr(panel_client.requests, "post", boom)
assert panel_client.report_job_result(URL, TOKEN, "x.bak", "failed") is False
@pytest.mark.parametrize("code", [200, 404])
def test_test_connection_ok(monkeypatch, code):
# 200 o 404 → conexión y token correctos
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(code))
ok, info = panel_client.test_connection(URL, TOKEN)
assert ok is True
def test_test_connection_token_rechazado(monkeypatch):
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(401))
ok, info = panel_client.test_connection(URL, TOKEN)
assert ok is False
assert "401" in info
def test_test_connection_url_invalida():
ok, info = panel_client.test_connection("noesurl", TOKEN)
assert ok is False
def test_report_instance_config_200_true(monkeypatch):
captured = {}
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
captured["url"] = url
captured["json"] = json
return FakeResponse(200)
monkeypatch.setattr(panel_client.requests, "post", fake_post)
ok = panel_client.report_instance_config(
URL, TOKEN, r"D:\Backups\Entrada", host_name="WIN-01", app_version="1.0.0"
)
assert ok is True
assert captured["url"].endswith("/api/restore/instance-config")
assert captured["json"]["input_folder"] == r"D:\Backups\Entrada"
assert captured["json"]["host_name"] == "WIN-01"
def test_report_instance_config_input_vacio_false():
assert panel_client.report_instance_config(URL, TOKEN, " ") is False
def test_report_instance_config_sin_token_false():
assert panel_client.report_instance_config(URL, "", r"D:\In") is False
def test_report_instance_config_otro_codigo_false(monkeypatch):
monkeypatch.setattr(panel_client.requests, "post", lambda *a, **k: FakeResponse(500))
assert panel_client.report_instance_config(URL, TOKEN, r"D:\In") is False
def test_report_instance_config_error_red_false(monkeypatch):
def boom(*a, **k):
raise panel_client.requests.exceptions.ConnectionError("caído")
monkeypatch.setattr(panel_client.requests, "post", boom)
assert panel_client.report_instance_config(URL, TOKEN, r"D:\In") is False
def test_target_con_instance_envia_param(monkeypatch):
captured = {}
monkeypatch.setattr(
panel_client.requests, "get",
lambda url, **k: (captured.__setitem__("url", url), FakeResponse(200, VALID_TARGET))[1]
)
panel_client.get_target_for_database(URL, TOKEN, DB, instance_key="Alfa")
assert "instance=Alfa" in captured["url"]
COLOCATED_TARGET = {
"id": 1,
"name": "Alfa",
"server": "localhost",
"username": "sa",
"password": "secreto",
"data_folder": "C:\\SQLData",
}
def test_target_colocado_no_exige_ssh(monkeypatch):
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, COLOCATED_TARGET)
)
target = panel_client.get_target_for_database(URL, TOKEN, DB, instance_key="Alfa")
assert target is not None
assert target["name"] == "Alfa"
def test_report_instance_config_con_instance_key(monkeypatch):
captured = {}
monkeypatch.setattr(
panel_client.requests, "post",
lambda url, json=None, **k: (captured.update({"json": json}), FakeResponse(200))[1]
)
ok = panel_client.report_instance_config(
URL, TOKEN, r"D:\Alfa\In", instance_key="Alfa"
)
assert ok is True
assert captured["json"]["instance_key"] == "Alfa"
CATALOG_RESPONSE = {
"targets": [
{"id": 1, "name": "Alfa"},
{"id": 2, "name": "Omega"},
{"id": 4, "name": "Delta"},
]
}
def test_list_restore_target_names_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None, **kwargs):
captured["url"] = url
return FakeResponse(200, CATALOG_RESPONSE)
monkeypatch.setattr(panel_client.requests, "get", fake_get)
names = panel_client.list_restore_target_names(URL, TOKEN)
assert names == ["Alfa", "Omega", "Delta"]
assert captured["url"].endswith("/api/restore/target-catalog")
def test_list_restore_target_names_url_vacia():
assert panel_client.list_restore_target_names("", TOKEN) == []
def test_list_restore_target_names_token_vacio():
assert panel_client.list_restore_target_names(URL, "") == []
@pytest.mark.parametrize("code", [401, 500, 503])
def test_list_restore_target_names_codigos_no_200(monkeypatch, code):
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(code))
assert panel_client.list_restore_target_names(URL, TOKEN) == []
def test_list_restore_target_names_error_red(monkeypatch):
def boom(*a, **k):
raise panel_client.requests.exceptions.ConnectionError("caído")
monkeypatch.setattr(panel_client.requests, "get", boom)
assert panel_client.list_restore_target_names(URL, TOKEN) == []
def test_list_restore_target_names_json_invalido(monkeypatch):
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, raise_json=True)
)
assert panel_client.list_restore_target_names(URL, TOKEN) == []
def test_list_restore_target_names_sin_lista_targets(monkeypatch):
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, {"foo": []})
)
assert panel_client.list_restore_target_names(URL, TOKEN) == []
ROUTE_FORWARD = {
"action": "forward",
"db_name": "GENERICA-TEST",
"node_key": "GENERICA-TEST",
"target": {
"id": 2,
"name": "Alfa",
"server": "192.168.1.10,1433",
"username": "sa",
"password": "secreto",
"data_folder": "C:\\SQLData",
"ssh_host": "192.168.1.10",
"ssh_port": 22,
"ssh_username": "Administrator",
"ssh_password": "ssh-secreto",
"remote_inbox_path": "C:\\RestoreInbox",
"input_folder": "D:\\Restore\\Alfa\\Entrada",
},
}
ROUTE_RESTORE_LOCAL = {
"action": "restore_local",
"db_name": "GENERICA-TEST",
"node_key": "GENERICA-TEST",
"target": {
"id": 2,
"name": "Alfa",
"server": "192.168.1.10,1433",
"username": "sa",
"password": "secreto",
"data_folder": "C:\\SQLData",
"ssh_host": "192.168.1.10",
"ssh_port": 22,
"ssh_username": "Administrator",
"ssh_password": "ssh-secreto",
"remote_inbox_path": "C:\\RestoreInbox",
"input_folder": "D:\\Restore\\Alfa\\Entrada",
},
}
def test_resolve_route_forward_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None, **kwargs):
captured["url"] = url
return FakeResponse(200, ROUTE_FORWARD)
monkeypatch.setattr(panel_client.requests, "get", fake_get)
route = panel_client.resolve_route(URL, TOKEN, "GENERICA-TEST.ZIP", instance_key="Omega")
assert route is not None
assert route["action"] == "forward"
assert "resolve-route" in captured["url"]
assert "instance=Omega" in captured["url"]
def test_resolve_route_restore_local_ok(monkeypatch):
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, ROUTE_RESTORE_LOCAL)
)
route = panel_client.resolve_route(URL, TOKEN, "GENERICA-TEST.ZIP", instance_key="Alfa")
assert route is not None
assert route["action"] == "restore_local"
def test_resolve_route_503_devuelve_none(monkeypatch):
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(503))
assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None
def test_resolve_route_forward_sin_input_folder_invalido(monkeypatch):
bad = dict(ROUTE_FORWARD)
bad["target"] = dict(bad["target"], input_folder=" ")
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, bad)
)
assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None
# ============================================================================
# platform / arch: el PANEL los usa para elegir qué artefacto le toca a este
# servidor al instalar o actualizar (a24c.cras_releases se llavea por
# version + platform + arch).
# ============================================================================
def _capture_post(monkeypatch) -> dict:
captured = {}
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
captured["url"] = url
captured["json"] = json
captured["headers"] = headers
return FakeResponse(200)
monkeypatch.setattr(panel_client.requests, "post", fake_post)
return captured
def test_report_instance_config_envia_platform_y_arch(monkeypatch):
captured = _capture_post(monkeypatch)
ok = panel_client.report_instance_config(
URL,
TOKEN,
r"D:\Backups\Entrada",
processed_folder=r"D:\Backups\Procesados",
host_name="WIN-01",
app_version="1.1.0",
instance_key="Alfa",
platform_name="windows",
arch="x86_64",
)
assert ok is True
assert captured["json"]["platform"] == "windows"
assert captured["json"]["arch"] == "x86_64"
assert captured["json"]["processed_folder"] == r"D:\Backups\Procesados"
def test_report_instance_config_sin_platform_manda_none(monkeypatch):
# Compatibilidad hacia atrás: un agente viejo no manda estas claves y el PANEL
# debe poder caer al texto libre de restore_targets.os.
captured = _capture_post(monkeypatch)
panel_client.report_instance_config(URL, TOKEN, r"D:\In")
assert captured["json"]["platform"] is None
assert captured["json"]["arch"] is None
@pytest.mark.parametrize("blank", ["", " ", None])
def test_report_instance_config_platform_en_blanco_es_none(monkeypatch, blank):
captured = _capture_post(monkeypatch)
panel_client.report_instance_config(
URL, TOKEN, r"D:\In", platform_name=blank, arch=blank
)
assert captured["json"]["platform"] is None
assert captured["json"]["arch"] is None
def test_constantes_platform_arch_son_del_vocabulario_del_panel():
# El PANEL valida platform contra ('windows','linux'); si esto cambia hay que
# actualizar el CHECK de a24c.cras_releases y la validación del endpoint.
from app.constants import APP_ARCH, APP_PLATFORM
assert APP_PLATFORM in ("windows", "linux")
assert APP_ARCH and APP_ARCH == APP_ARCH.strip()
assert " " not in APP_ARCH

View 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
View 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

138
tests/test_route_forward.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Pruebas de enrutamiento automático (restore_local | forward) en RestoreWorker.
"""
from pathlib import Path
import pytest
from app.engine.restore_worker import RestoreDeferred, RestoreWorker
@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",
},
}
def test_collect_zip_paths_simple(tmp_path, base_config):
z = tmp_path / "in" / "NODO.ZIP"
z.parent.mkdir(parents=True)
z.write_bytes(b"z")
worker = RestoreWorker("job-1", base_config)
paths = worker._collect_zip_paths(str(z))
assert paths == [str(z)]
def test_collect_zip_paths_multipart(tmp_path, base_config):
folder = tmp_path / "in"
folder.mkdir(parents=True)
p1 = folder / "NODO.ZIP.001"
p2 = folder / "NODO.ZIP.002"
p1.write_bytes(b"1")
p2.write_bytes(b"2")
worker = RestoreWorker("job-1", base_config)
paths = worker._collect_zip_paths(str(p1))
assert len(paths) == 2
assert all(Path(p).exists() for p in paths)
def test_resolve_route_envia_instance_key(monkeypatch, base_config):
worker = RestoreWorker("job-1", base_config)
captured = {}
def fake_resolve(api_url, api_token, filename, instance_key=None):
captured["instance_key"] = instance_key
return {
"action": "restore_local",
"db_name": "DB1",
"node_key": "NODO",
"target": {
"id": 1,
"name": "Alfa",
"server": "localhost",
"username": "sa",
"password": "x",
"data_folder": "C:\\Data",
},
}
monkeypatch.setattr(
"app.engine.restore_worker.panel_client.resolve_route", fake_resolve
)
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.update_node_and_db", lambda *a, **k: None
)
class FakeJob:
source_name = "NODO.ZIP"
source_path = "C:\\in\\NODO.ZIP"
route = worker._resolve_route(FakeJob())
assert route["action"] == "restore_local"
assert captured["instance_key"] == "Alfa"
def test_resolve_route_sin_instance_key_diferido(base_config):
base_config["panel"]["instance_key"] = ""
worker = RestoreWorker("job-1", base_config)
class FakeJob:
source_name = "NODO.ZIP"
source_path = "C:\\in\\NODO.ZIP"
with pytest.raises(RestoreDeferred, match="instance_key"):
worker._resolve_route(FakeJob())
def test_legacy_mode_no_cambia_comportamiento(monkeypatch, base_config):
base_config["panel"]["mode"] = "orchestrator"
worker = RestoreWorker("job-1", base_config)
assert worker._legacy_panel_mode() == "orchestrator"
captured = {}
def fake_resolve(api_url, api_token, filename, instance_key=None):
captured["instance_key"] = instance_key
return {
"action": "forward",
"db_name": "DB1",
"node_key": "NODO",
"target": {
"id": 2,
"name": "Omega",
"ssh_host": "h",
"ssh_username": "u",
"ssh_password": "p",
"input_folder": "D:\\In",
},
}
monkeypatch.setattr(
"app.engine.restore_worker.panel_client.resolve_route", fake_resolve
)
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.update_node_and_db", lambda *a, **k: None
)
class FakeJob:
source_name = "NODO.ZIP"
source_path = "C:\\in\\NODO.ZIP"
route = worker._resolve_route(FakeJob())
assert route["action"] == "forward"
assert captured["instance_key"] == "Alfa"

213
tests/test_sftp_copy.py Normal file
View File

@@ -0,0 +1,213 @@
"""
Pruebas de la transferencia SFTP al servidor remoto. Se mockea paramiko para no
requerir un servidor SSH real; se valida la conversión de rutas y el flujo de subida.
"""
import os
import pytest
from app.transfer import sftp_copy
from app.transfer.sftp_copy import SFTPCopyError
CFG = {
"ssh_host": "192.168.1.100",
"ssh_port": 22,
"ssh_username": "Administrator",
"ssh_password": "ssh-secreto",
"remote_inbox_path": "C:\\RestoreInbox",
}
def test_windows_restore_path():
assert sftp_copy.windows_restore_path("C:\\RestoreInbox", "empresa.bak") == "C:\\RestoreInbox\\empresa.bak"
def test_sftp_path_convierte_windows_a_posix():
# ruta destino en formato Windows → SFTP usa forward slashes
assert sftp_copy._sftp_path("C:\\RestoreInbox", "empresa.bak") == "C:/RestoreInbox/empresa.bak"
# ya en posix se respeta (sin doble slash)
assert sftp_copy._sftp_path("/srv/inbox/", "x.bak") == "/srv/inbox/x.bak"
def test_upload_origen_inexistente(tmp_path):
with pytest.raises(SFTPCopyError, match="no existe"):
sftp_copy.upload_to_remote(str(tmp_path / "noexiste.bak"), CFG)
class FakeStat:
def __init__(self, st_size):
self.st_size = st_size
class FakeSFTP:
def __init__(self, store):
self.store = store
def put(self, local, remote, confirm=True):
self.store["put"] = (local, remote)
self.store["confirm"] = confirm
# Registra el tamaño para que stat() (verificación de subida) lo confirme.
self.store.setdefault("sizes", {})[remote] = os.path.getsize(local)
def stat(self, remote):
sizes = self.store.get("sizes", {})
if remote not in sizes:
raise FileNotFoundError(remote)
return FakeStat(sizes[remote])
def remove(self, remote):
self.store["removed"] = remote
def close(self):
self.store["sftp_closed"] = True
class FakeClient:
def __init__(self, store):
self.store = store
def set_missing_host_key_policy(self, policy):
self.store["policy_set"] = True
def connect(self, **kwargs):
self.store["connect"] = kwargs
def open_sftp(self):
return FakeSFTP(self.store)
def close(self):
self.store["client_closed"] = True
def _fake_paramiko(store):
class FakeParamiko:
AutoAddPolicy = object
SSHClient = lambda self=None: FakeClient(store)
fp = FakeParamiko()
# SSHClient() debe devolver FakeClient
fp.SSHClient = lambda: FakeClient(store)
return fp
def test_upload_ok(tmp_path, monkeypatch):
bak = tmp_path / "empresa.bak"
bak.write_bytes(b"data")
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
remote = sftp_copy.upload_to_remote(str(bak), CFG)
assert remote == "C:/RestoreInbox/empresa.bak"
assert store["put"][1] == "C:/RestoreInbox/empresa.bak"
assert store["connect"]["hostname"] == "192.168.1.100"
assert store["connect"]["port"] == 22
assert store["client_closed"] is True
def test_cleanup_remote_borra(monkeypatch):
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
sftp_copy.cleanup_remote(CFG, "C:/RestoreInbox/empresa.bak")
assert store["removed"] == "C:/RestoreInbox/empresa.bak"
assert store["client_closed"] is True
def test_cleanup_remote_none_no_falla():
sftp_copy.cleanup_remote(CFG, None) # no debe conectar ni lanzar
def test_upload_file_to_folder_ok(tmp_path, monkeypatch):
zf = tmp_path / "backup.zip"
zf.write_bytes(b"zipdata")
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
remote = sftp_copy.upload_file_to_folder(
str(zf), CFG, "D:\\Restore\\Alfa\\Entrada"
)
assert remote == "D:/Restore/Alfa/Entrada/backup.zip"
assert store["put"][1] == "D:/Restore/Alfa/Entrada/backup.zip"
def test_upload_zip_parts_multipart(tmp_path, monkeypatch):
p1 = tmp_path / "big.zip.001"
p2 = tmp_path / "big.zip.002"
p1.write_bytes(b"a")
p2.write_bytes(b"b")
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
uploaded = sftp_copy.upload_zip_parts(
[str(p1), str(p2)], CFG, "D:\\In"
)
assert len(uploaded) == 2
assert uploaded[0].endswith("/big.zip.001")
assert uploaded[1].endswith("/big.zip.002")
def test_upload_file_to_folder_vacio_falla(tmp_path):
f = tmp_path / "x.zip"
f.write_bytes(b"x")
with pytest.raises(SFTPCopyError, match="carpeta remota"):
sftp_copy.upload_file_to_folder(str(f), CFG, " ")
def test_upload_usa_confirm_false(tmp_path, monkeypatch):
"""La subida no debe delegar la verificación al confirm inmediato de paramiko."""
zf = tmp_path / "backup.zip"
zf.write_bytes(b"zipdata")
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
sftp_copy.upload_file_to_folder(str(zf), CFG, "D:\\In")
assert store["confirm"] is False
def test_verify_remote_size_reintenta_stat_flaky(monkeypatch):
"""Un stat transitoriamente fallido se reintenta y NO produce falso fallo."""
monkeypatch.setattr(sftp_copy, "VERIFY_DELAY_SECONDS", 0)
calls = {"n": 0}
class Flaky:
def stat(self, remote):
calls["n"] += 1
if calls["n"] < 2:
raise OSError("stat flaky")
return FakeStat(100)
sftp_copy._verify_remote_size(Flaky(), "C:/In/x.zip", 100) # no debe lanzar
assert calls["n"] == 2
def test_verify_remote_size_tamano_incorrecto_falla(monkeypatch):
"""Si el tamaño remoto nunca coincide, es un fallo genuino de entrega."""
monkeypatch.setattr(sftp_copy, "VERIFY_DELAY_SECONDS", 0)
class Wrong:
def stat(self, remote):
return FakeStat(50)
with pytest.raises(SFTPCopyError, match="verificar"):
sftp_copy._verify_remote_size(Wrong(), "C:/In/x.zip", 100)
def test_upload_zip_parts_adjunta_uploaded_en_fallo(tmp_path, monkeypatch):
"""Ante un fallo parcial, la excepción lleva las partes ya subidas para limpieza."""
p1 = tmp_path / "big.zip.001"
p2 = tmp_path / "big.zip.002"
p1.write_bytes(b"a")
p2.write_bytes(b"b")
calls = {"n": 0}
def fake_upload(local, cfg, remote_folder):
calls["n"] += 1
if calls["n"] == 1:
return "D:/In/big.zip.001"
raise SFTPCopyError("boom en la parte 2")
monkeypatch.setattr(sftp_copy, "upload_file_to_folder", fake_upload)
with pytest.raises(SFTPCopyError) as exc_info:
sftp_copy.upload_zip_parts([str(p1), str(p2)], CFG, "D:\\In")
assert getattr(exc_info.value, "uploaded", None) == ["D:/In/big.zip.001"]

View File

@@ -0,0 +1,77 @@
"""
Pruebas de _build_move_clauses: nombres únicos por archivo lógico (G4),
evitando colisiones con múltiples data files / logs y sin descartar otros tipos.
"""
from app.sql.sql_manager import SQLServerManager, LogicalFile
def _paths(clauses):
"""Extrae las rutas destino (lo que va tras 'TO N') de cada cláusula MOVE."""
out = []
for c in clauses:
# MOVE N'logico' TO N'ruta'
ruta = c.split("TO N'")[1].rstrip("'")
out.append(ruta)
return out
def test_un_data_un_log():
files = [
LogicalFile("EMP_dat", "X.mdf", "D"),
LogicalFile("EMP_log", "X.ldf", "L"),
]
clauses = SQLServerManager._build_move_clauses("EMP", "C:\\D", files)
rutas = _paths(clauses)
assert rutas == ["C:\\D\\EMP.mdf", "C:\\D\\EMP_log.ldf"]
def test_multiples_data_files_sin_colision():
files = [
LogicalFile("d1", "a.mdf", "D"),
LogicalFile("d2", "b.ndf", "D"),
LogicalFile("d3", "c.ndf", "D"),
LogicalFile("l1", "a.ldf", "L"),
]
clauses = SQLServerManager._build_move_clauses("EMP", "C:\\D", files)
rutas = _paths(clauses)
assert rutas == [
"C:\\D\\EMP.mdf",
"C:\\D\\EMP_1.ndf",
"C:\\D\\EMP_2.ndf",
"C:\\D\\EMP_log.ldf",
]
# No debe haber rutas duplicadas (la causa del bug original).
assert len(set(rutas)) == len(rutas)
def test_multiples_logs_sin_colision():
files = [
LogicalFile("d1", "a.mdf", "D"),
LogicalFile("l1", "a.ldf", "L"),
LogicalFile("l2", "b.ldf", "L"),
]
rutas = _paths(SQLServerManager._build_move_clauses("EMP", "C:\\D", files))
assert rutas == ["C:\\D\\EMP.mdf", "C:\\D\\EMP_log.ldf", "C:\\D\\EMP_log_1.ldf"]
assert len(set(rutas)) == len(rutas)
def test_tipo_no_data_ni_log_no_se_descarta():
# Tipos como FILESTREAM/full-text (p. ej. 'S') deben moverse, no ignorarse.
files = [
LogicalFile("d1", "a.mdf", "D"),
LogicalFile("l1", "a.ldf", "L"),
LogicalFile("fs stream!", "fs", "S"),
]
clauses = SQLServerManager._build_move_clauses("EMP", "C:\\D", files)
assert len(clauses) == 3 # ninguno descartado
rutas = _paths(clauses)
# El nombre lógico se sanea (no alfanumérico → '_').
assert "C:\\D\\EMP_fs_stream_" in rutas[2]
assert len(set(rutas)) == len(rutas)
def test_cada_archivo_logico_genera_una_clausula():
files = [LogicalFile(f"f{i}", f"f{i}", "D") for i in range(5)]
clauses = SQLServerManager._build_move_clauses("DB", "C:\\D", files)
assert len(clauses) == 5
assert len(set(_paths(clauses))) == 5 # todas únicas