Compare commits
8 Commits
main
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| ce38956e3e | |||
| cafe3f1b87 | |||
| 0c14f1166b | |||
| 0d8d80d61d | |||
| 0111712df6 | |||
| 034a5ca5bb | |||
| 072be5b5db | |||
| afb76ce4a6 |
21
.gitignore
vendored
21
.gitignore
vendored
@@ -25,6 +25,8 @@ MANIFEST
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
venv-windows/
|
||||
venv-linux/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
@@ -35,16 +37,25 @@ env/
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
# Logs y datos locales
|
||||
logs/*.log
|
||||
logs/*.log.*
|
||||
|
||||
# Database
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
config/.env
|
||||
config/data/
|
||||
config/logs/
|
||||
config/odbc/
|
||||
config/7zip/
|
||||
config/.bootstrap_ok
|
||||
config/.autostart_registered
|
||||
|
||||
# PyInstaller
|
||||
*.spec
|
||||
# PyInstaller / empaquetado
|
||||
packaging/bundled/
|
||||
dist/installers/
|
||||
|
||||
# Variables de entorno locales
|
||||
.env
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
191
BUILD.md
Normal file
191
BUILD.md
Normal 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
197
INTEGRACION_PANEL.md
Normal 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**.
|
||||
@@ -20,6 +20,19 @@ Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"}
|
||||
.\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)
|
||||
|
||||
1. **Tab Configuración**:
|
||||
@@ -113,10 +126,10 @@ C:\CloudRestore\
|
||||
|
||||
## Para Ejecutar 24/7
|
||||
|
||||
1. Inicia la aplicación
|
||||
2. Menú Motor → Iniciar
|
||||
3. Minimiza (se va a la bandeja del sistema)
|
||||
4. La app continuará monitoreando en segundo plano
|
||||
1. Inicia la aplicación (el motor arranca solo si `CLOUDRESTORE_AUTO_START=true`)
|
||||
2. Cierra la ventana con **X** — se minimiza a la bandeja del sistema
|
||||
3. La app continuará monitoreando en segundo plano
|
||||
4. Para salir por completo: bandeja → **Salir** o menú **Archivo → Salir**
|
||||
|
||||
---
|
||||
|
||||
|
||||
53
README.md
53
README.md
@@ -24,6 +24,9 @@ CloudRestoreAS es una aplicación Windows de escritorio desarrollada en Python 3
|
||||
### Sistema Operativo
|
||||
- Windows 10/11
|
||||
- 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
|
||||
|
||||
@@ -73,19 +76,49 @@ CloudRestoreAS es una aplicación Windows de escritorio desarrollada en Python 3
|
||||
python runner.py
|
||||
```
|
||||
|
||||
### Opción 2: Ejecutable Empaquetado
|
||||
### Opción 2: Ejecutable portable (recomendado para producción)
|
||||
|
||||
1. **Generar el ejecutable**
|
||||
```powershell
|
||||
.\build.ps1
|
||||
```
|
||||
**Todo de una vez (desde WSL)** — genera Windows + Linux + los paquetes de release:
|
||||
```bash
|
||||
./build-all.sh # ambos + dist/release/*.tar.gz y *.zip
|
||||
./build-all.sh --linux-only # solo Linux (Docker)
|
||||
./build-all.sh --windows-only # solo Windows (PowerShell + build.ps1)
|
||||
./build-all.sh --clean # rebuild desde cero
|
||||
```
|
||||
Requiere Docker (para Linux) y, para Windows, `powershell.exe` accesible desde WSL con Python 3.11+ instalado en Windows.
|
||||
|
||||
Esto creará `CloudRestoreAS.exe` en la carpeta `dist\`
|
||||
**Solo Windows** — generar: `.\build.ps1` → `dist\CloudRestoreAS.exe`
|
||||
**Solo Linux** — generar: `./build.sh` (o `packaging/scripts/docker-build-linux.sh` para el entorno controlado) → `dist/CloudRestoreAS`
|
||||
|
||||
2. **Distribuir**
|
||||
- Copiar `CloudRestoreAS.exe` a la ubicación deseada
|
||||
- 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
|
||||
|
||||
|
||||
36
app/config/__init__.py
Normal file
36
app/config/__init__.py
Normal 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
88
app/config/autostart.py
Normal 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
107
app/config/bootstrap.py
Normal 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
139
app/config/env_loader.py
Normal 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
84
app/config/odbc_setup.py
Normal 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}")
|
||||
105
app/constants.py
105
app/constants.py
@@ -1,13 +1,51 @@
|
||||
"""Constantes globales de la aplicación."""
|
||||
|
||||
import sys
|
||||
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"
|
||||
|
||||
# Estados de jobs
|
||||
@@ -21,7 +59,7 @@ class JobStatus:
|
||||
FAILED_RESTART = "failed_restart"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
# Niveles de log
|
||||
|
||||
class LogLevel:
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
@@ -29,7 +67,7 @@ class LogLevel:
|
||||
ERROR = "ERROR"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
# Tipos de paso de job
|
||||
|
||||
class StepType:
|
||||
STABILITY_CHECK = "stability_check"
|
||||
NODE_MAPPING = "node_mapping"
|
||||
@@ -39,44 +77,63 @@ class StepType:
|
||||
FILELIST = "filelist"
|
||||
RESTORE = "restore"
|
||||
CLEANUP = "cleanup"
|
||||
SFTP_COPY = "sftp_copy"
|
||||
FORWARD_ZIP = "forward_zip"
|
||||
|
||||
# Configuración por defecto
|
||||
DEFAULT_CONFIG = {
|
||||
"paths": {
|
||||
"input_folder": "",
|
||||
"processed_folder": "",
|
||||
"failed_folder": "",
|
||||
"extract_folder": "",
|
||||
|
||||
def default_seven_zip_path() -> str:
|
||||
for candidate in (BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK):
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return ""
|
||||
|
||||
|
||||
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": "",
|
||||
"seven_zip_exe": ""
|
||||
},
|
||||
"seven_zip_exe": default_seven_zip_path(),
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"paths": default_work_paths(),
|
||||
"sql": {
|
||||
"server": "localhost",
|
||||
"use_windows_auth": True,
|
||||
"use_windows_auth": IS_WINDOWS,
|
||||
"username": "",
|
||||
"password_encrypted": ""
|
||||
"password_encrypted": "",
|
||||
},
|
||||
"concurrency": {
|
||||
"extract_workers": 1,
|
||||
"restore_workers": 1
|
||||
"restore_workers": 1,
|
||||
},
|
||||
"stability": {
|
||||
"check_enabled": True,
|
||||
"check_interval_seconds": 5,
|
||||
"stable_duration_seconds": 10,
|
||||
"use_ready_marker": False
|
||||
"use_ready_marker": False,
|
||||
},
|
||||
"timeouts": {
|
||||
"extract_minutes": 30,
|
||||
"restore_minutes": 60
|
||||
"restore_minutes": 60,
|
||||
},
|
||||
"retries": {
|
||||
"max_attempts": 3,
|
||||
"retry_delay_seconds": 60
|
||||
"retry_delay_seconds": 60,
|
||||
},
|
||||
"features": {
|
||||
"dry_run_mode": False,
|
||||
"auto_scan_enabled": True,
|
||||
"scan_interval_seconds": 30
|
||||
}
|
||||
"scan_interval_seconds": 30,
|
||||
},
|
||||
"panel": {
|
||||
"api_url": "",
|
||||
"api_token": "",
|
||||
"instance_key": "",
|
||||
"verify_ssl": False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -176,6 +176,15 @@ class JobRepository:
|
||||
tuple(params)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete(job_id: str):
|
||||
"""
|
||||
Elimina un job y su rastro de hash. Se usa para diferir un job cuando no
|
||||
hay servidor de restauración activo: al borrarlo, el próximo escaneo del
|
||||
FileWatcher vuelve a detectar el archivo y reintenta (G8 del plan).
|
||||
"""
|
||||
db.execute("DELETE FROM jobs WHERE job_id = ?", (job_id,))
|
||||
|
||||
@staticmethod
|
||||
def exists_by_hash(source_hash: str) -> bool:
|
||||
"""Verifica si existe un job con el hash dado."""
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"""Motor principal de la aplicación."""
|
||||
|
||||
import platform
|
||||
import socket
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QObject, Signal, QThreadPool
|
||||
|
||||
from .file_watcher import FileWatcher, FileStabilityChecker, calculate_file_hash
|
||||
from .restore_worker import RestoreWorker
|
||||
from .. import __version__
|
||||
from ..db.job_repository import JobRepository
|
||||
from ..db.event_repository import EventRepository
|
||||
from ..db.config_repository import ConfigRepository
|
||||
from ..config.env_loader import apply_env_overrides, load_env_file
|
||||
from ..constants import JobStatus, DEFAULT_CONFIG
|
||||
from ..panel import panel_client
|
||||
from ..utils.logger import app_logger
|
||||
|
||||
|
||||
@@ -41,18 +46,20 @@ class RestoreEngine(QObject):
|
||||
|
||||
# Configuración
|
||||
self._config = self._load_config()
|
||||
self._report_instance_config_to_panel()
|
||||
|
||||
app_logger.info("RestoreEngine inicializado")
|
||||
|
||||
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())
|
||||
|
||||
# Asegurar que tiene todas las claves
|
||||
for key, value in DEFAULT_CONFIG.items():
|
||||
if key not in config:
|
||||
config[key] = value
|
||||
|
||||
config = apply_env_overrides(config)
|
||||
self.signals.config_loaded.emit(config)
|
||||
return config
|
||||
|
||||
@@ -66,6 +73,7 @@ class RestoreEngine(QObject):
|
||||
ConfigRepository.set("app_config", config)
|
||||
self._config = config
|
||||
app_logger.info("Configuración guardada")
|
||||
self._report_instance_config_to_panel()
|
||||
|
||||
# Reconfigurar file watcher si está corriendo
|
||||
if self._running:
|
||||
@@ -103,6 +111,7 @@ class RestoreEngine(QObject):
|
||||
|
||||
self._running = True
|
||||
self._paused = False
|
||||
self._report_instance_config_to_panel()
|
||||
|
||||
EventRepository.create("INFO", "Motor iniciado")
|
||||
app_logger.info("Motor iniciado")
|
||||
@@ -190,6 +199,30 @@ class RestoreEngine(QObject):
|
||||
|
||||
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:
|
||||
"""Valida que la configuración sea correcta."""
|
||||
paths = self._config["paths"]
|
||||
|
||||
@@ -14,9 +14,19 @@ from ..db.event_repository import EventRepository
|
||||
from ..db.node_repository import NodeRepository
|
||||
from ..extract.seven_zip import SevenZipExtractor
|
||||
from ..sql.sql_manager import SQLServerManager
|
||||
from ..panel import panel_client
|
||||
from ..transfer import sftp_copy
|
||||
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):
|
||||
"""Señales para comunicación con la UI."""
|
||||
job_started = Signal(str) # job_id
|
||||
@@ -50,6 +60,17 @@ class RestoreWorker(QRunnable):
|
||||
|
||||
self._extract_dir: 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):
|
||||
"""Ejecuta el procesamiento del job."""
|
||||
@@ -64,8 +85,26 @@ class RestoreWorker(QRunnable):
|
||||
if not job:
|
||||
raise ValueError(f"Job {self.job_id} no encontrado")
|
||||
|
||||
# Pipeline de procesamiento
|
||||
self._process_node_mapping(job)
|
||||
if not self._panel_configured():
|
||||
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._restore_database(job)
|
||||
self._cleanup(job)
|
||||
@@ -80,8 +119,19 @@ class RestoreWorker(QRunnable):
|
||||
app_logger.info(
|
||||
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)
|
||||
|
||||
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:
|
||||
error_msg = str(e)
|
||||
app_logger.error(f"Error procesando job {self.job_id}: {error_msg}", exc_info=True)
|
||||
@@ -95,9 +145,124 @@ class RestoreWorker(QRunnable):
|
||||
|
||||
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.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):
|
||||
"""Procesa el mapeo de nodo a base de datos."""
|
||||
step_id = JobStepRepository.create(self.job_id, StepType.NODE_MAPPING)
|
||||
@@ -170,10 +335,16 @@ class RestoreWorker(QRunnable):
|
||||
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)
|
||||
|
||||
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:
|
||||
raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído")
|
||||
@@ -199,16 +370,30 @@ class RestoreWorker(QRunnable):
|
||||
if not job.db_name:
|
||||
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
|
||||
connect_step_id = JobStepRepository.create(self.job_id, StepType.SQL_CONNECT)
|
||||
|
||||
try:
|
||||
sql_config = self.config["sql"]
|
||||
sql_manager = SQLServerManager(
|
||||
server=sql_config["server"],
|
||||
use_windows_auth=sql_config["use_windows_auth"],
|
||||
username=sql_config.get("username"),
|
||||
password=sql_config.get("password")
|
||||
server=server,
|
||||
use_windows_auth=use_windows_auth,
|
||||
username=username,
|
||||
password=password
|
||||
)
|
||||
|
||||
# Test connection
|
||||
@@ -222,13 +407,15 @@ class RestoreWorker(QRunnable):
|
||||
JobStepRepository.complete(connect_step_id, exit_code=1, error=str(e))
|
||||
raise
|
||||
|
||||
sql_backup_path = self._bak_path
|
||||
|
||||
# Obtener FILELISTONLY
|
||||
filelist_step_id = JobStepRepository.create(self.job_id, StepType.FILELIST)
|
||||
filelist_start = time.time()
|
||||
|
||||
try:
|
||||
logical_files, error = sql_manager.get_filelist_from_backup(
|
||||
self._bak_path,
|
||||
sql_backup_path,
|
||||
timeout_minutes=self.config["timeouts"]["restore_minutes"]
|
||||
)
|
||||
|
||||
@@ -254,11 +441,9 @@ class RestoreWorker(QRunnable):
|
||||
restore_start = time.time()
|
||||
|
||||
try:
|
||||
data_folder = self.config["paths"]["data_sql_folder"]
|
||||
|
||||
success, stdout, error = sql_manager.restore_database(
|
||||
db_name=job.db_name,
|
||||
backup_path=self._bak_path,
|
||||
backup_path=sql_backup_path,
|
||||
data_folder=data_folder,
|
||||
logical_files=logical_files,
|
||||
timeout_minutes=self.config["timeouts"]["restore_minutes"],
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""Gestión de extracción de archivos con 7-Zip."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
import time
|
||||
|
||||
from ..constants import BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK, IS_WINDOWS
|
||||
from ..utils.logger import app_logger
|
||||
|
||||
|
||||
@@ -25,18 +29,29 @@ class SevenZipExtractor:
|
||||
|
||||
@staticmethod
|
||||
def _auto_detect_7zip() -> Optional[str]:
|
||||
"""Auto-detecta la ubicación de 7z.exe."""
|
||||
possible_paths = [
|
||||
r"C:\Program Files\7-Zip\7z.exe",
|
||||
r"D:\Program Files\7-Zip\7z.exe",
|
||||
r"C:\Program Files (x86)\7-Zip\7z.exe",
|
||||
r"D:\Program Files (x86)\7-Zip\7z.exe"
|
||||
]
|
||||
"""Prioriza 7-Zip embebido; en desarrollo busca en el sistema."""
|
||||
for candidate in (BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK):
|
||||
if candidate.exists():
|
||||
app_logger.info(f"7-Zip embebido: {candidate}")
|
||||
return str(candidate)
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
app_logger.info(f"7-Zip auto-detectado en: {path}")
|
||||
return path
|
||||
if not getattr(sys, "frozen", False):
|
||||
if IS_WINDOWS:
|
||||
possible_paths = [
|
||||
r"C:\Program Files\7-Zip\7z.exe",
|
||||
r"C:\Program Files (x86)\7-Zip\7z.exe",
|
||||
]
|
||||
else:
|
||||
possible_paths = []
|
||||
for name in ("7z", "7zz", "7za"):
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
possible_paths.append(found)
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
app_logger.info(f"7-Zip detectado en: {path}")
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
@@ -134,35 +149,78 @@ class SevenZipExtractor:
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
@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:
|
||||
extract_dir: Directorio donde se extrajo
|
||||
node_name: Nombre del nodo; si se indica, el backup se renombra a
|
||||
'<node_name>.bak'
|
||||
|
||||
Returns:
|
||||
Ruta al archivo .bak o None si no se encuentra
|
||||
|
||||
Raises:
|
||||
ValueError: Si hay múltiples archivos .bak
|
||||
Ruta al archivo .bak (ya renombrado si node_name) o None si no hay archivos.
|
||||
"""
|
||||
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:
|
||||
app_logger.error(f"No se encontró archivo .bak en {extract_dir}")
|
||||
if not all_files:
|
||||
app_logger.error(f"No se encontró ningún archivo en {extract_dir}")
|
||||
return None
|
||||
|
||||
if len(bak_files) > 1:
|
||||
# Si hay múltiples, tomar el más reciente
|
||||
bak_files = [p for p in all_files if p.suffix.lower() == ".bak"]
|
||||
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(
|
||||
f"Múltiples archivos .bak encontrados ({len(bak_files)}), "
|
||||
"seleccionando el más reciente"
|
||||
"No se encontró archivo con extensión .bak; usando el archivo más "
|
||||
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}")
|
||||
return bak_path
|
||||
|
||||
|
||||
1
app/panel/__init__.py
Normal file
1
app/panel/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Integración con PANEL_BASES_ANEXO24 (servidor de restauración activo)."""
|
||||
427
app/panel/panel_client.py
Normal file
427
app/panel/panel_client.py
Normal 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}"
|
||||
@@ -4,8 +4,28 @@ import pyodbc
|
||||
from typing import Optional, Tuple, List
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
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
|
||||
class LogicalFile:
|
||||
@@ -34,7 +54,8 @@ class SQLServerManager:
|
||||
username: Usuario 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.username = username
|
||||
self.password = password
|
||||
@@ -49,10 +70,13 @@ class SQLServerManager:
|
||||
Returns:
|
||||
Cadena de conexión ODBC
|
||||
"""
|
||||
driver = resolve_odbc_driver()
|
||||
parts = [
|
||||
f"DRIVER={{ODBC Driver 17 for SQL Server}}",
|
||||
f"SERVER={self.server}"
|
||||
f"DRIVER={{{driver}}}",
|
||||
f"SERVER={self.server}",
|
||||
]
|
||||
if "18" in driver:
|
||||
parts.append("TrustServerCertificate=yes")
|
||||
|
||||
if database:
|
||||
parts.append(f"DATABASE={database}")
|
||||
@@ -65,6 +89,93 @@ class SQLServerManager:
|
||||
|
||||
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]]:
|
||||
"""
|
||||
Prueba la conexión a SQL Server.
|
||||
@@ -168,42 +279,25 @@ class SQLServerManager:
|
||||
Returns:
|
||||
Tupla (éxito, stdout, error)
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
# Construir las cláusulas MOVE
|
||||
move_clauses = []
|
||||
for lf in logical_files:
|
||||
if lf.type == 'D': # Data file
|
||||
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}'")
|
||||
|
||||
# Construir las cláusulas MOVE con un destino único por archivo lógico.
|
||||
# Renombrar todos los data files a {db}.mdf colisiona si el backup tiene
|
||||
# varios archivos; aquí cada archivo recibe un nombre distinto (G4).
|
||||
move_clauses = self._build_move_clauses(db_name, data_folder, logical_files)
|
||||
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;
|
||||
restore_query = (
|
||||
f"RESTORE DATABASE [{db_name}] FROM DISK = N'{backup_path}' "
|
||||
f"WITH {', '.join(move_clauses)}, REPLACE"
|
||||
)
|
||||
|
||||
-- Restaurar
|
||||
RESTORE DATABASE [{db_name}]
|
||||
FROM DISK = N'{backup_path}'
|
||||
WITH {', '.join(move_clauses)}, REPLACE;
|
||||
|
||||
-- Volver a modo multi user
|
||||
ALTER DATABASE [{db_name}] SET MULTI_USER;
|
||||
"""
|
||||
|
||||
app_logger.info(f"Comando RESTORE generado:\n{restore_cmd}")
|
||||
app_logger.info(f"Comando RESTORE generado:\n{restore_query}")
|
||||
|
||||
if dry_run:
|
||||
app_logger.info("Modo DRY RUN: No se ejecutará el RESTORE")
|
||||
return True, restore_cmd, None
|
||||
return True, restore_query, None
|
||||
|
||||
# Ejecutar RESTORE
|
||||
conn_str = self.get_connection_string()
|
||||
@@ -214,39 +308,129 @@ ALTER DATABASE [{db_name}] SET MULTI_USER;
|
||||
start_time = time.time()
|
||||
app_logger.info(f"Ejecutando RESTORE DATABASE [{db_name}]...")
|
||||
|
||||
# Ejecutar en múltiples pasos
|
||||
output_lines = []
|
||||
restore_error: Optional[str] = None
|
||||
|
||||
# 1. Single user
|
||||
try:
|
||||
cursor.execute(f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE")
|
||||
output_lines.append("Base de datos configurada en modo SINGLE_USER")
|
||||
# 1. Limpiar BD atascada o poner SINGLE_USER si ya existe ONLINE
|
||||
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:
|
||||
app_logger.warning(f"Error configurando SINGLE_USER (puede no existir la DB): {e}")
|
||||
|
||||
# 2. RESTORE
|
||||
restore_query = f"RESTORE DATABASE [{db_name}] FROM DISK = N'{backup_path}' WITH {', '.join(move_clauses)}, REPLACE"
|
||||
cursor.execute(restore_query)
|
||||
output_lines.append("RESTORE DATABASE completado")
|
||||
|
||||
# 3. Multi user
|
||||
cursor.execute(f"ALTER DATABASE [{db_name}] SET MULTI_USER")
|
||||
output_lines.append("Base de datos configurada en modo MULTI_USER")
|
||||
restore_error = str(e)
|
||||
app_logger.error(f"RESTORE DATABASE falló para [{db_name}]: {e}")
|
||||
finally:
|
||||
# 3. MULTI_USER solo si la BD quedó ONLINE (G7)
|
||||
final_state = self.get_database_state(db_name)
|
||||
if final_state == "ONLINE":
|
||||
try:
|
||||
cursor.execute(f"ALTER DATABASE [{db_name}] SET 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
|
||||
output_lines.append(f"Restauración completada en {elapsed:.2f}s")
|
||||
|
||||
conn.close()
|
||||
output_lines.append(f"Restauración finalizada en {elapsed:.2f}s")
|
||||
|
||||
output = "\n".join(output_lines)
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error restaurando base de datos: {str(e)}"
|
||||
app_logger.error(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:
|
||||
"""
|
||||
|
||||
1
app/transfer/__init__.py
Normal file
1
app/transfer/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Transferencia de archivos hacia los servidores SQL remotos (SMB)."""
|
||||
160
app/transfer/sftp_copy.py
Normal file
160
app/transfer/sftp_copy.py
Normal 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()
|
||||
@@ -1,15 +1,18 @@
|
||||
"""Tab de configuración."""
|
||||
|
||||
import sys
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
|
||||
QFormLayout, QLineEdit, QPushButton, QSpinBox,
|
||||
QCheckBox, QFileDialog, QMessageBox, QScrollArea
|
||||
QCheckBox, QFileDialog, QMessageBox, QScrollArea, QComboBox
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
from ..utils.crypto import encrypt_password, decrypt_password
|
||||
from ..extract.seven_zip import SevenZipExtractor
|
||||
from ..sql.sql_manager import SQLServerManager
|
||||
from ..panel import panel_client
|
||||
|
||||
|
||||
class ConfigTab(QWidget):
|
||||
@@ -20,6 +23,7 @@ class ConfigTab(QWidget):
|
||||
super().__init__()
|
||||
self.engine = engine
|
||||
self.config = {}
|
||||
self._catalog_names: list[str] = []
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
@@ -100,7 +104,6 @@ class ConfigTab(QWidget):
|
||||
|
||||
self.sql_windows_auth_checkbox = QCheckBox()
|
||||
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)
|
||||
|
||||
self.sql_username_input = QLineEdit()
|
||||
@@ -112,6 +115,18 @@ class ConfigTab(QWidget):
|
||||
self.sql_password_input.setEnabled(False)
|
||||
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.clicked.connect(self._test_sql_connection)
|
||||
sql_layout.addRow("", test_sql_btn)
|
||||
@@ -119,6 +134,37 @@ class ConfigTab(QWidget):
|
||||
sql_group.setLayout(sql_layout)
|
||||
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
|
||||
concurrency_group = QGroupBox("Concurrencia")
|
||||
concurrency_layout = QFormLayout()
|
||||
@@ -270,6 +316,41 @@ class ConfigTab(QWidget):
|
||||
self.auto_scan_checkbox.setChecked(features.get("auto_scan_enabled", True))
|
||||
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):
|
||||
"""Guarda la configuración."""
|
||||
# Construir config
|
||||
@@ -307,9 +388,31 @@ class ConfigTab(QWidget):
|
||||
"dry_run_mode": self.dry_run_checkbox.isChecked(),
|
||||
"auto_scan_enabled": self.auto_scan_checkbox.isChecked(),
|
||||
"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
|
||||
if not self.sql_windows_auth_checkbox.isChecked():
|
||||
password = self.sql_password_input.text()
|
||||
@@ -333,12 +436,17 @@ class ConfigTab(QWidget):
|
||||
line_edit.setText(folder)
|
||||
|
||||
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(
|
||||
self,
|
||||
"Seleccionar 7z.exe",
|
||||
"C:\\Program Files\\7-Zip",
|
||||
"Ejecutables (*.exe)"
|
||||
self, title, default_dir, file_filter
|
||||
)
|
||||
if 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."
|
||||
)
|
||||
|
||||
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):
|
||||
"""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_password_input.setEnabled(not checked)
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ class DashboardTab(QWidget):
|
||||
title.setFont(title_font)
|
||||
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
|
||||
stats_group = QGroupBox("Estadísticas de Jobs")
|
||||
stats_layout = QGridLayout()
|
||||
@@ -94,6 +102,18 @@ class DashboardTab(QWidget):
|
||||
times_group.setLayout(times_layout)
|
||||
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
|
||||
motor_group = QGroupBox("Estado del Motor")
|
||||
motor_layout = QHBoxLayout()
|
||||
@@ -136,6 +156,7 @@ class DashboardTab(QWidget):
|
||||
|
||||
def _load_initial_stats(self):
|
||||
"""Carga las estadísticas iniciales."""
|
||||
self.update_motor_status()
|
||||
stats = self.engine.get_stats()
|
||||
self.update_stats(stats)
|
||||
self._refresh_recent_jobs()
|
||||
@@ -193,3 +214,40 @@ class DashboardTab(QWidget):
|
||||
self.recent_table.setItem(i, 4, status_item)
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tab de logs y soporte."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -91,10 +92,15 @@ class LogsTab(QWidget):
|
||||
self.logs_text.setPlainText("\n".join(log_lines))
|
||||
|
||||
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:
|
||||
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:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QTabWidget,
|
||||
QSystemTrayIcon, QMenu
|
||||
QSystemTrayIcon, QMenu, QMessageBox, QApplication,
|
||||
)
|
||||
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 .jobs_tab import JobsTab
|
||||
from .nodes_tab import NodesTab
|
||||
from .config_tab import ConfigTab
|
||||
from .logs_tab import LogsTab
|
||||
from .tray_assets import load_tray_icon
|
||||
from ..engine.engine import RestoreEngine
|
||||
from ..utils.logger import app_logger
|
||||
|
||||
@@ -19,58 +20,68 @@ from ..utils.logger import app_logger
|
||||
class MainWindow(QMainWindow):
|
||||
"""Ventana principal de la aplicación."""
|
||||
|
||||
# Señal para minimizar a tray
|
||||
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."""
|
||||
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()
|
||||
|
||||
# Configurar ventana
|
||||
self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server")
|
||||
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()
|
||||
|
||||
# UI
|
||||
self._setup_ui()
|
||||
|
||||
# Conectar señales del motor
|
||||
self._connect_engine_signals()
|
||||
|
||||
# Timer para actualizar stats
|
||||
self.stats_timer = QTimer()
|
||||
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")
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Configura la interfaz de usuario."""
|
||||
# Widget central
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
# Layout principal
|
||||
layout = QVBoxLayout(central_widget)
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
# Tabs
|
||||
self.tabs = QTabWidget()
|
||||
|
||||
# Crear tabs
|
||||
self.dashboard_tab = DashboardTab(self.engine)
|
||||
self.jobs_tab = JobsTab(self.engine)
|
||||
self.nodes_tab = NodesTab(self.engine)
|
||||
self.config_tab = ConfigTab(self.engine)
|
||||
self.logs_tab = LogsTab(self.engine)
|
||||
|
||||
# Agregar tabs
|
||||
self.tabs.addTab(self.dashboard_tab, "📊 Dashboard")
|
||||
self.tabs.addTab(self.jobs_tab, "📋 Jobs")
|
||||
self.tabs.addTab(self.nodes_tab, "🔗 Nodos")
|
||||
@@ -78,15 +89,18 @@ class MainWindow(QMainWindow):
|
||||
self.tabs.addTab(self.logs_tab, "📝 Logs")
|
||||
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
# Menu bar
|
||||
self._setup_menu()
|
||||
|
||||
def _setup_menu(self):
|
||||
"""Configura el menú."""
|
||||
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")
|
||||
|
||||
self.start_action = QAction("▶️ Iniciar", self)
|
||||
@@ -114,20 +128,28 @@ class MainWindow(QMainWindow):
|
||||
self.scan_action.triggered.connect(self._scan_now)
|
||||
motor_menu.addAction(self.scan_action)
|
||||
|
||||
# Menú Ayuda
|
||||
help_menu = menubar.addMenu("Ayuda")
|
||||
|
||||
about_action = QAction("Acerca de", self)
|
||||
about_action.triggered.connect(self._show_about)
|
||||
help_menu.addAction(about_action)
|
||||
|
||||
def _setup_tray(self):
|
||||
"""Configura el icono de bandeja del sistema."""
|
||||
# Crear icono (usar un icono por defecto o crear uno simple)
|
||||
def _setup_tray(self, _attempt: int = 0):
|
||||
"""Configura el icono de bandeja del sistema (reintenta si aún no está listo)."""
|
||||
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.setIcon(load_tray_icon())
|
||||
self.tray_icon.setToolTip("CloudRestoreAS")
|
||||
|
||||
# Menú del tray
|
||||
tray_menu = QMenu()
|
||||
|
||||
show_action = QAction("Mostrar", self)
|
||||
@@ -150,16 +172,12 @@ class MainWindow(QMainWindow):
|
||||
|
||||
tray_menu.addSeparator()
|
||||
|
||||
quit_action = QAction("Salir", self)
|
||||
quit_action.triggered.connect(self._quit_application)
|
||||
tray_menu.addAction(quit_action)
|
||||
quit_tray_action = QAction("Salir", self)
|
||||
quit_tray_action.triggered.connect(self._quit_application)
|
||||
tray_menu.addAction(quit_tray_action)
|
||||
|
||||
self.tray_icon.setContextMenu(tray_menu)
|
||||
|
||||
# Doble clic en tray para mostrar/ocultar
|
||||
self.tray_icon.activated.connect(self._on_tray_activated)
|
||||
|
||||
# Mostrar tray
|
||||
self.tray_icon.show()
|
||||
|
||||
def _connect_engine_signals(self):
|
||||
@@ -169,13 +187,27 @@ class MainWindow(QMainWindow):
|
||||
self.engine.signals.config_loaded.connect(self._on_config_loaded)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent):
|
||||
"""Maneja el evento de cierre de ventana (minimizar a tray)."""
|
||||
if self.tray_icon.isVisible():
|
||||
self.hide()
|
||||
event.ignore()
|
||||
app_logger.info("Ventana minimizada a bandeja del sistema")
|
||||
else:
|
||||
"""Nunca cierra la app: minimiza a la bandeja (o a la barra de tareas si no hay bandeja)."""
|
||||
if self._force_quit:
|
||||
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):
|
||||
"""Maneja la activación del icono de tray."""
|
||||
@@ -191,12 +223,29 @@ class MainWindow(QMainWindow):
|
||||
self.activateWindow()
|
||||
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):
|
||||
"""Inicia el motor."""
|
||||
self.engine.start()
|
||||
self.start_action.setEnabled(False)
|
||||
self.pause_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")
|
||||
|
||||
def _pause_engine(self):
|
||||
@@ -204,6 +253,8 @@ class MainWindow(QMainWindow):
|
||||
self.engine.pause()
|
||||
self.pause_action.setEnabled(False)
|
||||
self.resume_action.setEnabled(True)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor pausado desde UI")
|
||||
|
||||
def _resume_engine(self):
|
||||
@@ -211,6 +262,8 @@ class MainWindow(QMainWindow):
|
||||
self.engine.resume()
|
||||
self.resume_action.setEnabled(False)
|
||||
self.pause_action.setEnabled(True)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor reanudado desde UI")
|
||||
|
||||
def _stop_engine(self):
|
||||
@@ -220,6 +273,8 @@ class MainWindow(QMainWindow):
|
||||
self.pause_action.setEnabled(False)
|
||||
self.resume_action.setEnabled(False)
|
||||
self.start_action.setEnabled(True)
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
app_logger.info("Motor detenido desde UI")
|
||||
|
||||
def _scan_now(self):
|
||||
@@ -229,6 +284,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _update_stats(self):
|
||||
"""Actualiza las estadísticas."""
|
||||
self._update_tray_status()
|
||||
self.dashboard_tab.update_motor_status()
|
||||
if self.engine.is_running():
|
||||
stats = self.engine.get_stats()
|
||||
self.dashboard_tab.update_stats(stats)
|
||||
@@ -248,20 +305,21 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _show_about(self):
|
||||
"""Muestra el diálogo Acerca de."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
QMessageBox.about(
|
||||
self,
|
||||
"Acerca de CloudRestoreAS",
|
||||
"CloudRestoreAS v1.0.0\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):
|
||||
"""Sale de la aplicación completamente."""
|
||||
app_logger.info("Saliendo de la aplicación")
|
||||
self._force_quit = True
|
||||
self.engine.stop()
|
||||
self.tray_icon.hide()
|
||||
self.close()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
if self.tray_icon:
|
||||
self.tray_icon.hide()
|
||||
QApplication.quit()
|
||||
|
||||
@@ -4,7 +4,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget,
|
||||
QTableWidgetItem, QPushButton, QDialog, QFormLayout,
|
||||
QLineEdit, QTextEdit, QCheckBox, QDialogButtonBox,
|
||||
QMessageBox
|
||||
QMessageBox, QLabel
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
|
||||
55
app/ui/tray_assets.py
Normal file
55
app/ui/tray_assets.py
Normal 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()
|
||||
@@ -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 win32crypt
|
||||
import sys
|
||||
from pathlib import Path
|
||||
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:
|
||||
"""
|
||||
Cifra una contraseña usando DPAPI de Windows.
|
||||
|
||||
Args:
|
||||
password: Contraseña en texto plano
|
||||
|
||||
Returns:
|
||||
Contraseña cifrada en base64
|
||||
"""
|
||||
"""Cifra una contraseña."""
|
||||
if not password:
|
||||
return ""
|
||||
|
||||
try:
|
||||
encrypted_bytes = win32crypt.CryptProtectData(
|
||||
password.encode('utf-8'),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0
|
||||
)
|
||||
return base64.b64encode(encrypted_bytes).decode('ascii')
|
||||
if _IS_WINDOWS:
|
||||
import win32crypt
|
||||
|
||||
encrypted_bytes = win32crypt.CryptProtectData(
|
||||
password.encode("utf-8"), None, None, None, None, 0
|
||||
)
|
||||
return base64.b64encode(encrypted_bytes).decode("ascii")
|
||||
|
||||
fernet = _get_fernet()
|
||||
return fernet.encrypt(password.encode("utf-8")).decode("ascii")
|
||||
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:
|
||||
"""
|
||||
Descifra una contraseña usando DPAPI de Windows.
|
||||
|
||||
Args:
|
||||
encrypted_password: Contraseña cifrada en base64
|
||||
|
||||
Returns:
|
||||
Contraseña en texto plano
|
||||
"""
|
||||
"""Descifra una contraseña."""
|
||||
if not encrypted_password:
|
||||
return ""
|
||||
|
||||
try:
|
||||
encrypted_bytes = base64.b64decode(encrypted_password)
|
||||
decrypted_bytes = win32crypt.CryptUnprotectData(
|
||||
encrypted_bytes,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0
|
||||
)[1]
|
||||
return decrypted_bytes.decode('utf-8')
|
||||
if _IS_WINDOWS:
|
||||
import win32crypt
|
||||
|
||||
encrypted_bytes = base64.b64decode(encrypted_password)
|
||||
decrypted_bytes = win32crypt.CryptUnprotectData(
|
||||
encrypted_bytes, None, None, None, 0
|
||||
)[1]
|
||||
return decrypted_bytes.decode("utf-8")
|
||||
|
||||
fernet = _get_fernet()
|
||||
return fernet.decrypt(encrypted_password.encode("ascii")).decode("utf-8")
|
||||
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
131
build-all.sh
Executable 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
500
build-linux.log
Normal 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
343
build-windows.log
Normal 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
113
build.ps1
@@ -1,72 +1,63 @@
|
||||
# Script de build para PyInstaller
|
||||
# Genera un ejecutable único de la aplicación
|
||||
# Build CloudRestoreAS — PyInstaller onefile portable
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = $PSScriptRoot
|
||||
|
||||
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 ""
|
||||
|
||||
# Verificar que PyInstaller esté instalado
|
||||
Write-Host "Verificando PyInstaller..." -ForegroundColor Yellow
|
||||
$pyinstallerCheck = pip show pyinstaller 2>$null
|
||||
if (-not $pyinstallerCheck) {
|
||||
Write-Host "PyInstaller no está instalado. Instalando..." -ForegroundColor Yellow
|
||||
pip install pyinstaller
|
||||
& "$Root\packaging\scripts\download-bundled-deps.ps1"
|
||||
|
||||
# Python de Windows (evitar venv creado desde WSL)
|
||||
$PythonCandidates = @(
|
||||
"$env:LOCALAPPDATA\Programs\Python\Python313\python.exe",
|
||||
"$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
|
||||
$buildDir = "build"
|
||||
$distDir = "dist"
|
||||
|
||||
Write-Host ""
|
||||
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
|
||||
$venvDir = Join-Path $Root "venv-windows"
|
||||
$venvPython = Join-Path $venvDir "Scripts\python.exe"
|
||||
if (-not (Test-Path $venvPython)) {
|
||||
if (Test-Path $venvDir) { Remove-Item -Recurse -Force $venvDir }
|
||||
& $Python -m venv $venvDir
|
||||
}
|
||||
& $venvPython -m pip install --upgrade pip -q
|
||||
& $venvPython -m pip install -r (Join-Path $Root "requirements-windows.txt") -q
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Ejecutando PyInstaller..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
# Ejecutar PyInstaller
|
||||
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 ""
|
||||
$distDir = Join-Path $Root "dist"
|
||||
$isUncRoot = $Root -like "\\*"
|
||||
# PyInstaller no puede usar workpath en UNC (WSL); usar carpeta local en Windows
|
||||
if ($isUncRoot) {
|
||||
$buildDir = Join-Path $env:LOCALAPPDATA "Temp\cloudrestore-build"
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "===============================================" -ForegroundColor Red
|
||||
Write-Host "Error durante el build" -ForegroundColor Red
|
||||
Write-Host "===============================================" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
$buildDir = Join-Path $Root "build"
|
||||
}
|
||||
if (Test-Path $buildDir) {
|
||||
Remove-Item -Recurse -Force $buildDir -ErrorAction SilentlyContinue
|
||||
}
|
||||
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
|
||||
Read-Host "Presiona Enter para continuar..."
|
||||
Write-Host "Ejecutando PyInstaller (onefile)..." -ForegroundColor Yellow
|
||||
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
43
build.sh
Executable 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
103
install.sh
Executable 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."
|
||||
113
packaging/CloudRestoreAS.spec
Normal file
113
packaging/CloudRestoreAS.spec
Normal 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
40
packaging/LEEME.txt
Normal 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).
|
||||
BIN
packaging/assets/tray-icon.ico
Normal file
BIN
packaging/assets/tray-icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
packaging/assets/tray-icon.png
Normal file
BIN
packaging/assets/tray-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
9
packaging/bundled-versions.json
Normal file
9
packaging/bundled-versions.json
Normal 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"
|
||||
}
|
||||
19
packaging/linux/cloudrestoreas.service
Normal file
19
packaging/linux/cloudrestoreas.service
Normal 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
6
packaging/scripts/build-all.sh
Executable 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" "$@"
|
||||
28
packaging/scripts/docker-build-linux.sh
Executable file
28
packaging/scripts/docker-build-linux.sh
Executable 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
|
||||
'
|
||||
93
packaging/scripts/download-bundled-deps.ps1
Normal file
93
packaging/scripts/download-bundled-deps.ps1
Normal 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
|
||||
156
packaging/scripts/download-bundled-deps.sh
Executable file
156
packaging/scripts/download-bundled-deps.sh
Executable 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"
|
||||
155
packaging/scripts/make-icons.py
Normal file
155
packaging/scripts/make-icons.py
Normal 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()
|
||||
47
packaging/scripts/package-release.sh
Executable file
47
packaging/scripts/package-release.sh
Executable 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"
|
||||
19
packaging/templates/env.default
Normal file
19
packaging/templates/env.default
Normal 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
2
requirements-windows.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pywin32>=306
|
||||
@@ -1,13 +1,14 @@
|
||||
# CloudRestoreAS - Dependencias
|
||||
# CloudRestoreAS - Dependencias base (Windows y Linux)
|
||||
|
||||
# PySide6 para la interfaz gráfica
|
||||
PySide6>=6.6.0
|
||||
|
||||
# pyodbc para SQL Server
|
||||
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)
|
||||
pywin32>=306
|
||||
|
||||
# Para empaquetado (opcional)
|
||||
# Empaquetado (opcional, solo build)
|
||||
pyinstaller>=6.0.0
|
||||
|
||||
# Pruebas (opcional)
|
||||
pytest>=8.0.0
|
||||
|
||||
197
runner.py
197
runner.py
@@ -1,53 +1,186 @@
|
||||
"""Punto de entrada principal de la aplicación."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
# Agregar el directorio raíz al path
|
||||
ROOT_DIR = Path(__file__).parent
|
||||
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
|
||||
ROOT_DIR = Path(__file__).parent.resolve()
|
||||
if not getattr(sys, "frozen", False):
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
|
||||
def main():
|
||||
"""Función principal."""
|
||||
# Crear directorios necesarios
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="CloudRestoreAS")
|
||||
parser.add_argument(
|
||||
"--minimized",
|
||||
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("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)
|
||||
|
||||
# Configurar aplicación Qt
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("CloudRestoreAS")
|
||||
app.setOrganizationName("Aduanasoft")
|
||||
|
||||
# Habilitar high DPI scaling
|
||||
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:
|
||||
# Crear y mostrar ventana principal
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
app_logger.info("Ventana principal mostrada")
|
||||
|
||||
# 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(_run())
|
||||
except SystemExit:
|
||||
raise
|
||||
except BaseException as exc: # capturamos TODO en el arranque: nunca morir en silencio
|
||||
_show_fatal(exc)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
||||
5
tests/conftest.py
Normal file
5
tests/conftest.py
Normal 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
79
tests/test_bootstrap.py
Normal 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"
|
||||
41
tests/test_colocated_resolve.py
Normal file
41
tests/test_colocated_resolve.py
Normal 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
381
tests/test_panel_client.py
Normal 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
138
tests/test_route_forward.py
Normal 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
135
tests/test_sftp_copy.py
Normal 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, " ")
|
||||
77
tests/test_sql_manager_move.py
Normal file
77
tests/test_sql_manager_move.py
Normal 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
|
||||
Reference in New Issue
Block a user