8 Commits

56 changed files with 5560 additions and 442 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

191
BUILD.md Normal file
View File

@@ -0,0 +1,191 @@
# 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 + 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 --no-package # compila sin generar .tar.gz/.zip
./build-all.sh --clean # rebuild desde cero (borra venvs, bundled, dist)
./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
```
Genera en `dist/release/`:
| Archivo | Contenido |
|---|---|
| `CloudRestoreAS-linux.tar.gz` | `CloudRestoreAS/` → binario + `install.sh` + `packaging/linux/cloudrestoreas.service` + `LEEME.txt` |
| `CloudRestoreAS-win.zip` | `CloudRestoreAS/``CloudRestoreAS.exe` + `install.ps1` + `LEEME.txt` |
---
## 5. Instalación / despliegue
### Linux — `install.sh` (no instala nada del sistema)
```bash
tar xzf CloudRestoreAS-linux.tar.gz && cd CloudRestoreAS
sudo ./install.sh --service # servicio systemd 24/7 headless (recomendado en servidor)
./install.sh --desktop # autostart .desktop (requiere sesión gráfica)
./install.sh # solo instala + bootstrap; lo corres a mano
./install.sh --help
```
Variables: `PREFIX=/opt/cloudrestoreas` (destino), `SERVICE_USER=<usuario>` (usuario del servicio).
Servicio systemd:
```bash
systemctl status cloudrestoreas
journalctl -u cloudrestoreas -f
sudo systemctl restart cloudrestoreas # tras editar config/.env
```
### Windows
Copiar `CloudRestoreAS.exe` a una carpeta y ejecutarlo (o usar `install.ps1`). Al iniciar
crea `config/` y un icono en la bandeja. Ver [packaging/LEEME.txt](packaging/LEEME.txt).
---
## 6. 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 |
| `QT_QPA_PLATFORM=offscreen` | Plataforma Qt sin display (el binario ya cae a esto automáticamente si no hay `DISPLAY`/`WAYLAND_DISPLAY` en Linux) |
En Linux sin `DISPLAY`, el binario selecciona `offscreen` **solo**; con display usa `xcb`.
---
## 7. Configuración (primera ejecución)
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`.
---
## 8. 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.
---
## 9. Artefactos y ubicaciones
| Ruta | Qué es |
|---|---|
| `dist/CloudRestoreAS` | Binario Linux onefile |
| `dist/CloudRestoreAS.exe` | Ejecutable Windows onefile |
| `dist/release/*.tar.gz` / `*.zip` | Paquetes de despliegue (binario + instalador + docs) |
| `packaging/bundled/{linux,windows}/` | Deps embebidas (generadas en build; git-ignored) |
| `venv-linux/`, `venv-windows/` | Entornos virtuales de build (git-ignored) |
---
## 10. Scripts de referencia rápida
| 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 `.tar.gz` / `.zip` |
| `install.sh` / `install.ps1` | Instalador Linux / Windows |
| `packaging/linux/cloudrestoreas.service` | Unit systemd (24/7 headless) |

197
INTEGRACION_PANEL.md Normal file
View File

@@ -0,0 +1,197 @@
# 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 (`instance_key` = nombre del servidor).
---
## Agregar servidores adicionales
1. Panel → **Servidores de Restauración → + Nuevo servidor**
2. **Gestión de Bases de Datos** → dropdown servidor por base
3. Instalar CRA → 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

@@ -20,6 +20,19 @@ Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"}
.\venv\Scripts\python.exe runner.py .\venv\Scripts\python.exe runner.py
``` ```
### 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 +126,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)
Esto creará `CloudRestoreAS.exe` en la carpeta `dist\` ./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.
2. **Distribuir** **Solo Windows** — generar: `.\build.ps1` → `dist\CloudRestoreAS.exe`
- Copiar `CloudRestoreAS.exe` a la ubicación deseada **Solo Linux** — generar: `./build.sh` (o `packaging/scripts/docker-build-linux.sh` para el entorno controlado) → `dist/CloudRestoreAS`
- Crear carpetas `data` y `logs` en el mismo directorio
- El ejecutable es portable (no requiere instalación) > 📦 Referencia completa de compilación, empaquetado, instalación (`install.sh`/systemd),
> flags del binario y verificación: **[BUILD.md](BUILD.md)**.
> 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

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

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

@@ -0,0 +1,107 @@
"""Creación automática de config/ y carpetas de trabajo."""
import shutil
import sys
from pathlib import Path
from ..constants import (
APP_DIR,
BUNDLE_DIR,
BUNDLED_SOURCE_7ZIP,
BUNDLED_SOURCE_ODBC,
CONFIG_DIR,
DATA_DIR,
DIR_ENTRADA,
DIR_FALLADOS,
DIR_PROCESADOS,
DIR_TEMP,
ENV_PATH,
IS_WINDOWS,
LOGS_DIR,
ODBC_DIR,
SEVEN_ZIP_DIR,
)
from ..db.database import DatabaseManager
from .env_loader import render_env_template
def _copy_tree_if_missing(src: Path, dest: Path) -> None:
if not src.is_dir():
return
# dest puede existir pero VACÍO: ensure_runtime_layout crea ODBC_DIR/SEVEN_ZIP_DIR
# antes de llamar aquí. Solo saltar si ya tiene contenido (evita re-copiar en cada
# arranque). Antes se saltaba por dest.exists(), dejando 7-Zip/ODBC sin desplegar.
if dest.exists() and any(dest.iterdir()):
return
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()
_copy_tree_if_missing(BUNDLED_SOURCE_7ZIP, SEVEN_ZIP_DIR)
_copy_tree_if_missing(BUNDLED_SOURCE_ODBC, ODBC_DIR)
_ensure_seven_zip_executable()
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")

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

@@ -0,0 +1,139 @@
"""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,
)
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 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)
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=
"""

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,51 @@
"""Constantes globales de la aplicación.""" """Constantes globales de la aplicación."""
import sys
from pathlib import Path from pathlib import Path
# Rutas base
APP_DIR = Path(__file__).parent.parent
DATA_DIR = APP_DIR / "data"
LOGS_DIR = APP_DIR / "logs"
# Base de datos 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)
platform = "windows" if sys.platform == "win32" else "linux"
return _resolve_app_dir() / "packaging" / "bundled" / platform
APP_DIR = _resolve_app_dir()
CONFIG_DIR = APP_DIR / "config"
ENV_PATH = CONFIG_DIR / ".env"
DATA_DIR = CONFIG_DIR / "data"
LOGS_DIR = CONFIG_DIR / "logs"
ODBC_DIR = CONFIG_DIR / "odbc"
SEVEN_ZIP_DIR = CONFIG_DIR / "7zip"
IS_WINDOWS = sys.platform == "win32"
BUNDLE_DIR = _resolve_bundle_dir()
BUNDLED_SOURCE_7ZIP = BUNDLE_DIR / "bundled" / "7zip"
BUNDLED_SOURCE_ODBC = BUNDLE_DIR / "bundled" / "odbc"
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"
DB_PATH = DATA_DIR / "app.db" DB_PATH = DATA_DIR / "app.db"
# Estados de jobs # Estados de jobs
@@ -21,7 +59,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 +67,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 +77,63 @@ 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,
},
} }

View File

@@ -176,6 +176,15 @@ 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 @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."""

View File

@@ -1,15 +1,20 @@
"""Motor principal de la aplicación.""" """Motor principal de la aplicación."""
import platform
import socket
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 .restore_worker import RestoreWorker from .restore_worker import RestoreWorker
from .. import __version__
from ..db.job_repository import JobRepository from ..db.job_repository import JobRepository
from ..db.event_repository import EventRepository from ..db.event_repository import EventRepository
from ..db.config_repository import ConfigRepository from ..db.config_repository import ConfigRepository
from ..config.env_loader import apply_env_overrides, load_env_file
from ..constants import JobStatus, DEFAULT_CONFIG from ..constants import JobStatus, DEFAULT_CONFIG
from ..panel import panel_client
from ..utils.logger import app_logger from ..utils.logger import app_logger
@@ -41,18 +46,20 @@ class RestoreEngine(QObject):
# 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,7 +73,8 @@ 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 si está corriendo
if self._running: if self._running:
self._restart_file_watcher() self._restart_file_watcher()
@@ -103,7 +111,8 @@ class RestoreEngine(QObject):
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")
@@ -190,6 +199,30 @@ 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
input_folder = (self._config.get("paths") or {}).get("input_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
panel_client.report_instance_config(
api_url=api_url,
api_token=api_token,
input_folder=input_folder,
host_name=host_name,
app_version=__version__,
instance_key=instance_key,
)
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"]

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
@@ -47,9 +57,20 @@ class RestoreWorker(QRunnable):
self.config = config self.config = config
self.dry_run = dry_run self.dry_run = dry_run
self.signals = RestoreWorkerSignals() self.signals = RestoreWorkerSignals()
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."""
@@ -58,46 +79,190 @@ class RestoreWorker(QRunnable):
try: try:
app_logger.info(f"Iniciando procesamiento de job {self.job_id}") app_logger.info(f"Iniciando procesamiento de job {self.job_id}")
self.signals.job_started.emit(self.job_id) self.signals.job_started.emit(self.job_id)
# Obtener job # Obtener job
job = JobRepository.get(self.job_id) job = JobRepository.get(self.job_id)
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)
# Actualizar tiempos # Actualizar tiempos
total_ms = int((time.time() - start_time) * 1000) total_ms = int((time.time() - start_time) * 1000)
JobRepository.update_timing(self.job_id, total_ms=total_ms) JobRepository.update_timing(self.job_id, total_ms=total_ms)
# Marcar como completado # Marcar como completado
JobRepository.update_status(self.job_id, JobStatus.COMPLETED) JobRepository.update_status(self.job_id, JobStatus.COMPLETED)
app_logger.info( app_logger.info(
f"Job {self.job_id} 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)
JobRepository.update_status( JobRepository.update_status(
self.job_id, self.job_id,
JobStatus.FAILED, JobStatus.FAILED,
error=error_msg, error=error_msg,
increment_attempts=True increment_attempts=True
) )
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)
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)
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)."""
path = Path(source_path)
if SevenZipExtractor.is_multipart(str(path)):
base_name = path.stem.split(".zip")[0]
parts = sorted(path.parent.glob(f"{base_name}.zip.*"))
return [str(p) for p in parts]
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 _forward_zip(self, job, route: dict, start_time: float):
"""Reenvía el ZIP al input_folder del servidor destino vía SFTP."""
target = route["target"]
self._target = target
step_id = JobStepRepository.create(self.job_id, StepType.FORWARD_ZIP)
try:
zip_paths = self._collect_zip_paths(job.source_path)
remote_folder = target["input_folder"]
uploaded = sftp_copy.upload_zip_parts(zip_paths, target, remote_folder)
JobStepRepository.complete(
step_id,
exit_code=0,
stdout=f"Destino: {target.get('name')} ({len(uploaded)} archivo(s))",
)
except Exception as e:
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
raise
self._move_zip_to_processed(job)
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)
self.signals.job_completed.emit(self.job_id, True)
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,11 +335,17 @@ 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")
@@ -192,95 +363,109 @@ class RestoreWorker(QRunnable):
"""Restaura la base de datos desde el backup.""" """Restaura la base de datos desde el backup."""
JobRepository.update_status(self.job_id, JobStatus.RESTORING) JobRepository.update_status(self.job_id, JobStatus.RESTORING)
self.signals.job_progress.emit(self.job_id, JobStatus.RESTORING) self.signals.job_progress.emit(self.job_id, JobStatus.RESTORING)
# Recargar job para obtener db_name actualizado # Recargar job para obtener db_name actualizado
job = JobRepository.get(self.job_id) job = JobRepository.get(self.job_id)
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
success, error = sql_manager.test_connection() success, error = sql_manager.test_connection()
if not success: if not success:
raise RuntimeError(f"Conexión SQL falló: {error}") raise RuntimeError(f"Conexión SQL falló: {error}")
JobStepRepository.complete(connect_step_id, exit_code=0) JobStepRepository.complete(connect_step_id, exit_code=0)
except Exception as e: except Exception as e:
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"]
) )
filelist_ms = int((time.time() - filelist_start) * 1000) filelist_ms = int((time.time() - filelist_start) * 1000)
JobRepository.update_timing(self.job_id, filelist_ms=filelist_ms) JobRepository.update_timing(self.job_id, filelist_ms=filelist_ms)
if error: if error:
raise RuntimeError(error) raise RuntimeError(error)
files_info = ", ".join([f"{lf.logical_name}({lf.type})" for lf in logical_files]) files_info = ", ".join([f"{lf.logical_name}({lf.type})" for lf in logical_files])
JobStepRepository.complete( JobStepRepository.complete(
filelist_step_id, filelist_step_id,
exit_code=0, exit_code=0,
stdout=files_info[:1000] stdout=files_info[:1000]
) )
except Exception as e: except Exception as e:
JobStepRepository.complete(filelist_step_id, exit_code=1, error=str(e)) JobStepRepository.complete(filelist_step_id, exit_code=1, error=str(e))
raise raise
# RESTORE DATABASE # RESTORE DATABASE
restore_step_id = JobStepRepository.create(self.job_id, StepType.RESTORE) restore_step_id = JobStepRepository.create(self.job_id, StepType.RESTORE)
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"],
dry_run=self.dry_run dry_run=self.dry_run
) )
restore_ms = int((time.time() - restore_start) * 1000) restore_ms = int((time.time() - restore_start) * 1000)
JobRepository.update_timing(self.job_id, restore_ms=restore_ms) JobRepository.update_timing(self.job_id, restore_ms=restore_ms)
if not success: if not success:
raise RuntimeError(error or "RESTORE falló sin mensaje de error") raise RuntimeError(error or "RESTORE falló sin mensaje de error")
JobStepRepository.complete( JobStepRepository.complete(
restore_step_id, restore_step_id,
exit_code=0, exit_code=0,
stdout=stdout[:1000] if stdout else None stdout=stdout[:1000] if stdout else None
) )
except Exception as e: except Exception as e:
JobStepRepository.complete(restore_step_id, exit_code=1, error=str(e)) JobStepRepository.complete(restore_step_id, exit_code=1, error=str(e))
raise raise
def _cleanup(self, job): def _cleanup(self, job):
"""Limpia archivos temporales y mueve el ZIP.""" """Limpia archivos temporales y mueve el ZIP."""
JobRepository.update_status(self.job_id, JobStatus.CLEANING) JobRepository.update_status(self.job_id, JobStatus.CLEANING)

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,19 +29,30 @@ 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"
] if not getattr(sys, "frozen", False):
if IS_WINDOWS:
for path in possible_paths: possible_paths = [
if Path(path).exists(): r"C:\Program Files\7-Zip\7z.exe",
app_logger.info(f"7-Zip auto-detectado en: {path}") r"C:\Program Files (x86)\7-Zip\7z.exe",
return path ]
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
def extract( def extract(
@@ -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)."""

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

@@ -0,0 +1,427 @@
"""
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,
) -> 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.
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(),
"host_name": (host_name or "").strip() or None,
"app_version": (app_version 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,85 +279,158 @@ 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_cmd = f"""
-- Poner la base de datos en modo single user
ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
-- Restaurar restore_query = (
RESTORE DATABASE [{db_name}] f"RESTORE DATABASE [{db_name}] FROM DISK = N'{backup_path}' "
FROM DISK = N'{backup_path}' f"WITH {', '.join(move_clauses)}, REPLACE"
WITH {', '.join(move_clauses)}, REPLACE; )
app_logger.info(f"Comando RESTORE generado:\n{restore_query}")
-- 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()
conn = pyodbc.connect(conn_str, timeout=timeout_minutes * 60) conn = pyodbc.connect(conn_str, timeout=timeout_minutes * 60)
conn.autocommit = True # Necesario para ALTER DATABASE conn.autocommit = True # Necesario para ALTER DATABASE
cursor = conn.cursor() cursor = conn.cursor()
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)."""

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

@@ -0,0 +1,160 @@
"""
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
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
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)
client = _connect(cfg)
try:
sftp = client.open_sftp()
try:
app_logger.info(f"Subiendo archivo por SFTP a {cfg['ssh_host']}: {remote_sftp}")
sftp.put(str(src), remote_sftp)
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 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.
Returns:
Lista de rutas SFTP subidas.
"""
uploaded: list[str] = []
for local_path in local_paths:
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
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,17 +104,28 @@ 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()
self.sql_username_input.setEnabled(False) self.sql_username_input.setEnabled(False)
sql_layout.addRow("Usuario SQL:", self.sql_username_input) sql_layout.addRow("Usuario SQL:", self.sql_username_input)
self.sql_password_input = QLineEdit() self.sql_password_input = QLineEdit()
self.sql_password_input.setEchoMode(QLineEdit.EchoMode.Password) self.sql_password_input.setEchoMode(QLineEdit.EchoMode.Password)
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)
@@ -118,7 +133,38 @@ 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()
@@ -269,7 +315,42 @@ class ConfigTab(QWidget):
self.dry_run_checkbox.setChecked(features.get("dry_run_mode", False)) self.dry_run_checkbox.setChecked(features.get("dry_run_mode", False))
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

@@ -32,6 +32,14 @@ class DashboardTab(QWidget):
title_font.setBold(True) title_font.setBold(True)
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")
@@ -94,17 +102,29 @@ 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()
self.motor_status_label = QLabel("Detenido") self.motor_status_label = QLabel("Detenido")
motor_status_font = QFont() motor_status_font = QFont()
motor_status_font.setPointSize(14) motor_status_font.setPointSize(14)
motor_status_font.setBold(True) motor_status_font.setBold(True)
self.motor_status_label.setFont(motor_status_font) self.motor_status_label.setFont(motor_status_font)
self.motor_status_label.setStyleSheet("color: red;") self.motor_status_label.setStyleSheet("color: red;")
motor_layout.addWidget(QLabel("Estado:")) motor_layout.addWidget(QLabel("Estado:"))
motor_layout.addWidget(self.motor_status_label) motor_layout.addWidget(self.motor_status_label)
motor_layout.addStretch() motor_layout.addStretch()
@@ -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,181 +2,213 @@
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 ..engine.engine import RestoreEngine from ..engine.engine import RestoreEngine
from ..utils.logger import app_logger 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 self.tray_icon: QSystemTrayIcon | None = 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")
self.tabs.addTab(self.config_tab, "⚙️ Configuración") self.tabs.addTab(self.config_tab, "⚙️ Configuración")
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)
self.start_action.triggered.connect(self._start_engine) self.start_action.triggered.connect(self._start_engine)
motor_menu.addAction(self.start_action) motor_menu.addAction(self.start_action)
self.pause_action = QAction("⏸️ Pausar", self) self.pause_action = QAction("⏸️ Pausar", self)
self.pause_action.triggered.connect(self._pause_engine) self.pause_action.triggered.connect(self._pause_engine)
self.pause_action.setEnabled(False) self.pause_action.setEnabled(False)
motor_menu.addAction(self.pause_action) motor_menu.addAction(self.pause_action)
self.resume_action = QAction("▶️ Continuar", self) self.resume_action = QAction("▶️ Continuar", self)
self.resume_action.triggered.connect(self._resume_engine) self.resume_action.triggered.connect(self._resume_engine)
self.resume_action.setEnabled(False) self.resume_action.setEnabled(False)
motor_menu.addAction(self.resume_action) motor_menu.addAction(self.resume_action)
self.stop_action = QAction("⏹️ Detener", self) self.stop_action = QAction("⏹️ Detener", self)
self.stop_action.triggered.connect(self._stop_engine) self.stop_action.triggered.connect(self._stop_engine)
self.stop_action.setEnabled(False) self.stop_action.setEnabled(False)
motor_menu.addAction(self.stop_action) motor_menu.addAction(self.stop_action)
motor_menu.addSeparator() motor_menu.addSeparator()
self.scan_action = QAction("🔍 Escanear Ahora", self) self.scan_action = QAction("🔍 Escanear Ahora", self)
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
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)
show_action.triggered.connect(self._show_window) show_action.triggered.connect(self._show_window)
tray_menu.addAction(show_action) tray_menu.addAction(show_action)
hide_action = QAction("Ocultar", self) hide_action = QAction("Ocultar", self)
hide_action.triggered.connect(self.hide) hide_action.triggered.connect(self.hide)
tray_menu.addAction(hide_action) tray_menu.addAction(hide_action)
tray_menu.addSeparator() tray_menu.addSeparator()
pause_tray_action = QAction("Pausar", self) pause_tray_action = QAction("Pausar", self)
pause_tray_action.triggered.connect(self._pause_engine) pause_tray_action.triggered.connect(self._pause_engine)
tray_menu.addAction(pause_tray_action) tray_menu.addAction(pause_tray_action)
resume_tray_action = QAction("Continuar", self) resume_tray_action = QAction("Continuar", self)
resume_tray_action.triggered.connect(self._resume_engine) resume_tray_action.triggered.connect(self._resume_engine)
tray_menu.addAction(resume_tray_action) tray_menu.addAction(resume_tray_action)
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):
"""Conecta las señales del motor.""" """Conecta las señales del motor."""
self.engine.signals.job_created.connect(self._on_job_created) self.engine.signals.job_created.connect(self._on_job_created)
self.engine.signals.stats_updated.connect(self._on_stats_updated) self.engine.signals.stats_updated.connect(self._on_stats_updated)
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."""
if reason == QSystemTrayIcon.ActivationReason.DoubleClick: if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
@@ -184,35 +216,56 @@ class MainWindow(QMainWindow):
self.hide() self.hide()
else: else:
self._show_window() self._show_window()
def _show_window(self): def _show_window(self):
"""Muestra la ventana.""" """Muestra la ventana."""
self.show() self.show()
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):
"""Pausa el motor.""" """Pausa el motor."""
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):
"""Reanuda el motor.""" """Reanuda el motor."""
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):
"""Detiene el motor.""" """Detiene el motor."""
self.engine.stop() self.engine.stop()
@@ -220,48 +273,53 @@ 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):
"""Fuerza un escaneo manual.""" """Fuerza un escaneo manual."""
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 _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)
def _on_job_created(self, job_id: str): def _on_job_created(self, job_id: str):
"""Maneja la creación de un nuevo job.""" """Maneja la creación de un nuevo job."""
self.jobs_tab.refresh_jobs() self.jobs_tab.refresh_jobs()
self._update_stats() self._update_stats()
def _on_stats_updated(self, stats: dict): def _on_stats_updated(self, stats: dict):
"""Maneja la actualización de estadísticas.""" """Maneja la actualización de estadísticas."""
self.dashboard_tab.update_stats(stats) self.dashboard_tab.update_stats(stats)
def _on_config_loaded(self, config: dict): def _on_config_loaded(self, config: dict):
"""Maneja la carga de configuración.""" """Maneja la carga de configuración."""
self.config_tab.load_config(config) self.config_tab.load_config(config)
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" "CloudRestoreAS v1.0.0\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

131
build-all.sh Executable file
View File

@@ -0,0 +1,131 @@
#!/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-linux.tar.gz y CloudRestoreAS-win.zip
#
# 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)
# =============================================================================
set -uo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT"
DO_LINUX=1
DO_WINDOWS=1
DO_PACKAGE=1
DO_CLEAN=0
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 ;;
-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
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

103
install.sh Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Instalador Linux de CloudRestoreAS.
#
# NO instala ni descarga NADA del sistema: el binario es 100% autocontenido
# (Qt/xcb, driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo
# coloca el binario, marca permisos, hace el bootstrap de config/, y opcionalmente
# registra el arranque automático (servicio systemd headless o autostart de escritorio).
#
# Uso:
# sudo ./install.sh --service # 24/7 headless vía systemd (recomendado en servidor)
# ./install.sh --desktop # autostart de escritorio (requiere sesión gráfica)
# ./install.sh # solo instala; sin arranque automático
#
# Variables:
# PREFIX=/opt/cloudrestoreas # carpeta destino (default)
# SERVICE_USER=<usuario> # usuario del servicio systemd (default: quien invoca)
set -euo pipefail
MODE="none"
for arg in "$@"; do
case "$arg" in
--service) MODE="service" ;;
--desktop) MODE="desktop" ;;
-h|--help)
grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Opción desconocida: $arg" >&2; exit 2 ;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PREFIX="${PREFIX:-/opt/cloudrestoreas}"
BIN_NAME="CloudRestoreAS"
# 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 "==============================================="
mkdir -p "$PREFIX"
install -m 0755 "$SRC" "$PREFIX/$BIN_NAME"
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.
echo "Inicializando config/ (bootstrap)..."
( cd "$PREFIX" && QT_QPA_PLATFORM=offscreen timeout 20 "$PREFIX/$BIN_NAME" --headless >/dev/null 2>&1 || true )
if [[ -f "$PREFIX/config/.env" ]]; then
echo "config/.env creado."
else
echo "NOTA: config/.env se creará en la primera ejecución."
fi
case "$MODE" in
service)
SERVICE_USER="${SERVICE_USER:-${SUDO_USER:-$(id -un)}}"
UNIT_SRC="$SCRIPT_DIR/packaging/linux/cloudrestoreas.service"
UNIT_DST="/etc/systemd/system/cloudrestoreas.service"
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 cloudrestoreas.service
echo "Servicio systemd instalado y arrancado (usuario: $SERVICE_USER)."
echo " Estado : systemctl status cloudrestoreas"
echo " Logs : journalctl -u cloudrestoreas -f"
;;
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
echo ""
echo "Siguiente paso: edita $PREFIX/config/.env (CLOUDRESTORE_PANEL_*) y reinicia."
[[ "$MODE" == "service" ]] && echo " Tras editar: sudo systemctl restart cloudrestoreas"
echo "Listo."

View File

@@ -0,0 +1,113 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec onefile portable (ejecutar en Windows o Linux según destino)."""
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
_assets = ROOT / "packaging" / "assets"
datas = [
(str(ROOT / "packaging" / "templates" / "env.default"), "packaging/templates"),
]
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
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="CloudRestoreAS",
icon=_icon,
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,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,47 @@
#!/usr/bin/env bash
# Empaqueta artefactos en dist/release/
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
RELEASE="$ROOT/dist/release"
mkdir -p "$RELEASE"
[[ -f "$ROOT/dist/CloudRestoreAS.exe" ]] && cp -f "$ROOT/dist/CloudRestoreAS.exe" "$RELEASE/"
[[ -f "$ROOT/dist/CloudRestoreAS" ]] && cp -f "$ROOT/dist/CloudRestoreAS" "$RELEASE/CloudRestoreAS-linux" && chmod +x "$RELEASE/CloudRestoreAS-linux"
cp -f "$ROOT/packaging/LEEME.txt" "$RELEASE/"
# Paquete Windows: carpeta raíz CloudRestoreAS/ con .exe + install.ps1 + LEEME
# (paridad con el .tar.gz de Linux).
if [[ -f "$RELEASE/CloudRestoreAS.exe" ]]; then
ROOT="$ROOT" RELEASE="$RELEASE" python3 <<'PY'
import os, zipfile
from pathlib import Path
root = Path(os.environ["ROOT"]); release = Path(os.environ["RELEASE"])
items = [(release / "CloudRestoreAS.exe", "CloudRestoreAS/CloudRestoreAS.exe"),
(root / "packaging" / "LEEME.txt", "CloudRestoreAS/LEEME.txt")]
if (root / "install.ps1").exists():
items.append((root / "install.ps1", "CloudRestoreAS/install.ps1"))
with zipfile.ZipFile(release / "CloudRestoreAS-win.zip", "w", zipfile.ZIP_DEFLATED) as z:
for src, arc in items:
z.write(src, arc)
print(" zip Windows:", release / "CloudRestoreAS-win.zip")
PY
fi
if [[ -f "$RELEASE/CloudRestoreAS-linux" ]]; then
# Paquete de despliegue completo: binario + instalador + unit systemd + LEEME,
# bajo una carpeta raíz para que extraiga limpio. install.sh encuentra el binario
# (CloudRestoreAS-linux) y la unit (packaging/linux/) relativos a su ubicación.
STAGE="$RELEASE/CloudRestoreAS-linux"
PKG="$RELEASE/pkg-linux/CloudRestoreAS"
rm -rf "$RELEASE/pkg-linux"
mkdir -p "$PKG/packaging/linux"
cp -f "$STAGE" "$PKG/CloudRestoreAS-linux"; chmod +x "$PKG/CloudRestoreAS-linux"
cp -f "$ROOT/install.sh" "$PKG/install.sh"; chmod +x "$PKG/install.sh"
cp -f "$ROOT/packaging/linux/cloudrestoreas.service" "$PKG/packaging/linux/"
cp -f "$ROOT/packaging/LEEME.txt" "$PKG/LEEME.txt"
tar czf "$RELEASE/CloudRestoreAS-linux.tar.gz" -C "$RELEASE/pkg-linux" CloudRestoreAS
rm -rf "$RELEASE/pkg-linux"
fi
echo "Release en: $RELEASE"
ls -lh "$RELEASE"

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

203
runner.py
View File

@@ -1,53 +1,186 @@
"""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",
)
return parser.parse_args(argv)
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."""
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)

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__))))

79
tests/test_bootstrap.py Normal file
View File

@@ -0,0 +1,79 @@
"""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"

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

381
tests/test_panel_client.py Normal file
View File

@@ -0,0 +1,381 @@
"""
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 = {}
def fake_get(url, headers=None, timeout=None):
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):
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):
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):
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):
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

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"

135
tests/test_sftp_copy.py Normal file
View File

@@ -0,0 +1,135 @@
"""
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 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 FakeSFTP:
def __init__(self, store):
self.store = store
def put(self, local, remote):
self.store["put"] = (local, 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, " ")

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