Merge pull request 'feature/integracion-panel-restore-targets' (#1) from feature/integracion-panel-restore-targets into development

Reviewed-on: #1
This commit is contained in:
2026-06-30 17:29:55 +00:00
48 changed files with 4572 additions and 413 deletions

21
.gitignore vendored
View File

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

197
INTEGRACION_PANEL.md Normal file
View File

@@ -0,0 +1,197 @@
# Integración CloudRestoreAS ↔ PANEL_BASES_ANEXO24
## Variables de entorno y configuración
| Panel (`.env`) | CloudRestoreAS (pestaña Config → PANEL) | Debe coincidir |
|----------------|-------------------------------------------|----------------|
| `CLOUDRESTORE_API_TOKEN` | `panel.api_token` | **Sí** — mismo valor en todas las instalaciones |
| `ENCRYPTION_KEY` | — | Solo panel (cifra credenciales de `restore_targets`) |
| URL del panel (ej. `https://panel:3000`) | `panel.api_url` | **Sí** — base URL sin barra final |
| — | `panel.instance_key` | Nombre de **este** servidor en el panel (`restore_targets.name`) |
Generar token:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
Generar clave de cifrado (panel):
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```
`BACKUP_PATH` del panel es independiente: lista respaldos en el dashboard. Cada CloudRestoreAS reporta su carpeta local vía `instance-config` (clave = nombre del servidor).
---
## Enrutamiento automático (todos los CRA)
No hay modos que configurar. **Todo CRA con panel** hace lo mismo:
1. Llega un ZIP a la carpeta de entrada.
2. `GET resolve-route?filename=X&instance=<esta instancia>` — el panel identifica el nodo y el servidor asignado.
3. Si `action=restore_local` → extract + RESTORE aquí (credenciales SQL de este servidor en el panel).
4. Si `action=forward` → SFTP del ZIP al `input_folder` del destino (credenciales SSH del destino).
5. Mover ZIP a `processed`; reportar `completed` o `forwarded`.
```mermaid
flowchart TB
ZIP[ZIP en carpeta local]
CRA[Any CRA con instance_key]
Panel["resolve-route"]
SFTP[SFTP al destino]
SQL[RESTORE local]
ZIP --> CRA --> Panel
Panel -->|restore_local| SQL
Panel -->|forward| SFTP
```
Aplica igual en Alfa, Omega o un hub donde llegan todos los ZIP:
| Situación | Qué hace el CRA |
|-----------|-----------------|
| Nodo asignado a **esta** instancia | RESTORE local |
| Nodo asignado a **otro** servidor | SFTP ZIP al destino |
La decisión la toma el **panel** (nodo + asignación en Gestión BD), no el operador.
### Qué configura cada instalación
| Campo | Para qué |
|-------|----------|
| URL + token del panel | Conectar al panel |
| **Instancia** | Identidad de este servidor (`restore_targets.name`) |
| Carpeta entrada | Dónde vigila ZIPs esta máquina |
El selector de instancia se llena desde `GET /api/restore/target-catalog`. No hay límite de servidores.
### Hub donde llegan todos los ZIP
Ejemplo: máquina **Alfa** recibe todos los archivos y también tiene bases propias:
1. Panel: servidor Alfa registrado; bases de Alfa/Omega/Gamma asignadas en Gestión BD.
2. CRA en Alfa: instancia **Alfa**, misma URL/token que el resto.
3. ZIP de base Alfa → RESTORE local.
4. ZIP de base Omega → SFTP a carpeta de Omega (card **Reportada**).
5. CRA Omega detecta el ZIP y restaura localmente.
No hay paso extra ni modo especial.
### Sin panel (legacy local)
Si `panel.api_url` está vacío, CloudRestoreAS usa nodos SQLite locales y SQL de la pestaña Config (sin reparto automático).
### Config legacy `panel.mode`
Valores antiguos (`orchestrator`, `hub_restore`, `colocated`) se **ignoran**. Se registra un aviso en log y se usa siempre `resolve-route`. El modo hub que subía `.bak` por SFTP ya no aplica.
---
## Contrato API (`/api/restore/*`)
Autenticación: `Authorization: Bearer <CLOUDRESTORE_API_TOKEN>`.
### GET `/api/restore/target-catalog`
**Cliente:** `panel_client.list_restore_target_names()`
| Respuesta 200 | Descripción |
|---------------|-------------|
| `targets[]` | `{ "id", "name" }` — sin credenciales |
### GET `/api/restore/resolve-route?filename=<zip>&instance=<nombre>`
**Cliente:** `panel_client.resolve_route()`
| Query | Obligatorio | Descripción |
|-------|-------------|-------------|
| `filename` | Sí | Nombre del ZIP (panel resuelve nodo desde el stem) |
| `instance` | Sí | Instancia de este CRA (`restore_targets.name`) |
| Respuesta 200 | Descripción |
|---------------|-------------|
| `action` | `restore_local` o `forward` |
| `db_name` | Base resuelta |
| `node_key` | Nodo en panel |
| `target` | Servidor destino (SQL/SSH + `input_folder`) |
| Código | Significado |
|--------|-------------|
| 404 | Sin nodo/base o sin servidor asignado |
| 503 | Destino sin `input_folder` reportado |
### GET `/api/restore/target-for?database=<db_name>&instance=<nombre>`
Usado por utilidades legacy; el flujo principal de jobs usa `resolve-route`.
### POST `/api/restore/job-result`
`status`: `completed` | `failed` | `forwarded`.
### POST `/api/restore/instance-config`
Reporte de carpeta de entrada (`instance_key` = nombre del servidor).
---
## Agregar servidores adicionales
1. Panel → **Servidores de Restauración → + Nuevo servidor**
2. **Gestión de Bases de Datos** → dropdown servidor por base
3. Instalar CRA → Config → instancia → Guardar (card **Reportada**)
---
## Despliegue inicial
### Orden de arranque (migraciones automáticas)
1. **a24c-postgres** — volumen Postgres.
2. **a24c-backend** — aplica `alembic upgrade head` al iniciar (incluye tablas CRA:
`restore_targets`, `restore_job_logs`, `cloudrestore_status`, `restore_target_id`).
3. **Panel** (`docker compose up`) — espera el esquema CRA en Postgres antes de abrir el puerto 3000.
No hace falta ejecutar `database/migrations/*.sql` a mano: la fuente de verdad es Alembic en **a24c**.
### Panel
```bash
cd ~/dev/PANEL_BASES_ANEXO24
cp .env.example .env
docker compose up -d
```
Servidores de restauración + asignación de bases en Gestión BD.
### Cada Windows con CloudRestoreAS
| Config | Valor |
|--------|--------|
| URL PANEL | misma en todos |
| API Token | mismo en todos |
| **Instancia** | nombre de **este** servidor en panel |
| Carpeta Entrada | local de esta máquina |
### Verificaciones
| Paso | Qué comprobar |
|------|----------------|
| Cards en panel | **Reportada** con carpeta |
| ZIP propio | RESTORE local |
| ZIP ajeno en esta carpeta | SFTP al destino; bitácora `forwarded` |
| Destino recibe ZIP | RESTORE local allí |
### Troubleshooting
- **Sin reportar**: CRA destino no guardó config o no alcanza el panel.
- **503 en forward**: destino sin `input_folder` reportado.
- **Job diferido**: panel caído, sin `instance_key`, o base sin asignación.
- **401**: token distinto entre panel y CRA.
---
## Prueba local
Un CRA con `instance_key` igual al nombre en panel. El panel muestra una card por servidor; las no reportadas aparecen como **Sin reportar**.

View File

@@ -20,6 +20,19 @@ Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"}
.\venv\Scripts\python.exe runner.py .\venv\Scripts\python.exe runner.py
``` ```
### Integración con PANEL_BASES_ANEXO24
Si usas el panel para asignar servidores de restauración, ver [INTEGRACION_PANEL.md](INTEGRACION_PANEL.md) para tokens, catálogo dinámico (`target-catalog`) y prueba end-to-end.
**Prueba local en tu PC** (carpetas + config automática):
```powershell
.\scripts\prepare_local_test.ps1
.\venv\Scripts\python.exe runner.py
```
Copia el ZIP a `C:\CloudRestore\Entrada` (ver `LEEME.txt` en esa carpeta).
### 3. Configuración Básica (en la aplicación) ### 3. Configuración Básica (en la aplicación)
1. **Tab Configuración**: 1. **Tab Configuración**:
@@ -113,10 +126,10 @@ C:\CloudRestore\
## Para Ejecutar 24/7 ## Para Ejecutar 24/7
1. Inicia la aplicación 1. Inicia la aplicación (el motor arranca solo si `CLOUDRESTORE_AUTO_START=true`)
2. Menú Motor → Iniciar 2. Cierra la ventana con **X** — se minimiza a la bandeja del sistema
3. Minimiza (se va a la bandeja del sistema) 3. La app continuará monitoreando en segundo plano
4. La app continuará monitoreando en segundo plano 4. Para salir por completo: bandeja → **Salir** o menú **Archivo → Salir**
--- ---

View File

@@ -73,19 +73,28 @@ CloudRestoreAS es una aplicación Windows de escritorio desarrollada en Python 3
python runner.py python runner.py
``` ```
### Opción 2: Ejecutable Empaquetado ### Opción 2: Ejecutable portable (recomendado para producción)
1. **Generar el ejecutable** **Windows** — generar:
```powershell ```powershell
.\build.ps1 .\build.ps1
``` ```
Salida: `dist\CloudRestoreAS.exe`
Esto creará `CloudRestoreAS.exe` en la carpeta `dist\` **Linux** — generar:
```bash
./build.sh
```
Salida: `dist/CloudRestoreAS`
2. **Distribuir** **Desplegar en cada equipo** (sin Python, sin instalador):
- Copiar `CloudRestoreAS.exe` a la ubicación deseada 1. Copiar **solo** el ejecutable a una carpeta.
- Crear carpetas `data` y `logs` en el mismo directorio 2. Ejecutarlo (doble clic o `./CloudRestoreAS`).
- El ejecutable es portable (no requiere instalación) 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.
Ver `packaging/LEEME.txt` para instrucciones resumidas.
## ⚙️ Configuración Inicial ## ⚙️ Configuración Inicial

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

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

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

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

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

@@ -0,0 +1,83 @@
"""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,
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() or dest.exists():
return
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dest)
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)
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")

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

@@ -0,0 +1,138 @@
"""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 salvo START_MINIMIZED=true)
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=
"""

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

@@ -0,0 +1,71 @@
"""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:
so_path = None
for pattern in ("libmsodbcsql-18*.so*", "msodbcsql-18*.so*"):
matches = list(odbc_dir.glob(pattern))
if matches:
so_path = matches[0]
break
driver_line = str(so_path.resolve()) if so_path else "libmsodbcsql-18.so"
content = f"""[ODBC Driver 18 for SQL Server]
Description=Microsoft ODBC Driver 18 for SQL Server
Driver={driver_line}
UsageCount=1
"""
inst.write_text(content, encoding="utf-8")
ini = odbc_dir / "odbc.ini"
if not ini.exists():
ini.write_text("[ODBC Data Sources]\n", encoding="utf-8")
def configure_odbc_environment() -> None:
"""Apunta pyodbc al driver embebido en config/odbc si está disponible."""
if not ODBC_DIR.is_dir():
return
lib_dir = ODBC_DIR / "lib"
if lib_dir.is_dir():
existing = os.environ.get("LD_LIBRARY_PATH", "")
lib_path = str(lib_dir.resolve())
if lib_path not in existing.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{lib_path}:{existing}" if existing else lib_path
)
_write_portable_odbc_ini(ODBC_DIR)
odbc_sys = str(ODBC_DIR.resolve())
os.environ["ODBCSYSINI"] = odbc_sys
os.environ["ODBCINI"] = str((ODBC_DIR / "odbc.ini").resolve())
if IS_WINDOWS:
os.environ["PATH"] = odbc_sys + os.pathsep + os.environ.get("PATH", "")
app_logger.debug(f"ODBC configurado: ODBCSYSINI={odbc_sys}")

View File

@@ -1,13 +1,51 @@
"""Constantes globales de la aplicación.""" """Constantes globales de la aplicación."""
import sys
from pathlib import Path from pathlib import Path
# Rutas base
APP_DIR = Path(__file__).parent.parent
DATA_DIR = APP_DIR / "data"
LOGS_DIR = APP_DIR / "logs"
# Base de datos def _resolve_app_dir() -> Path:
"""Directorio donde vive el ejecutable (persistente)."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent
def _resolve_bundle_dir() -> Path:
"""Recursos embebidos en el binario PyInstaller onefile."""
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS)
platform = "windows" if sys.platform == "win32" else "linux"
return _resolve_app_dir() / "packaging" / "bundled" / platform
APP_DIR = _resolve_app_dir()
CONFIG_DIR = APP_DIR / "config"
ENV_PATH = CONFIG_DIR / ".env"
DATA_DIR = CONFIG_DIR / "data"
LOGS_DIR = CONFIG_DIR / "logs"
ODBC_DIR = CONFIG_DIR / "odbc"
SEVEN_ZIP_DIR = CONFIG_DIR / "7zip"
IS_WINDOWS = sys.platform == "win32"
BUNDLE_DIR = _resolve_bundle_dir()
BUNDLED_SOURCE_7ZIP = BUNDLE_DIR / "bundled" / "7zip"
BUNDLED_SOURCE_ODBC = BUNDLE_DIR / "bundled" / "odbc"
def _default_7zip_name() -> str:
if IS_WINDOWS:
return "7z.exe"
return "7zz"
BUNDLED_7ZIP_EXE = SEVEN_ZIP_DIR / _default_7zip_name()
BUNDLED_7ZIP_FALLBACK = SEVEN_ZIP_DIR / ("7za.exe" if IS_WINDOWS else "7zz")
# Carpetas de trabajo junto al ejecutable
DIR_ENTRADA = APP_DIR / "Entrada"
DIR_PROCESADOS = APP_DIR / "Procesados"
DIR_FALLADOS = APP_DIR / "Fallados"
DIR_TEMP = APP_DIR / "Temp"
DB_PATH = DATA_DIR / "app.db" DB_PATH = DATA_DIR / "app.db"
# Estados de jobs # Estados de jobs
@@ -21,7 +59,7 @@ class JobStatus:
FAILED_RESTART = "failed_restart" FAILED_RESTART = "failed_restart"
CANCELLED = "cancelled" CANCELLED = "cancelled"
# Niveles de log
class LogLevel: class LogLevel:
DEBUG = "DEBUG" DEBUG = "DEBUG"
INFO = "INFO" INFO = "INFO"
@@ -29,7 +67,7 @@ class LogLevel:
ERROR = "ERROR" ERROR = "ERROR"
CRITICAL = "CRITICAL" CRITICAL = "CRITICAL"
# Tipos de paso de job
class StepType: class StepType:
STABILITY_CHECK = "stability_check" STABILITY_CHECK = "stability_check"
NODE_MAPPING = "node_mapping" NODE_MAPPING = "node_mapping"
@@ -39,44 +77,63 @@ class StepType:
FILELIST = "filelist" FILELIST = "filelist"
RESTORE = "restore" RESTORE = "restore"
CLEANUP = "cleanup" CLEANUP = "cleanup"
SFTP_COPY = "sftp_copy"
FORWARD_ZIP = "forward_zip"
# Configuración por defecto
DEFAULT_CONFIG = { def default_seven_zip_path() -> str:
"paths": { for candidate in (BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK):
"input_folder": "", if candidate.exists():
"processed_folder": "", return str(candidate)
"failed_folder": "", return ""
"extract_folder": "",
def default_work_paths() -> dict[str, str]:
return {
"input_folder": str(DIR_ENTRADA),
"processed_folder": str(DIR_PROCESADOS),
"failed_folder": str(DIR_FALLADOS),
"extract_folder": str(DIR_TEMP),
"data_sql_folder": "", "data_sql_folder": "",
"seven_zip_exe": "" "seven_zip_exe": default_seven_zip_path(),
}, }
DEFAULT_CONFIG = {
"paths": default_work_paths(),
"sql": { "sql": {
"server": "localhost", "server": "localhost",
"use_windows_auth": True, "use_windows_auth": IS_WINDOWS,
"username": "", "username": "",
"password_encrypted": "" "password_encrypted": "",
}, },
"concurrency": { "concurrency": {
"extract_workers": 1, "extract_workers": 1,
"restore_workers": 1 "restore_workers": 1,
}, },
"stability": { "stability": {
"check_enabled": True, "check_enabled": True,
"check_interval_seconds": 5, "check_interval_seconds": 5,
"stable_duration_seconds": 10, "stable_duration_seconds": 10,
"use_ready_marker": False "use_ready_marker": False,
}, },
"timeouts": { "timeouts": {
"extract_minutes": 30, "extract_minutes": 30,
"restore_minutes": 60 "restore_minutes": 60,
}, },
"retries": { "retries": {
"max_attempts": 3, "max_attempts": 3,
"retry_delay_seconds": 60 "retry_delay_seconds": 60,
}, },
"features": { "features": {
"dry_run_mode": False, "dry_run_mode": False,
"auto_scan_enabled": True, "auto_scan_enabled": True,
"scan_interval_seconds": 30 "scan_interval_seconds": 30,
} },
"panel": {
"api_url": "",
"api_token": "",
"instance_key": "",
"verify_ssl": False,
},
} }

View File

@@ -176,6 +176,15 @@ class JobRepository:
tuple(params) tuple(params)
) )
@staticmethod
def delete(job_id: str):
"""
Elimina un job y su rastro de hash. Se usa para diferir un job cuando no
hay servidor de restauración activo: al borrarlo, el próximo escaneo del
FileWatcher vuelve a detectar el archivo y reintenta (G8 del plan).
"""
db.execute("DELETE FROM jobs WHERE job_id = ?", (job_id,))
@staticmethod @staticmethod
def exists_by_hash(source_hash: str) -> bool: def exists_by_hash(source_hash: str) -> bool:
"""Verifica si existe un job con el hash dado.""" """Verifica si existe un job con el hash dado."""

View File

@@ -1,15 +1,20 @@
"""Motor principal de la aplicación.""" """Motor principal de la aplicación."""
import platform
import socket
from typing import Optional from typing import Optional
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QObject, Signal, QThreadPool from PySide6.QtCore import QObject, Signal, QThreadPool
from .file_watcher import FileWatcher, FileStabilityChecker, calculate_file_hash from .file_watcher import FileWatcher, FileStabilityChecker, calculate_file_hash
from .restore_worker import RestoreWorker from .restore_worker import RestoreWorker
from .. import __version__
from ..db.job_repository import JobRepository from ..db.job_repository import JobRepository
from ..db.event_repository import EventRepository from ..db.event_repository import EventRepository
from ..db.config_repository import ConfigRepository from ..db.config_repository import ConfigRepository
from ..config.env_loader import apply_env_overrides, load_env_file
from ..constants import JobStatus, DEFAULT_CONFIG from ..constants import JobStatus, DEFAULT_CONFIG
from ..panel import panel_client
from ..utils.logger import app_logger from ..utils.logger import app_logger
@@ -41,18 +46,20 @@ class RestoreEngine(QObject):
# Configuración # Configuración
self._config = self._load_config() self._config = self._load_config()
self._report_instance_config_to_panel()
app_logger.info("RestoreEngine inicializado") app_logger.info("RestoreEngine inicializado")
def _load_config(self) -> dict: def _load_config(self) -> dict:
"""Carga la configuración desde la base de datos.""" """Carga la configuración desde la base de datos y variables de entorno."""
load_env_file()
config = ConfigRepository.get("app_config", DEFAULT_CONFIG.copy()) config = ConfigRepository.get("app_config", DEFAULT_CONFIG.copy())
# Asegurar que tiene todas las claves
for key, value in DEFAULT_CONFIG.items(): for key, value in DEFAULT_CONFIG.items():
if key not in config: if key not in config:
config[key] = value config[key] = value
config = apply_env_overrides(config)
self.signals.config_loaded.emit(config) self.signals.config_loaded.emit(config)
return config return config
@@ -66,6 +73,7 @@ class RestoreEngine(QObject):
ConfigRepository.set("app_config", config) ConfigRepository.set("app_config", config)
self._config = config self._config = config
app_logger.info("Configuración guardada") app_logger.info("Configuración guardada")
self._report_instance_config_to_panel()
# Reconfigurar file watcher si está corriendo # Reconfigurar file watcher si está corriendo
if self._running: if self._running:
@@ -103,6 +111,7 @@ class RestoreEngine(QObject):
self._running = True self._running = True
self._paused = False self._paused = False
self._report_instance_config_to_panel()
EventRepository.create("INFO", "Motor iniciado") EventRepository.create("INFO", "Motor iniciado")
app_logger.info("Motor iniciado") app_logger.info("Motor iniciado")
@@ -190,6 +199,30 @@ class RestoreEngine(QObject):
return stats return stats
def _report_instance_config_to_panel(self) -> None:
"""Reporta input_folder al PANEL (best-effort, no bloquea el flujo)."""
panel_cfg = self._config.get("panel", {})
api_url = (panel_cfg.get("api_url") or "").strip()
api_token = (panel_cfg.get("api_token") or "").strip()
if not api_url or not api_token:
return
input_folder = (self._config.get("paths") or {}).get("input_folder") or ""
try:
host_name = socket.gethostname() or platform.node()
except Exception:
host_name = platform.node()
instance_key = (panel_cfg.get("instance_key") or "").strip() or None
panel_client.report_instance_config(
api_url=api_url,
api_token=api_token,
input_folder=input_folder,
host_name=host_name,
app_version=__version__,
instance_key=instance_key,
)
def _validate_config(self) -> bool: def _validate_config(self) -> bool:
"""Valida que la configuración sea correcta.""" """Valida que la configuración sea correcta."""
paths = self._config["paths"] paths = self._config["paths"]

View File

@@ -14,9 +14,19 @@ from ..db.event_repository import EventRepository
from ..db.node_repository import NodeRepository from ..db.node_repository import NodeRepository
from ..extract.seven_zip import SevenZipExtractor from ..extract.seven_zip import SevenZipExtractor
from ..sql.sql_manager import SQLServerManager from ..sql.sql_manager import SQLServerManager
from ..panel import panel_client
from ..transfer import sftp_copy
from ..utils.logger import app_logger from ..utils.logger import app_logger
class RestoreDeferred(Exception):
"""
Señala que el job no puede ejecutarse ahora pero NO es un error: no hay
servidor de restauración activo en el PANEL. El job se difiere (se borra para
que el próximo escaneo lo reintente), no se marca como fallido (G8 del plan).
"""
class RestoreWorkerSignals(QObject): class RestoreWorkerSignals(QObject):
"""Señales para comunicación con la UI.""" """Señales para comunicación con la UI."""
job_started = Signal(str) # job_id job_started = Signal(str) # job_id
@@ -50,6 +60,17 @@ class RestoreWorker(QRunnable):
self._extract_dir: Optional[str] = None self._extract_dir: Optional[str] = None
self._bak_path: Optional[str] = None self._bak_path: Optional[str] = None
# Servidor de restauración activo del PANEL (None = modo local sin PANEL).
self._target: Optional[dict] = None
def _panel_configured(self) -> bool:
panel_cfg = self.config.get("panel", {})
return bool((panel_cfg.get("api_url") or "").strip())
def _legacy_panel_mode(self) -> Optional[str]:
"""Detecta mode legacy en config; solo para log de deprecación."""
mode = (self.config.get("panel", {}).get("mode") or "").strip()
return mode if mode in ("orchestrator", "hub_restore", "colocated") else None
def run(self): def run(self):
"""Ejecuta el procesamiento del job.""" """Ejecuta el procesamiento del job."""
@@ -64,8 +85,26 @@ class RestoreWorker(QRunnable):
if not job: if not job:
raise ValueError(f"Job {self.job_id} no encontrado") raise ValueError(f"Job {self.job_id} no encontrado")
# Pipeline de procesamiento if not self._panel_configured():
self._process_node_mapping(job) self._process_node_mapping(job)
job = JobRepository.get(self.job_id)
self._target = None
app_logger.info("PANEL no configurado: usando configuración SQL local")
else:
legacy_mode = self._legacy_panel_mode()
if legacy_mode and legacy_mode != "colocated":
app_logger.warning(
f"panel.mode={legacy_mode} está obsoleto; "
"se usa enrutamiento automático (resolve-route)"
)
route = self._resolve_route(job)
job = JobRepository.get(self.job_id)
if route["action"] == "forward":
self._forward_zip(job, route, start_time)
return
self._target = route["target"]
# Extraer y restaurar localmente.
self._extract_backup(job) self._extract_backup(job)
self._restore_database(job) self._restore_database(job)
self._cleanup(job) self._cleanup(job)
@@ -80,8 +119,19 @@ class RestoreWorker(QRunnable):
app_logger.info( app_logger.info(
f"Job {self.job_id} completado exitosamente en {total_ms}ms" f"Job {self.job_id} completado exitosamente en {total_ms}ms"
) )
self._report_to_panel(job, "completed", duration_ms=total_ms)
self.signals.job_completed.emit(self.job_id, True) self.signals.job_completed.emit(self.job_id, True)
except RestoreDeferred as e:
# No hay servidor activo: borrar el job para que el próximo escaneo
# vuelva a detectar el archivo y reintente. No cuenta como fallo.
app_logger.warning(f"Job {self.job_id} diferido: {e}")
EventRepository.create(
"WARNING", f"Restauración diferida (sin servidor activo): {e}", self.job_id
)
JobRepository.delete(self.job_id)
self.signals.job_completed.emit(self.job_id, False)
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
app_logger.error(f"Error procesando job {self.job_id}: {error_msg}", exc_info=True) app_logger.error(f"Error procesando job {self.job_id}: {error_msg}", exc_info=True)
@@ -95,9 +145,124 @@ class RestoreWorker(QRunnable):
EventRepository.create("ERROR", f"Job falló: {error_msg}", self.job_id) EventRepository.create("ERROR", f"Job falló: {error_msg}", self.job_id)
job = JobRepository.get(self.job_id)
self._report_to_panel(job, "failed", error_message=error_msg)
self.signals.error_occurred.emit(self.job_id, error_msg) self.signals.error_occurred.emit(self.job_id, error_msg)
self.signals.job_completed.emit(self.job_id, False) self.signals.job_completed.emit(self.job_id, False)
def _resolve_route(self, job) -> dict:
"""
Enrutamiento automático vía panel: restore_local o forward según nodo/asignación.
Actualiza db_name del job desde el panel.
"""
panel_cfg = self.config.get("panel", {})
api_url = (panel_cfg.get("api_url") or "").strip()
api_token = (panel_cfg.get("api_token") or "").strip()
instance_key = (panel_cfg.get("instance_key") or "").strip()
if not instance_key:
raise RestoreDeferred(
"Falta instance_key (instancia/servidor) en la configuración del PANEL"
)
route = panel_client.resolve_route(
api_url,
api_token,
job.source_name,
instance_key=instance_key,
)
if not route:
raise RestoreDeferred(
f"El PANEL no pudo resolver la ruta para '{job.source_name}'"
)
node_key = (route.get("node_key") or Path(job.source_name).name).upper()
JobRepository.update_node_and_db(self.job_id, node_key, route["db_name"])
app_logger.info(
f"Ruta resuelta: action={route.get('action')}, db={route.get('db_name')}"
)
return route
def _collect_zip_paths(self, source_path: str) -> list[str]:
"""Rutas locales del ZIP (incluye todas las partes multipart)."""
path = Path(source_path)
if SevenZipExtractor.is_multipart(str(path)):
base_name = path.stem.split(".zip")[0]
parts = sorted(path.parent.glob(f"{base_name}.zip.*"))
return [str(p) for p in parts]
return [str(path)]
def _move_zip_to_processed(self, job):
"""Mueve el ZIP (y partes multipart) a la carpeta processed."""
processed_folder = Path(self.config["paths"]["processed_folder"])
date_folder = processed_folder / datetime.now().strftime("%Y-%m-%d")
date_folder.mkdir(parents=True, exist_ok=True)
for zip_path in self._collect_zip_paths(job.source_path):
src = Path(zip_path)
dest = date_folder / src.name
shutil.move(str(src), str(dest))
app_logger.info(f"Movido: {src.name} -> {dest}")
def _forward_zip(self, job, route: dict, start_time: float):
"""Reenvía el ZIP al input_folder del servidor destino vía SFTP."""
target = route["target"]
self._target = target
step_id = JobStepRepository.create(self.job_id, StepType.FORWARD_ZIP)
try:
zip_paths = self._collect_zip_paths(job.source_path)
remote_folder = target["input_folder"]
uploaded = sftp_copy.upload_zip_parts(zip_paths, target, remote_folder)
JobStepRepository.complete(
step_id,
exit_code=0,
stdout=f"Destino: {target.get('name')} ({len(uploaded)} archivo(s))",
)
except Exception as e:
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
raise
self._move_zip_to_processed(job)
total_ms = int((time.time() - start_time) * 1000)
JobRepository.update_timing(self.job_id, total_ms=total_ms)
JobRepository.update_status(self.job_id, JobStatus.COMPLETED)
app_logger.info(
f"Job {self.job_id} reenviado a '{target.get('name')}' en {total_ms}ms"
)
EventRepository.create(
"INFO",
f"ZIP reenviado por SFTP a {target.get('name')}",
self.job_id,
)
self._report_to_panel(job, "forwarded", duration_ms=total_ms)
self.signals.job_completed.emit(self.job_id, True)
def _report_to_panel(
self,
job,
status: str,
duration_ms: Optional[int] = None,
error_message: Optional[str] = None,
):
"""Reporta el resultado del job al PANEL (best-effort, solo en modo PANEL)."""
if not self._target:
return
panel_cfg = self.config.get("panel", {})
panel_client.report_job_result(
api_url=(panel_cfg.get("api_url") or "").strip(),
api_token=(panel_cfg.get("api_token") or "").strip(),
filename=job.source_name if job else "",
status=status,
restore_target_id=self._target.get("id"),
db_name=job.db_name if job else None,
duration_ms=duration_ms,
error_message=error_message,
)
def _process_node_mapping(self, job): def _process_node_mapping(self, job):
"""Procesa el mapeo de nodo a base de datos.""" """Procesa el mapeo de nodo a base de datos."""
step_id = JobStepRepository.create(self.job_id, StepType.NODE_MAPPING) step_id = JobStepRepository.create(self.job_id, StepType.NODE_MAPPING)
@@ -170,10 +335,16 @@ class RestoreWorker(QRunnable):
stderr=stderr[:1000] if stderr else None stderr=stderr[:1000] if stderr else None
) )
# Buscar archivo .bak # Buscar archivo .bak. El backup puede venir con terminaciones extra
# (p.ej. ".KNOWNWORLD") tanto en el ZIP como en el propio .bak; se
# normaliza a "<nodo>.bak". El nodo es siempre el primer segmento del
# nombre del archivo de origen.
locate_step_id = JobStepRepository.create(self.job_id, StepType.LOCATE_BAK) locate_step_id = JobStepRepository.create(self.job_id, StepType.LOCATE_BAK)
self._bak_path = SevenZipExtractor.find_bak_file(self._extract_dir) node_base = Path(job.source_name).name.split(".")[0].strip() or None
self._bak_path = SevenZipExtractor.find_bak_file(
self._extract_dir, node_name=node_base
)
if not self._bak_path: if not self._bak_path:
raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído") raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído")
@@ -199,16 +370,30 @@ class RestoreWorker(QRunnable):
if not job.db_name: if not job.db_name:
raise ValueError("DB name no está configurado en el job") raise ValueError("DB name no está configurado en el job")
# Servidor y credenciales: del servidor activo del PANEL, o config local.
if self._target:
server = self._target["server"]
use_windows_auth = False
username = self._target["username"]
password = self._target["password"]
data_folder = self._target["data_folder"]
else:
sql_config = self.config["sql"]
server = sql_config["server"]
use_windows_auth = sql_config["use_windows_auth"]
username = sql_config.get("username")
password = sql_config.get("password")
data_folder = self.config["paths"]["data_sql_folder"]
# Conectar a SQL # Conectar a SQL
connect_step_id = JobStepRepository.create(self.job_id, StepType.SQL_CONNECT) connect_step_id = JobStepRepository.create(self.job_id, StepType.SQL_CONNECT)
try: try:
sql_config = self.config["sql"]
sql_manager = SQLServerManager( sql_manager = SQLServerManager(
server=sql_config["server"], server=server,
use_windows_auth=sql_config["use_windows_auth"], use_windows_auth=use_windows_auth,
username=sql_config.get("username"), username=username,
password=sql_config.get("password") password=password
) )
# Test connection # Test connection
@@ -222,13 +407,15 @@ class RestoreWorker(QRunnable):
JobStepRepository.complete(connect_step_id, exit_code=1, error=str(e)) JobStepRepository.complete(connect_step_id, exit_code=1, error=str(e))
raise raise
sql_backup_path = self._bak_path
# Obtener FILELISTONLY # Obtener FILELISTONLY
filelist_step_id = JobStepRepository.create(self.job_id, StepType.FILELIST) filelist_step_id = JobStepRepository.create(self.job_id, StepType.FILELIST)
filelist_start = time.time() filelist_start = time.time()
try: try:
logical_files, error = sql_manager.get_filelist_from_backup( logical_files, error = sql_manager.get_filelist_from_backup(
self._bak_path, sql_backup_path,
timeout_minutes=self.config["timeouts"]["restore_minutes"] timeout_minutes=self.config["timeouts"]["restore_minutes"]
) )
@@ -254,11 +441,9 @@ class RestoreWorker(QRunnable):
restore_start = time.time() restore_start = time.time()
try: try:
data_folder = self.config["paths"]["data_sql_folder"]
success, stdout, error = sql_manager.restore_database( success, stdout, error = sql_manager.restore_database(
db_name=job.db_name, db_name=job.db_name,
backup_path=self._bak_path, backup_path=sql_backup_path,
data_folder=data_folder, data_folder=data_folder,
logical_files=logical_files, logical_files=logical_files,
timeout_minutes=self.config["timeouts"]["restore_minutes"], timeout_minutes=self.config["timeouts"]["restore_minutes"],

View File

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

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

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

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

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

View File

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

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

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

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

@@ -0,0 +1,160 @@
"""
Transferencia del .bak al servidor SQL externo vía SFTP/SSH (paramiko).
Los servidores SQL destino son máquinas externas independientes (no comparten red
local con CloudRestoreAS), por lo que el .bak se sube por SFTP al servidor y el SQL
Server restaura desde su disco local (remote_inbox_path). Tras el job, el .bak del
servidor se elimina siempre (G10).
Nota de seguridad: se usa AutoAddPolicy para las host keys (TOFU). Para endurecer en
producción conviene fijar/known_hosts las claves de cada servidor.
"""
import ntpath
import posixpath
from pathlib import Path
from typing import Optional
import paramiko
from ..utils.logger import app_logger
# Timeout de conexión SSH en segundos.
SSH_TIMEOUT = 30
class SFTPCopyError(Exception):
"""Error al transferir o limpiar el .bak en el servidor remoto vía SFTP."""
def _sftp_path(remote_inbox_path: str, filename: str) -> str:
"""
Ruta estilo POSIX para SFTP a partir de la carpeta destino (que puede venir en
formato Windows, p. ej. C:\\RestoreInbox). OpenSSH en Windows acepta 'C:/...'.
"""
posix_dir = remote_inbox_path.replace("\\", "/").rstrip("/")
return f"{posix_dir}/{filename}"
def windows_restore_path(remote_inbox_path: str, filename: str) -> str:
"""Ruta Windows que usará RESTORE DATABASE en el servidor (C:\\RestoreInbox\\x.bak)."""
return ntpath.join(remote_inbox_path, filename)
def _connect(cfg: dict) -> paramiko.SSHClient:
"""Abre una conexión SSH con las credenciales del servidor (cfg del PANEL)."""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
hostname=cfg["ssh_host"],
port=int(cfg.get("ssh_port") or 22),
username=cfg["ssh_username"],
password=cfg["ssh_password"],
timeout=SSH_TIMEOUT,
allow_agent=False,
look_for_keys=False,
)
except Exception as e:
raise SFTPCopyError(
f"No se pudo conectar por SSH a {cfg.get('ssh_host')}:{cfg.get('ssh_port')}: {e}"
) from e
return client
def upload_to_remote(local_bak: str, cfg: dict) -> str:
"""
Sube `local_bak` al servidor remoto vía SFTP, a remote_inbox_path/filename.
Args:
local_bak: ruta local del .bak ya extraído.
cfg: dict con ssh_host, ssh_port, ssh_username, ssh_password, remote_inbox_path.
Returns:
Ruta SFTP (POSIX) del .bak en el servidor, para limpieza posterior.
Raises:
SFTPCopyError: si el origen no existe o falla la conexión/transferencia.
"""
remote_folder = cfg.get("remote_inbox_path") or ""
return upload_file_to_folder(local_bak, cfg, remote_folder)
def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str:
"""
Sube un archivo local a remote_folder/filename vía SFTP.
Args:
local_file: ruta local del archivo.
cfg: dict con ssh_host, ssh_port, ssh_username, ssh_password.
remote_folder: carpeta destino en el servidor remoto (Windows o POSIX).
Returns:
Ruta SFTP (POSIX) del archivo en el servidor.
Raises:
SFTPCopyError: si el origen no existe o falla la transferencia.
"""
src = Path(local_file)
if not src.is_file():
raise SFTPCopyError(f"El archivo local no existe: {local_file}")
if not remote_folder or not str(remote_folder).strip():
raise SFTPCopyError("La carpeta remota destino está vacía")
remote_sftp = _sftp_path(str(remote_folder).strip(), src.name)
client = _connect(cfg)
try:
sftp = client.open_sftp()
try:
app_logger.info(f"Subiendo archivo por SFTP a {cfg['ssh_host']}: {remote_sftp}")
sftp.put(str(src), remote_sftp)
finally:
sftp.close()
except SFTPCopyError:
raise
except Exception as e:
raise SFTPCopyError(f"Fallo al subir archivo por SFTP ({remote_sftp}): {e}") from e
finally:
client.close()
return remote_sftp
def upload_zip_parts(local_paths: list[str], cfg: dict, remote_folder: str) -> list[str]:
"""
Sube uno o más archivos ZIP (incl. multipart) al input_folder del destino.
Returns:
Lista de rutas SFTP subidas.
"""
uploaded: list[str] = []
for local_path in local_paths:
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
return uploaded
def cleanup_remote(cfg: dict, remote_sftp_path: Optional[str]) -> None:
"""
Elimina el .bak del servidor remoto vía SFTP. Best-effort: registra pero no lanza,
para no enmascarar el resultado real del job. Se invoca siempre en `finally`.
"""
if not remote_sftp_path:
return
try:
client = _connect(cfg)
except SFTPCopyError as e:
app_logger.error(f"No se pudo conectar para limpiar el .bak remoto: {e}")
return
try:
sftp = client.open_sftp()
try:
sftp.remove(remote_sftp_path)
app_logger.info(f"Inbox remoto limpiado: {remote_sftp_path}")
except FileNotFoundError:
pass
finally:
sftp.close()
except Exception as e:
app_logger.error(f"No se pudo limpiar el .bak remoto ({remote_sftp_path}): {e}")
finally:
client.close()

View File

@@ -1,15 +1,18 @@
"""Tab de configuración.""" """Tab de configuración."""
import sys
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
QFormLayout, QLineEdit, QPushButton, QSpinBox, QFormLayout, QLineEdit, QPushButton, QSpinBox,
QCheckBox, QFileDialog, QMessageBox, QScrollArea QCheckBox, QFileDialog, QMessageBox, QScrollArea, QComboBox
) )
from pathlib import Path from pathlib import Path
from ..utils.crypto import encrypt_password, decrypt_password from ..utils.crypto import encrypt_password, decrypt_password
from ..extract.seven_zip import SevenZipExtractor from ..extract.seven_zip import SevenZipExtractor
from ..sql.sql_manager import SQLServerManager from ..sql.sql_manager import SQLServerManager
from ..panel import panel_client
class ConfigTab(QWidget): class ConfigTab(QWidget):
@@ -20,6 +23,7 @@ class ConfigTab(QWidget):
super().__init__() super().__init__()
self.engine = engine self.engine = engine
self.config = {} self.config = {}
self._catalog_names: list[str] = []
self._setup_ui() self._setup_ui()
def _setup_ui(self): def _setup_ui(self):
@@ -102,6 +106,12 @@ class ConfigTab(QWidget):
self.sql_windows_auth_checkbox.setChecked(True) self.sql_windows_auth_checkbox.setChecked(True)
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed) self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox) sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox)
if sys.platform != "win32":
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."
)
self.sql_username_input = QLineEdit() self.sql_username_input = QLineEdit()
self.sql_username_input.setEnabled(False) self.sql_username_input.setEnabled(False)
@@ -119,6 +129,37 @@ class ConfigTab(QWidget):
sql_group.setLayout(sql_layout) sql_group.setLayout(sql_layout)
layout.addWidget(sql_group) layout.addWidget(sql_group)
# Grupo: PANEL de Control (servidor de restauración activo)
panel_group = QGroupBox("PANEL de Control")
panel_layout = QFormLayout()
self.panel_url_input = QLineEdit()
self.panel_url_input.setPlaceholderText("http://ip-del-panel:3000")
panel_layout.addRow("URL del PANEL:", self.panel_url_input)
self.panel_token_input = QLineEdit()
self.panel_token_input.setEchoMode(QLineEdit.EchoMode.Password)
panel_layout.addRow("API Token:", self.panel_token_input)
self.panel_instance_combo = QComboBox()
self.panel_instance_combo.setToolTip(
"Nombre de este servidor en el panel. El CRA restaurará aquí lo que le "
"corresponda y reenviará el resto por SFTP automáticamente."
)
self.refresh_instance_btn = QPushButton("Actualizar lista de servidores")
self.refresh_instance_btn.clicked.connect(self._refresh_instance_combo)
instance_row = QHBoxLayout()
instance_row.addWidget(self.panel_instance_combo, stretch=1)
instance_row.addWidget(self.refresh_instance_btn)
panel_layout.addRow("Instancia (servidor):", instance_row)
test_panel_btn = QPushButton("🔌 Probar Conexión al PANEL")
test_panel_btn.clicked.connect(self._test_panel_connection)
panel_layout.addRow("", test_panel_btn)
panel_group.setLayout(panel_layout)
layout.addWidget(panel_group)
# Grupo: Concurrencia # Grupo: Concurrencia
concurrency_group = QGroupBox("Concurrencia") concurrency_group = QGroupBox("Concurrencia")
concurrency_layout = QFormLayout() concurrency_layout = QFormLayout()
@@ -270,6 +311,41 @@ class ConfigTab(QWidget):
self.auto_scan_checkbox.setChecked(features.get("auto_scan_enabled", True)) self.auto_scan_checkbox.setChecked(features.get("auto_scan_enabled", True))
self.scan_interval_spinbox.setValue(features.get("scan_interval_seconds", 30)) self.scan_interval_spinbox.setValue(features.get("scan_interval_seconds", 30))
# PANEL de Control
panel = config.get("panel", {})
self.panel_url_input.setText(panel.get("api_url", ""))
self.panel_token_input.setText(panel.get("api_token", ""))
self._refresh_instance_combo()
def _refresh_instance_combo(self):
"""Recarga los nombres de servidores desde el catálogo del panel."""
current = self.panel_instance_combo.currentText().strip()
saved_key = (self.config.get("panel", {}).get("instance_key") or "").strip()
preserve = current or saved_key
api_url = self.panel_url_input.text().strip()
api_token = self.panel_token_input.text().strip()
names: list[str] = []
if api_url and api_token:
names = panel_client.list_restore_target_names(api_url, api_token)
self._catalog_names = names
items = [""]
for name in names:
if name not in items:
items.append(name)
if preserve and preserve not in items:
items.append(preserve)
self.panel_instance_combo.blockSignals(True)
self.panel_instance_combo.clear()
self.panel_instance_combo.addItems(items)
idx = self.panel_instance_combo.findText(preserve)
self.panel_instance_combo.setCurrentIndex(idx if idx >= 0 else 0)
self.panel_instance_combo.blockSignals(False)
def _save_config(self): def _save_config(self):
"""Guarda la configuración.""" """Guarda la configuración."""
# Construir config # Construir config
@@ -307,9 +383,31 @@ class ConfigTab(QWidget):
"dry_run_mode": self.dry_run_checkbox.isChecked(), "dry_run_mode": self.dry_run_checkbox.isChecked(),
"auto_scan_enabled": self.auto_scan_checkbox.isChecked(), "auto_scan_enabled": self.auto_scan_checkbox.isChecked(),
"scan_interval_seconds": self.scan_interval_spinbox.value() "scan_interval_seconds": self.scan_interval_spinbox.value()
},
"panel": {
"api_url": self.panel_url_input.text().strip(),
"api_token": self.panel_token_input.text().strip(),
"instance_key": self.panel_instance_combo.currentText().strip(),
} }
} }
panel_url = self.panel_url_input.text().strip()
panel_token = self.panel_token_input.text().strip()
instance_key = self.panel_instance_combo.currentText().strip()
if panel_url and panel_token and not instance_key:
QMessageBox.warning(
self,
"Advertencia",
"Con panel configurado debe seleccionar la instancia (servidor).",
)
if panel_url and instance_key and self._catalog_names and instance_key not in self._catalog_names:
QMessageBox.warning(
self,
"Advertencia",
f'La instancia "{instance_key}" no está en el catálogo del panel. '
"Verifique el nombre o cree el servidor en Servidores de Restauración.",
)
# Cifrar password si no usa Windows Auth # Cifrar password si no usa Windows Auth
if not self.sql_windows_auth_checkbox.isChecked(): if not self.sql_windows_auth_checkbox.isChecked():
password = self.sql_password_input.text() password = self.sql_password_input.text()
@@ -356,6 +454,25 @@ class ConfigTab(QWidget):
"No se pudo auto-detectar 7-Zip. Por favor, selecciona manualmente." "No se pudo auto-detectar 7-Zip. Por favor, selecciona manualmente."
) )
def _test_panel_connection(self):
"""Prueba la conexión al PANEL y muestra el servidor de restauración activo."""
api_url = self.panel_url_input.text().strip()
api_token = self.panel_token_input.text().strip()
if not api_url:
QMessageBox.warning(self, "Error", "Debe especificar la URL del PANEL.")
return
success, info = panel_client.test_connection(api_url, api_token)
if success:
self._refresh_instance_combo()
QMessageBox.information(
self, "Éxito", f"Conexión al PANEL exitosa.\n{info or ''}"
)
else:
QMessageBox.critical(self, "Error", f"No se pudo conectar al PANEL:\n{info}")
def _on_auth_changed(self, checked: bool): def _on_auth_changed(self, checked: bool):
"""Maneja el cambio en el tipo de autenticación.""" """Maneja el cambio en el tipo de autenticación."""
self.sql_username_input.setEnabled(not checked) self.sql_username_input.setEnabled(not checked)

View File

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

View File

@@ -2,16 +2,17 @@
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QTabWidget, QMainWindow, QWidget, QVBoxLayout, QTabWidget,
QSystemTrayIcon, QMenu QSystemTrayIcon, QMenu, QMessageBox, QApplication,
) )
from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QIcon, QCloseEvent, QAction from PySide6.QtGui import QCloseEvent, QAction
from .dashboard_tab import DashboardTab from .dashboard_tab import DashboardTab
from .jobs_tab import JobsTab from .jobs_tab import JobsTab
from .nodes_tab import NodesTab from .nodes_tab import NodesTab
from .config_tab import ConfigTab from .config_tab import ConfigTab
from .logs_tab import LogsTab from .logs_tab import LogsTab
from .tray_assets import load_tray_icon
from ..engine.engine import RestoreEngine from ..engine.engine import RestoreEngine
from ..utils.logger import app_logger from ..utils.logger import app_logger
@@ -19,58 +20,70 @@ from ..utils.logger import app_logger
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
"""Ventana principal de la aplicación.""" """Ventana principal de la aplicación."""
# Señal para minimizar a tray
minimize_to_tray = Signal() minimize_to_tray = Signal()
def __init__(self): def __init__(
self,
minimized: bool = False,
start_engine: bool = False,
panel_configured: bool = True,
):
"""Inicializa la ventana principal.""" """Inicializa la ventana principal."""
super().__init__() super().__init__()
# Motor de restauración self._start_on_load = start_engine
self._minimized_on_load = minimized
self._panel_configured = panel_configured
self._force_quit = False
self._tray_hint_shown = False
self.engine = RestoreEngine() self.engine = RestoreEngine()
# Configurar ventana
self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server") self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server")
self.setMinimumSize(1200, 800) self.setMinimumSize(1200, 800)
# System tray self.tray_icon: QSystemTrayIcon | None = None
self.tray_icon: QSystemTrayIcon = None
self._setup_tray() self._setup_tray()
# UI
self._setup_ui() self._setup_ui()
# Conectar señales del motor
self._connect_engine_signals() self._connect_engine_signals()
# Timer para actualizar stats
self.stats_timer = QTimer() self.stats_timer = QTimer()
self.stats_timer.timeout.connect(self._update_stats) self.stats_timer.timeout.connect(self._update_stats)
self.stats_timer.start(5000) # Cada 5 segundos self.stats_timer.start(5000)
if not self._panel_configured:
self.dashboard_tab.set_panel_warning(
"Configure la conexión al servicio de bases en config/.env "
"(CLOUDRESTORE_PANEL_API_URL, TOKEN e INSTANCE_KEY)."
)
if self._start_on_load:
QTimer.singleShot(500, self._start_engine)
if self._minimized_on_load:
QTimer.singleShot(100, self.hide)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Ventana principal inicializada") app_logger.info("Ventana principal inicializada")
def _setup_ui(self): def _setup_ui(self):
"""Configura la interfaz de usuario.""" """Configura la interfaz de usuario."""
# Widget central
central_widget = QWidget() central_widget = QWidget()
self.setCentralWidget(central_widget) self.setCentralWidget(central_widget)
# Layout principal
layout = QVBoxLayout(central_widget) layout = QVBoxLayout(central_widget)
layout.setContentsMargins(10, 10, 10, 10) layout.setContentsMargins(10, 10, 10, 10)
# Tabs
self.tabs = QTabWidget() self.tabs = QTabWidget()
# Crear tabs
self.dashboard_tab = DashboardTab(self.engine) self.dashboard_tab = DashboardTab(self.engine)
self.jobs_tab = JobsTab(self.engine) self.jobs_tab = JobsTab(self.engine)
self.nodes_tab = NodesTab(self.engine) self.nodes_tab = NodesTab(self.engine)
self.config_tab = ConfigTab(self.engine) self.config_tab = ConfigTab(self.engine)
self.logs_tab = LogsTab(self.engine) self.logs_tab = LogsTab(self.engine)
# Agregar tabs
self.tabs.addTab(self.dashboard_tab, "📊 Dashboard") self.tabs.addTab(self.dashboard_tab, "📊 Dashboard")
self.tabs.addTab(self.jobs_tab, "📋 Jobs") self.tabs.addTab(self.jobs_tab, "📋 Jobs")
self.tabs.addTab(self.nodes_tab, "🔗 Nodos") self.tabs.addTab(self.nodes_tab, "🔗 Nodos")
@@ -78,15 +91,18 @@ class MainWindow(QMainWindow):
self.tabs.addTab(self.logs_tab, "📝 Logs") self.tabs.addTab(self.logs_tab, "📝 Logs")
layout.addWidget(self.tabs) layout.addWidget(self.tabs)
# Menu bar
self._setup_menu() self._setup_menu()
def _setup_menu(self): def _setup_menu(self):
"""Configura el menú.""" """Configura el menú."""
menubar = self.menuBar() menubar = self.menuBar()
# Menú Motor file_menu = menubar.addMenu("Archivo")
quit_action = QAction("Salir", self)
quit_action.triggered.connect(self._quit_application)
file_menu.addAction(quit_action)
motor_menu = menubar.addMenu("Motor") motor_menu = menubar.addMenu("Motor")
self.start_action = QAction("▶️ Iniciar", self) self.start_action = QAction("▶️ Iniciar", self)
@@ -114,7 +130,6 @@ class MainWindow(QMainWindow):
self.scan_action.triggered.connect(self._scan_now) self.scan_action.triggered.connect(self._scan_now)
motor_menu.addAction(self.scan_action) motor_menu.addAction(self.scan_action)
# Menú Ayuda
help_menu = menubar.addMenu("Ayuda") help_menu = menubar.addMenu("Ayuda")
about_action = QAction("Acerca de", self) about_action = QAction("Acerca de", self)
@@ -123,11 +138,14 @@ class MainWindow(QMainWindow):
def _setup_tray(self): def _setup_tray(self):
"""Configura el icono de bandeja del sistema.""" """Configura el icono de bandeja del sistema."""
# Crear icono (usar un icono por defecto o crear uno simple) if not QSystemTrayIcon.isSystemTrayAvailable():
app_logger.warning("Bandeja del sistema no disponible en este entorno")
return
self.tray_icon = QSystemTrayIcon(self) self.tray_icon = QSystemTrayIcon(self)
self.tray_icon.setIcon(load_tray_icon())
self.tray_icon.setToolTip("CloudRestoreAS") self.tray_icon.setToolTip("CloudRestoreAS")
# Menú del tray
tray_menu = QMenu() tray_menu = QMenu()
show_action = QAction("Mostrar", self) show_action = QAction("Mostrar", self)
@@ -150,16 +168,12 @@ class MainWindow(QMainWindow):
tray_menu.addSeparator() tray_menu.addSeparator()
quit_action = QAction("Salir", self) quit_tray_action = QAction("Salir", self)
quit_action.triggered.connect(self._quit_application) quit_tray_action.triggered.connect(self._quit_application)
tray_menu.addAction(quit_action) tray_menu.addAction(quit_tray_action)
self.tray_icon.setContextMenu(tray_menu) self.tray_icon.setContextMenu(tray_menu)
# Doble clic en tray para mostrar/ocultar
self.tray_icon.activated.connect(self._on_tray_activated) self.tray_icon.activated.connect(self._on_tray_activated)
# Mostrar tray
self.tray_icon.show() self.tray_icon.show()
def _connect_engine_signals(self): def _connect_engine_signals(self):
@@ -169,11 +183,23 @@ class MainWindow(QMainWindow):
self.engine.signals.config_loaded.connect(self._on_config_loaded) self.engine.signals.config_loaded.connect(self._on_config_loaded)
def closeEvent(self, event: QCloseEvent): def closeEvent(self, event: QCloseEvent):
"""Maneja el evento de cierre de ventana (minimizar a tray).""" """Minimiza a bandeja al cerrar la ventana (no sale de la aplicación)."""
if self.tray_icon.isVisible(): if self._force_quit:
event.accept()
return
if self.tray_icon and self.tray_icon.isVisible():
self.hide() self.hide()
event.ignore() event.ignore()
app_logger.info("Ventana minimizada a bandeja del sistema") 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: else:
event.accept() event.accept()
@@ -191,12 +217,29 @@ class MainWindow(QMainWindow):
self.activateWindow() self.activateWindow()
self.raise_() self.raise_()
def _motor_status_text(self) -> str:
if not self.engine.is_running():
return "Detenido"
if self.engine.is_paused():
return "Pausado"
return "Activo"
def _update_tray_status(self):
"""Actualiza tooltip del icono de bandeja según estado del motor."""
if not self.tray_icon:
return
status = self._motor_status_text()
self.tray_icon.setToolTip(f"CloudRestoreAS — Motor: {status}")
def _start_engine(self): def _start_engine(self):
"""Inicia el motor.""" """Inicia el motor."""
self.engine.start() self.engine.start()
self.start_action.setEnabled(False) self.start_action.setEnabled(False)
self.pause_action.setEnabled(True) self.pause_action.setEnabled(True)
self.stop_action.setEnabled(True) self.stop_action.setEnabled(True)
self.resume_action.setEnabled(False)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor iniciado desde UI") app_logger.info("Motor iniciado desde UI")
def _pause_engine(self): def _pause_engine(self):
@@ -204,6 +247,8 @@ class MainWindow(QMainWindow):
self.engine.pause() self.engine.pause()
self.pause_action.setEnabled(False) self.pause_action.setEnabled(False)
self.resume_action.setEnabled(True) self.resume_action.setEnabled(True)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor pausado desde UI") app_logger.info("Motor pausado desde UI")
def _resume_engine(self): def _resume_engine(self):
@@ -211,6 +256,8 @@ class MainWindow(QMainWindow):
self.engine.resume() self.engine.resume()
self.resume_action.setEnabled(False) self.resume_action.setEnabled(False)
self.pause_action.setEnabled(True) self.pause_action.setEnabled(True)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor reanudado desde UI") app_logger.info("Motor reanudado desde UI")
def _stop_engine(self): def _stop_engine(self):
@@ -220,6 +267,8 @@ class MainWindow(QMainWindow):
self.pause_action.setEnabled(False) self.pause_action.setEnabled(False)
self.resume_action.setEnabled(False) self.resume_action.setEnabled(False)
self.start_action.setEnabled(True) self.start_action.setEnabled(True)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor detenido desde UI") app_logger.info("Motor detenido desde UI")
def _scan_now(self): def _scan_now(self):
@@ -229,6 +278,8 @@ class MainWindow(QMainWindow):
def _update_stats(self): def _update_stats(self):
"""Actualiza las estadísticas.""" """Actualiza las estadísticas."""
self._update_tray_status()
self.dashboard_tab.update_motor_status()
if self.engine.is_running(): if self.engine.is_running():
stats = self.engine.get_stats() stats = self.engine.get_stats()
self.dashboard_tab.update_stats(stats) self.dashboard_tab.update_stats(stats)
@@ -248,20 +299,21 @@ class MainWindow(QMainWindow):
def _show_about(self): def _show_about(self):
"""Muestra el diálogo Acerca de.""" """Muestra el diálogo Acerca de."""
from PySide6.QtWidgets import QMessageBox
QMessageBox.about( QMessageBox.about(
self, self,
"Acerca de CloudRestoreAS", "Acerca de CloudRestoreAS",
"CloudRestoreAS v1.0.0\n\n" "CloudRestoreAS v1.0.0\n\n"
"Aplicación de restauración automática de bases de datos SQL Server.\n\n" "Aplicación de restauración automática de bases de datos SQL Server.\n\n"
"© 2026 Aduanasoft" "Al cerrar la ventana, la aplicación permanece en la bandeja del sistema.\n"
"Use Archivo → Salir o la bandeja → Salir para cerrar por completo.\n\n"
"© 2026 Aduanasoft",
) )
def _quit_application(self): def _quit_application(self):
"""Sale de la aplicación completamente.""" """Sale de la aplicación completamente."""
app_logger.info("Saliendo de la aplicación") app_logger.info("Saliendo de la aplicación")
self._force_quit = True
self.engine.stop() self.engine.stop()
if self.tray_icon:
self.tray_icon.hide() self.tray_icon.hide()
self.close()
from PySide6.QtWidgets import QApplication
QApplication.quit() QApplication.quit()

View File

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

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

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

View File

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

499
build-linux.log Normal file
View File

@@ -0,0 +1,499 @@
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)...
74 INFO: PyInstaller: 6.20.0, contrib hooks: 2026.6
74 INFO: Python: 3.10.12
75 INFO: Platform: Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.35
75 INFO: Python environment: /app/venv-linux
77 INFO: Removing temporary files and cleaning cache in /root/.cache/pyinstaller
89 WARNING: Failed to collect submodules for 'PySide6.scripts.deploy_lib' because importing 'PySide6.scripts.deploy_lib' raised: ModuleNotFoundError: No module named 'project_lib'
713 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']
793 INFO: Appending 'binaries' from .spec
797 INFO: Appending 'datas' from .spec
854 INFO: checking Analysis
854 INFO: Building Analysis because Analysis-00.toc is non existent
854 INFO: Looking for Python shared library...
861 INFO: Using Python shared library: /lib/x86_64-linux-gnu/libpython3.10.so.1.0
861 INFO: Running Analysis Analysis-00.toc
861 INFO: Target bytecode optimization level: 0
861 INFO: Initializing module dependency graph...
861 INFO: Initializing module graph hook caches...
867 INFO: Analyzing modules for base_library.zip ...
1077 INFO: Processing standard module hook 'hook-heapq.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
1111 INFO: Processing standard module hook 'hook-encodings.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
1770 INFO: Processing standard module hook 'hook-pickle.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2325 INFO: Caching module dependency graph...
2349 INFO: Analyzing /app/runner.py
2355 INFO: Processing standard module hook 'hook-sqlite3.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2595 INFO: Processing standard module hook 'hook-PySide6.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2687 INFO: Processing standard module hook 'hook-shiboken6.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2753 INFO: Processing standard module hook 'hook-PySide6.QtNetwork.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
3261 INFO: Processing standard module hook 'hook-PySide6.QtCore.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
3732 INFO: Processing standard module hook 'hook-PySide6.QtWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
4244 INFO: Processing standard module hook 'hook-PySide6.QtGui.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
5411 INFO: Processing standard module hook 'hook-platform.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
5434 INFO: Processing standard module hook 'hook-cryptography.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
6128 INFO: hook-cryptography: cryptography does not seem to be using dynamically linked OpenSSL.
6278 INFO: Processing standard module hook 'hook-pyodbc.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
6390 INFO: Processing standard module hook 'hook-urllib3.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
6500 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'
6503 INFO: SetuptoolsInfo: initializing cached setuptools info...
7023 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'
7167 INFO: Processing standard module hook 'hook-multiprocessing.util.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
7199 INFO: Processing standard module hook 'hook-xml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
7334 INFO: Processing standard module hook 'hook-_ctypes.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
7979 INFO: Processing standard module hook 'hook-certifi.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
7998 INFO: Processing standard module hook 'hook-charset_normalizer.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
8193 INFO: Processing standard module hook 'hook-bcrypt.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
8489 INFO: Processing standard module hook 'hook-difflib.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8655 INFO: Processing standard module hook 'hook-nacl.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
8816 INFO: Analyzing hidden import 'PySide6.Qt3DAnimation'
8830 INFO: Processing standard module hook 'hook-PySide6.Qt3DAnimation.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8938 INFO: Processing standard module hook 'hook-PySide6.Qt3DCore.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
9135 INFO: Processing standard module hook 'hook-PySide6.Qt3DRender.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10294 INFO: Processing standard module hook 'hook-PySide6.QtOpenGL.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10400 INFO: Analyzing hidden import 'PySide6.Qt3DExtras'
10458 INFO: Processing standard module hook 'hook-PySide6.Qt3DExtras.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10552 INFO: Analyzing hidden import 'PySide6.Qt3DInput'
10564 INFO: Processing standard module hook 'hook-PySide6.Qt3DInput.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10645 INFO: Analyzing hidden import 'PySide6.Qt3DLogic'
10646 INFO: Processing standard module hook 'hook-PySide6.Qt3DLogic.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10725 INFO: Analyzing hidden import 'PySide6.QtAsyncio'
10740 INFO: Analyzing hidden import 'PySide6.QtBluetooth'
10770 INFO: Processing standard module hook 'hook-PySide6.QtBluetooth.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10850 INFO: Analyzing hidden import 'PySide6.QtCanvasPainter'
10915 INFO: Processing standard module hook 'hook-PySide6.QtQuick.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11049 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',)
12557 INFO: Analyzing hidden import 'PySide6.QtCharts'
12610 INFO: Processing standard module hook 'hook-PySide6.QtCharts.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12689 INFO: Analyzing hidden import 'PySide6.QtConcurrent'
12692 INFO: Processing standard module hook 'hook-PySide6.QtConcurrent.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12769 INFO: Analyzing hidden import 'PySide6.QtDBus'
12787 INFO: Processing standard module hook 'hook-PySide6.QtDBus.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12863 INFO: Analyzing hidden import 'PySide6.QtDataVisualization'
12910 INFO: Processing standard module hook 'hook-PySide6.QtDataVisualization.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12990 INFO: Analyzing hidden import 'PySide6.QtDesigner'
13019 INFO: Processing standard module hook 'hook-PySide6.QtDesigner.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13139 INFO: Analyzing hidden import 'PySide6.QtGraphs'
13211 INFO: Processing standard module hook 'hook-PySide6.QtGraphs.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13297 INFO: Analyzing hidden import 'PySide6.QtGraphsWidgets'
13307 INFO: Processing standard module hook 'hook-PySide6.QtGraphsWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13384 INFO: Processing standard module hook 'hook-PySide6.QtQuickWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13465 INFO: Analyzing hidden import 'PySide6.QtHelp'
13473 INFO: Processing standard module hook 'hook-PySide6.QtHelp.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13554 INFO: Analyzing hidden import 'PySide6.QtHttpServer'
13561 INFO: Processing standard module hook 'hook-PySide6.QtHttpServer.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13642 INFO: Analyzing hidden import 'PySide6.QtLocation'
13689 INFO: Processing standard module hook 'hook-PySide6.QtLocation.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13809 INFO: Processing standard module hook 'hook-PySide6.QtPositioning.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13921 INFO: Analyzing hidden import 'PySide6.QtMultimedia'
13959 INFO: Processing standard module hook 'hook-PySide6.QtMultimedia.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14068 INFO: Analyzing hidden import 'PySide6.QtMultimediaWidgets'
14070 INFO: Processing standard module hook 'hook-PySide6.QtMultimediaWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14149 INFO: Analyzing hidden import 'PySide6.QtNetworkAuth'
14162 INFO: Processing standard module hook 'hook-PySide6.QtNetworkAuth.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14243 INFO: Analyzing hidden import 'PySide6.QtNfc'
14253 INFO: Processing standard module hook 'hook-PySide6.QtNfc.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14336 INFO: Analyzing hidden import 'PySide6.QtOpenGLWidgets'
14338 INFO: Processing standard module hook 'hook-PySide6.QtOpenGLWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14418 INFO: Analyzing hidden import 'PySide6.QtPdf'
14426 INFO: Processing standard module hook 'hook-PySide6.QtPdf.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14510 INFO: Analyzing hidden import 'PySide6.QtPdfWidgets'
14513 INFO: Processing standard module hook 'hook-PySide6.QtPdfWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14596 INFO: Analyzing hidden import 'PySide6.QtPrintSupport'
14605 INFO: Processing standard module hook 'hook-PySide6.QtPrintSupport.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14698 INFO: Analyzing hidden import 'PySide6.QtQuick3D'
14704 INFO: Processing standard module hook 'hook-PySide6.QtQuick3D.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14786 INFO: Analyzing hidden import 'PySide6.QtQuickControls2'
14787 INFO: Processing standard module hook 'hook-PySide6.QtQuickControls2.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14866 INFO: Analyzing hidden import 'PySide6.QtQuickTest'
14867 INFO: Analyzing hidden import 'PySide6.QtRemoteObjects'
14877 INFO: Processing standard module hook 'hook-PySide6.QtRemoteObjects.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14959 INFO: Analyzing hidden import 'PySide6.QtScxml'
14968 INFO: Processing standard module hook 'hook-PySide6.QtScxml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15052 INFO: Analyzing hidden import 'PySide6.QtSensors'
15081 INFO: Processing standard module hook 'hook-PySide6.QtSensors.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15169 INFO: Analyzing hidden import 'PySide6.QtSerialBus'
15186 INFO: Processing standard module hook 'hook-PySide6.QtSerialBus.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15301 INFO: Analyzing hidden import 'PySide6.QtSerialPort'
15306 INFO: Processing standard module hook 'hook-PySide6.QtSerialPort.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15381 INFO: Analyzing hidden import 'PySide6.QtSpatialAudio'
15387 INFO: Processing standard module hook 'hook-PySide6.QtSpatialAudio.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15469 INFO: Analyzing hidden import 'PySide6.QtSql'
15487 INFO: Processing standard module hook 'hook-PySide6.QtSql.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15670 INFO: Analyzing hidden import 'PySide6.QtStateMachine'
15678 INFO: Processing standard module hook 'hook-PySide6.QtStateMachine.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15758 INFO: Analyzing hidden import 'PySide6.QtSvg'
15762 INFO: Processing standard module hook 'hook-PySide6.QtSvg.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15841 INFO: Analyzing hidden import 'PySide6.QtSvgWidgets'
15843 INFO: Processing standard module hook 'hook-PySide6.QtSvgWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15922 INFO: Analyzing hidden import 'PySide6.QtTest'
15932 INFO: Processing standard module hook 'hook-PySide6.QtTest.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
16010 INFO: Analyzing hidden import 'PySide6.QtTextToSpeech'
16015 INFO: Processing standard module hook 'hook-PySide6.QtTextToSpeech.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
16112 INFO: Analyzing hidden import 'PySide6.QtUiTools'
16114 INFO: Processing standard module hook 'hook-PySide6.QtUiTools.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
16198 INFO: Analyzing hidden import 'PySide6.QtWebChannel'
16200 INFO: Processing standard module hook 'hook-PySide6.QtWebChannel.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
16295 INFO: Analyzing hidden import 'PySide6.QtWebEngineCore'
16332 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineCore.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
16522 INFO: Analyzing hidden import 'PySide6.QtWebEngineQuick'
16526 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineQuick.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
16617 INFO: Analyzing hidden import 'PySide6.QtWebEngineWidgets'
16621 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
16707 INFO: Analyzing hidden import 'PySide6.QtWebSockets'
16713 INFO: Processing standard module hook 'hook-PySide6.QtWebSockets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
16790 INFO: Analyzing hidden import 'PySide6.QtWebView'
16793 INFO: Analyzing hidden import 'PySide6.QtXml'
16804 INFO: Processing standard module hook 'hook-PySide6.QtXml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
16894 INFO: Analyzing hidden import 'PySide6._config'
16896 INFO: Analyzing hidden import 'PySide6._git_pyside_version'
16897 INFO: Analyzing hidden import 'PySide6.scripts'
16897 INFO: Analyzing hidden import 'PySide6.scripts.android_deploy'
16901 INFO: Analyzing hidden import 'PySide6.scripts.deploy'
16904 INFO: Analyzing hidden import 'PySide6.scripts.metaobjectdump'
16912 INFO: Analyzing hidden import 'PySide6.scripts.project'
16936 INFO: Analyzing hidden import 'PySide6.scripts.project_lib'
16972 INFO: Processing standard module hook 'hook-xml.etree.cElementTree.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
16978 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'
17002 INFO: Analyzing hidden import 'PySide6.scripts.pyside_tool'
17016 INFO: Processing standard module hook 'hook-sysconfig.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
17020 INFO: Analyzing hidden import 'PySide6.scripts.qml'
17024 INFO: Analyzing hidden import 'PySide6.scripts.qtpy2cpp'
17026 INFO: Analyzing hidden import 'PySide6.support'
17027 INFO: Analyzing hidden import 'PySide6.support.deprecated'
17028 INFO: Analyzing hidden import 'PySide6.support.generate_pyi'
17030 INFO: Processing module hooks (post-graph stage)...
17104 INFO: Performing binary vs. data reclassification (3660 entries)
19240 INFO: Looking for ctypes DLLs
19242 INFO: Analyzing run-time hooks ...
19245 INFO: Including run-time hook 'pyi_rth_inspect.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
19246 INFO: Including run-time hook 'pyi_rth_pkgutil.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
19248 INFO: Including run-time hook 'pyi_rth_multiprocessing.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
19249 INFO: Including run-time hook 'pyi_rth_cryptography_openssl.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/rthooks'
19250 INFO: Including run-time hook 'pyi_rth_pyside6.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
19251 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'
19252 INFO: Processing standard module hook 'hook-_pyi_rth_utils.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
19329 INFO: Creating base_library.zip...
19340 INFO: Looking for dynamic libraries
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23477 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 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'.
23478 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
23478 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
23478 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
23478 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
23478 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
23478 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
23478 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
23478 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
23478 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
23478 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
23478 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'.
23478 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'.
23478 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23479 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23480 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'.
23481 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'.
23481 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'.
23481 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'.
23481 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'.
23481 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'.
23481 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'.
23481 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'.
23481 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtTextToSpeech.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtSpatialAudio.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libpcsclite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtNfc.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtMultimediaWidgets.abi3.so'.
23481 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtMultimedia.abi3.so'.
23545 INFO: Warnings written to /tmp/cloudrestore-build/CloudRestoreAS/warn-CloudRestoreAS.txt
23570 INFO: Graph cross-reference written to /tmp/cloudrestore-build/CloudRestoreAS/xref-CloudRestoreAS.html
23670 INFO: checking PYZ
23670 INFO: Building PYZ because PYZ-00.toc is non existent
23670 INFO: Building PYZ (ZlibArchive) /tmp/cloudrestore-build/CloudRestoreAS/PYZ-00.pyz
23962 INFO: Building PYZ (ZlibArchive) /tmp/cloudrestore-build/CloudRestoreAS/PYZ-00.pyz completed successfully.
24040 INFO: checking PKG
24040 INFO: Building PKG because PKG-00.toc is non existent
24040 INFO: Building PKG (CArchive) CloudRestoreAS.pkg
93529 INFO: Building PKG (CArchive) CloudRestoreAS.pkg completed successfully.
93583 INFO: Bootloader /app/venv-linux/lib/python3.10/site-packages/PyInstaller/bootloader/Linux-64bit-intel/run
93583 INFO: checking EXE
93583 INFO: Building EXE because EXE-00.toc is non existent
93583 INFO: Building EXE from EXE-00.toc
93583 INFO: Copying bootloader EXE to /app/dist/CloudRestoreAS
93585 INFO: Appending PKG archive to custom ELF section in EXE
99643 INFO: Building EXE from EXE-00.toc completed successfully.
99693 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===

326
build-windows.log Normal file
View File

@@ -0,0 +1,326 @@
===============================================
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
739 INFO: PyInstaller: 6.20.0, contrib hooks: 2026.6
739 INFO: Python: 3.13.3
760 INFO: Platform: Windows-11-10.0.26200-SP0
760 INFO: Python environment: \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows
767 INFO: Removing temporary files and cleaning cache in C:\Users\Hugo Reyes\AppData\Local\pyinstaller
1995 WARNING: Failed to collect submodules for 'PySide6.scripts.deploy_lib' because importing 'PySide6.scripts.deploy_lib' raised: ModuleNotFoundError: No module named 'project_lib'
60908 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']
62173 INFO: Appending 'binaries' from .spec
63219 INFO: Appending 'datas' from .spec
73737 INFO: checking Analysis
73738 INFO: Building Analysis because Analysis-00.toc is non existent
73738 INFO: Looking for Python shared library...
73738 INFO: Using Python shared library: C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\python313.dll
73738 INFO: Running Analysis Analysis-00.toc
73738 INFO: Target bytecode optimization level: 0
73738 INFO: Initializing module dependency graph...
73739 INFO: Initializing module graph hook caches...
73939 INFO: Analyzing modules for base_library.zip ...
79161 INFO: Processing standard module hook 'hook-encodings.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
83574 INFO: Processing standard module hook 'hook-pickle.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
86088 INFO: Processing standard module hook 'hook-heapq.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
89220 INFO: Caching module dependency graph...
89327 INFO: Analyzing \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\runner.py
89562 INFO: Processing standard module hook 'hook-sqlite3.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
91855 INFO: Processing standard module hook 'hook-PySide6.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
92619 INFO: Processing standard module hook 'hook-shiboken6.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
92791 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'
97922 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'
102127 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'
109064 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'
140346 INFO: Processing standard module hook 'hook-platform.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
140386 INFO: Processing standard module hook 'hook-_ctypes.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
140584 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'
144730 INFO: hook-cryptography: cryptography does not seem to be using dynamically linked OpenSSL.
145207 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'
145794 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'
146377 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'
146440 INFO: SetuptoolsInfo: initializing cached setuptools info...
150867 INFO: Setuptools: 'backports' appears to be a full setuptools-vendored copy - creating alias to 'setuptools._vendor.backports'!
150904 INFO: Processing standard module hook 'hook-setuptools.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
151016 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'
151182 INFO: Processing standard module hook 'hook-sysconfig.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
151201 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'
151205 INFO: Setuptools: 'jaraco' appears to be a full setuptools-vendored copy - creating alias to 'setuptools._vendor.jaraco'!
151239 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'
151242 INFO: Setuptools: 'more_itertools' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.more_itertools'!
151523 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'
151727 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'
152152 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'
152159 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'
152946 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'
152950 INFO: Setuptools: 'importlib_metadata' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.importlib_metadata'!
152983 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'
153157 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'
153161 INFO: Setuptools: 'zipp' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.zipp'!
153794 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'
153800 INFO: Setuptools: 'tomli' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.tomli'!
155363 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'
155367 INFO: Setuptools: 'wheel' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.wheel'!
156755 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'
157092 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'
159788 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'
162515 INFO: Processing standard module hook 'hook-difflib.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
162955 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'
163298 INFO: Processing standard module hook 'hook-xml.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
165811 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'
168908 INFO: Analyzing hidden import 'PySide6.Qt3DAnimation'
168980 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'
171050 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'
173412 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'
187367 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'
193279 INFO: Analyzing hidden import 'PySide6.Qt3DExtras'
193358 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'
195332 INFO: Analyzing hidden import 'PySide6.Qt3DInput'
195365 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'
196759 INFO: Analyzing hidden import 'PySide6.Qt3DLogic'
196773 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'
198361 INFO: Analyzing hidden import 'PySide6.QtAsyncio'
198435 INFO: Analyzing hidden import 'PySide6.QtAxContainer'
198462 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'
199952 INFO: Analyzing hidden import 'PySide6.QtBluetooth'
200101 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'
201249 INFO: Analyzing hidden import 'PySide6.QtCanvasPainter'
201572 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'
204027 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',)
275746 INFO: Analyzing hidden import 'PySide6.QtCharts'
276105 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'
279221 INFO: Analyzing hidden import 'PySide6.QtConcurrent'
279250 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'
280311 INFO: Analyzing hidden import 'PySide6.QtDBus'
280357 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'
281539 INFO: Analyzing hidden import 'PySide6.QtDataVisualization'
281715 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'
283429 INFO: Analyzing hidden import 'PySide6.QtDesigner'
283475 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'
287863 INFO: Analyzing hidden import 'PySide6.QtGraphs'
288258 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'
290498 INFO: Analyzing hidden import 'PySide6.QtGraphsWidgets'
290529 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'
291489 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'
293266 INFO: Analyzing hidden import 'PySide6.QtHelp'
293297 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'
294778 INFO: Analyzing hidden import 'PySide6.QtHttpServer'
294836 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'
295978 INFO: Analyzing hidden import 'PySide6.QtLocation'
296080 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'
299546 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'
302720 INFO: Analyzing hidden import 'PySide6.QtMultimedia'
302889 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'
307961 INFO: Analyzing hidden import 'PySide6.QtMultimediaWidgets'
308000 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'
310129 INFO: Analyzing hidden import 'PySide6.QtNetworkAuth'
310201 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'
311724 INFO: Analyzing hidden import 'PySide6.QtNfc'
311759 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'
312965 INFO: Analyzing hidden import 'PySide6.QtOpenGLWidgets'
312999 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'
314755 INFO: Analyzing hidden import 'PySide6.QtPdf'
314785 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'
316217 INFO: Analyzing hidden import 'PySide6.QtPdfWidgets'
316256 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'
318721 INFO: Analyzing hidden import 'PySide6.QtPrintSupport'
318783 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'
321097 INFO: Analyzing hidden import 'PySide6.QtQuick3D'
321121 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'
323189 INFO: Analyzing hidden import 'PySide6.QtQuickControls2'
323210 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'
324672 INFO: Analyzing hidden import 'PySide6.QtQuickTest'
324686 INFO: Analyzing hidden import 'PySide6.QtRemoteObjects'
324716 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'
325812 INFO: Analyzing hidden import 'PySide6.QtScxml'
325844 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'
327910 INFO: Analyzing hidden import 'PySide6.QtSensors'
327951 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'
329877 INFO: Analyzing hidden import 'PySide6.QtSerialBus'
329978 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'
333019 INFO: Analyzing hidden import 'PySide6.QtSerialPort'
333041 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'
334172 INFO: Analyzing hidden import 'PySide6.QtSpatialAudio'
334196 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'
335737 INFO: Analyzing hidden import 'PySide6.QtSql'
335786 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'
340576 INFO: Analyzing hidden import 'PySide6.QtStateMachine'
340648 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'
342135 INFO: Analyzing hidden import 'PySide6.QtSvg'
342220 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'
344343 INFO: Analyzing hidden import 'PySide6.QtSvgWidgets'
344415 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'
346445 INFO: Analyzing hidden import 'PySide6.QtTest'
346515 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'
348584 INFO: Analyzing hidden import 'PySide6.QtTextToSpeech'
348616 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'
351294 INFO: Analyzing hidden import 'PySide6.QtUiTools'
351319 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'
353034 INFO: Analyzing hidden import 'PySide6.QtWebChannel'
353050 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'
354101 INFO: Analyzing hidden import 'PySide6.QtWebEngineCore'
354172 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'
358005 INFO: Analyzing hidden import 'PySide6.QtWebEngineQuick'
358049 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'
359992 INFO: Analyzing hidden import 'PySide6.QtWebEngineWidgets'
360012 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'
361731 INFO: Analyzing hidden import 'PySide6.QtWebSockets'
361758 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'
362821 INFO: Analyzing hidden import 'PySide6.QtWebView'
362842 INFO: Analyzing hidden import 'PySide6.QtXml'
362877 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'
364018 INFO: Analyzing hidden import 'PySide6._config'
364032 INFO: Analyzing hidden import 'PySide6._git_pyside_version'
364047 INFO: Analyzing hidden import 'PySide6.scripts'
364051 INFO: Analyzing hidden import 'PySide6.scripts.deploy'
364083 INFO: Analyzing hidden import 'PySide6.scripts.metaobjectdump'
364144 INFO: Analyzing hidden import 'PySide6.scripts.project'
364206 INFO: Analyzing hidden import 'PySide6.scripts.project_lib'
364407 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'
364588 INFO: Analyzing hidden import 'PySide6.scripts.pyside_tool'
364619 INFO: Analyzing hidden import 'PySide6.scripts.qml'
364648 INFO: Analyzing hidden import 'PySide6.scripts.qtpy2cpp'
364670 INFO: Analyzing hidden import 'PySide6.support'
364682 INFO: Analyzing hidden import 'PySide6.support.deprecated'
364697 INFO: Analyzing hidden import 'PySide6.support.generate_pyi'
364717 INFO: Processing module hooks (post-graph stage)...
366519 INFO: Performing binary vs. data reclassification (3704 entries)
434075 INFO: Looking for ctypes DLLs
434137 INFO: Analyzing run-time hooks ...
434140 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'
434182 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'
434219 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'
434274 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'
434306 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'
434323 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'
434347 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'
434407 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'
434578 INFO: Creating base_library.zip...
434595 INFO: Looking for dynamic libraries
448674 INFO: Extra DLL search directories (AddDllDirectory): ['\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\shiboken6']
448674 INFO: Extra DLL search directories (PATH): ['\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6']
627416 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'.
627645 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'.
627727 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'.
627756 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'.
628660 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'.
634420 INFO: Warnings written to C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\warn-CloudRestoreAS.txt
634496 INFO: Graph cross-reference written to C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\xref-CloudRestoreAS.html
634828 INFO: checking PYZ
634828 INFO: Building PYZ because PYZ-00.toc is non existent
634828 INFO: Building PYZ (ZlibArchive) C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\PYZ-00.pyz
635544 INFO: Building PYZ (ZlibArchive) C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\PYZ-00.pyz completed successfully.
635679 INFO: checking PKG
635679 INFO: Building PKG because PKG-00.toc is non existent
635679 INFO: Building PKG (CArchive) CloudRestoreAS.pkg

113
build.ps1
View File

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

43
build.sh Executable file
View File

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

View File

@@ -0,0 +1,97 @@
# -*- 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))
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)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="CloudRestoreAS",
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,
)

18
packaging/LEEME.txt Normal file
View File

@@ -0,0 +1,18 @@
CloudRestoreAS — despliegue portable
=====================================
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.
No requiere Python ni instaladores adicionales en el equipo destino.

View File

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

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Genera ambos artefactos: Linux (local/Docker) y Windows (PowerShell + Python en Windows).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
RELEASE="$ROOT/dist/release"
mkdir -p "$RELEASE"
echo "=== Build Linux ==="
if [[ -f "$ROOT/build.sh" ]]; then
bash "$ROOT/build.sh"
cp -f "$ROOT/dist/CloudRestoreAS" "$RELEASE/CloudRestoreAS-linux"
chmod +x "$RELEASE/CloudRestoreAS-linux"
fi
echo "=== Build Windows (via PowerShell) ==="
WSL_PATH=$(wslpath -w "$ROOT" 2>/dev/null || echo "")
if [[ -n "$WSL_PATH" ]] && command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "
Set-Location '$WSL_PATH'
& 'C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\python.exe' -m pip install -q -r requirements-windows.txt 2>\$null
& .\build.ps1
"
if [[ -f "$ROOT/dist/CloudRestoreAS.exe" ]]; then
cp -f "$ROOT/dist/CloudRestoreAS.exe" "$RELEASE/CloudRestoreAS.exe"
fi
else
echo "Omitido: ejecute build.ps1 en Windows manualmente"
fi
cp -f "$ROOT/packaging/LEEME.txt" "$RELEASE/"
echo ""
echo "Artefactos en: $RELEASE"
ls -la "$RELEASE"

View File

@@ -0,0 +1,19 @@
#!/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
apt-get install -y -qq \
python3 python3-venv python3-pip curl dpkg-dev binutils unixodbc \
libglib2.0-0 libdbus-1-3 libxkbcommon0 libfontconfig1 libxcb1 libx11-6 \
libxcb-xkb1 libegl1 libgl1 libxi6 libxrender1 libxext6 \
> /dev/null
chmod +x build.sh packaging/scripts/download-bundled-deps.sh
./build.sh
'

View File

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

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Descarga 7zz y bibliotecas ODBC para empaquetar en el binario Linux.
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"
mkdir -p "$SEVEN" "$ODBC"
read_url() {
python3 -c "import json; print(json.load(open('$VERSIONS'))['$1'])"
}
LINUX_7Z_URL="$(read_url seven_zip_linux_x64)"
MSODBC_DEB="$(read_url msodbcsql18_deb)"
echo "==> 7-Zip Linux (7zz)"
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"
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
apt-get install -y -qq unixodbc dpkg-dev > /dev/null 2>&1 || true
if dpkg -l unixodbc 2>/dev/null | grep -q ^ii; then
find /usr/lib -name 'libodbc*.so*' -exec cp -P {} "$ODBC/" \; 2>/dev/null || true
find /usr/lib -name 'libodbcinst*.so*' -exec cp -P {} "$ODBC/" \; 2>/dev/null || true
fi
fi
dpkg-deb -x msodbcsql18.deb msodbcsql
find msodbcsql -name '*.so*' -exec cp -P {} "$ODBC/" \;
cd "$ROOT"
rm -rf "$TMP"
echo " Bibliotecas ODBC en $ODBC"
else
echo " ODBC ya existe"
fi
echo "Listo: $BUNDLED"

View File

@@ -0,0 +1,27 @@
#!/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/"
python3 <<'PY'
import zipfile
from pathlib import Path
release = Path("dist/release")
if (release / "CloudRestoreAS.exe").exists():
with zipfile.ZipFile(release / "CloudRestoreAS-win.zip", "w", zipfile.ZIP_DEFLATED) as z:
z.write(release / "CloudRestoreAS.exe", "CloudRestoreAS.exe")
z.write(release / "LEEME.txt", "LEEME.txt")
PY
if [[ -f "$RELEASE/CloudRestoreAS-linux" ]]; then
tar czf "$RELEASE/CloudRestoreAS-linux.tar.gz" -C "$RELEASE" CloudRestoreAS-linux LEEME.txt
fi
echo "Release en: $RELEASE"
ls -lh "$RELEASE"

View File

@@ -0,0 +1,18 @@
# CloudRestoreAS — configuración local (editar y reiniciar la app)
# Arranca el motor al abrir (la ventana siempre se muestra salvo START_MINIMIZED=true)
CLOUDRESTORE_AUTO_START=true
CLOUDRESTORE_START_MINIMIZED=false
CLOUDRESTORE_REGISTER_AUTOSTART=true
# Servicio PANEL_BASES_ANEXO24
CLOUDRESTORE_PANEL_API_URL=
CLOUDRESTORE_PANEL_API_TOKEN=
CLOUDRESTORE_PANEL_INSTANCE_KEY=
CLOUDRESTORE_PANEL_VERIFY_SSL=false
# Carpetas (se crean automáticamente junto al ejecutable)
CLOUDRESTORE_INPUT_FOLDER={APP_DIR}/Entrada
CLOUDRESTORE_PROCESSED_FOLDER={APP_DIR}/Procesados
CLOUDRESTORE_FAILED_FOLDER={APP_DIR}/Fallados
CLOUDRESTORE_EXTRACT_FOLDER={APP_DIR}/Temp

2
requirements-windows.txt Normal file
View File

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

View File

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

View File

@@ -1,48 +1,88 @@
"""Punto de entrada principal de la aplicación.""" """Punto de entrada principal de la aplicación."""
import argparse
import sys import sys
from pathlib import Path from pathlib import Path
# Agregar el directorio raíz al path ROOT_DIR = Path(__file__).parent.resolve()
ROOT_DIR = Path(__file__).parent if not getattr(sys, "frozen", False):
sys.path.insert(0, str(ROOT_DIR)) sys.path.insert(0, str(ROOT_DIR))
# 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.QtWidgets import QApplication
from PySide6.QtCore import Qt 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.main_window import MainWindow
from app.utils.logger import app_logger from app.utils.logger import app_logger
from app.constants import DATA_DIR, LOGS_DIR
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",
)
return parser.parse_args(argv)
def main(): def main():
"""Función principal.""" """Función principal."""
# Crear directorios necesarios args = parse_args()
DATA_DIR.mkdir(parents=True, exist_ok=True) launch = get_launch_options()
LOGS_DIR.mkdir(parents=True, exist_ok=True) initialize_env()
minimized = args.minimized or launch.minimized
panel_ok = is_panel_configured()
start_engine = args.start_engine or launch.start_engine
app_logger.info("=" * 80) app_logger.info("=" * 80)
app_logger.info("CloudRestoreAS - Iniciando aplicación") app_logger.info("CloudRestoreAS - Iniciando aplicación")
app_logger.info(f"Directorio app: {ROOT_DIR}")
if minimized:
app_logger.info("Modo: ventana oculta (--minimized o START_MINIMIZED)")
else:
app_logger.info("Modo: ventana visible; cerrar minimiza a bandeja")
if start_engine:
app_logger.info("Modo: motor auto-inicio")
if not panel_ok:
app_logger.warning(
"Servicio PANEL_BASES_ANEXO24 no configurado en config/.env — "
"complete CLOUDRESTORE_PANEL_* para enrutamiento automático."
)
app_logger.info("=" * 80) app_logger.info("=" * 80)
# Configurar aplicación Qt
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("CloudRestoreAS") app.setApplicationName("CloudRestoreAS")
app.setOrganizationName("Aduanasoft") app.setOrganizationName("Aduanasoft")
# Habilitar high DPI scaling
app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling) app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling)
app.setQuitOnLastWindowClosed(False)
try: try:
# Crear y mostrar ventana principal window = MainWindow(
window = MainWindow() minimized=minimized,
start_engine=start_engine,
panel_configured=panel_ok,
)
if not minimized:
window.show() window.show()
app_logger.info("Ventana principal mostrada") app_logger.info("Ventana principal mostrada")
else:
app_logger.info("Ventana oculta; icono en bandeja del sistema")
# Ejecutar aplicación
exit_code = app.exec() exit_code = app.exec()
app_logger.info(f"Aplicación finalizada con código: {exit_code}") app_logger.info(f"Aplicación finalizada con código: {exit_code}")
sys.exit(exit_code) sys.exit(exit_code)

5
tests/conftest.py Normal file
View File

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

79
tests/test_bootstrap.py Normal file
View File

@@ -0,0 +1,79 @@
"""Pruebas de bootstrap y carga de .env."""
import os
from pathlib import Path
import pytest
from app.config.bootstrap import ensure_runtime_layout
from app.config.env_loader import (
apply_env_overrides,
get_launch_options,
is_panel_configured,
render_env_template,
)
from app.constants import CONFIG_DIR, DEFAULT_CONFIG, DIR_ENTRADA, ENV_PATH
def test_render_env_template_contains_app_dir(tmp_path: Path):
text = render_env_template(tmp_path)
assert str(tmp_path) in text
assert "CLOUDRESTORE_PANEL_API_URL" in text
def test_ensure_runtime_layout_creates_structure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("app.config.bootstrap.APP_DIR", tmp_path)
monkeypatch.setattr("app.config.bootstrap.CONFIG_DIR", tmp_path / "config")
monkeypatch.setattr("app.config.bootstrap.DATA_DIR", tmp_path / "config" / "data")
monkeypatch.setattr("app.config.bootstrap.LOGS_DIR", tmp_path / "config" / "logs")
monkeypatch.setattr("app.config.bootstrap.ODBC_DIR", tmp_path / "config" / "odbc")
monkeypatch.setattr("app.config.bootstrap.SEVEN_ZIP_DIR", tmp_path / "config" / "7zip")
monkeypatch.setattr("app.config.bootstrap.ENV_PATH", tmp_path / "config" / ".env")
monkeypatch.setattr("app.config.bootstrap.DIR_ENTRADA", tmp_path / "Entrada")
monkeypatch.setattr("app.config.bootstrap.DIR_PROCESADOS", tmp_path / "Procesados")
monkeypatch.setattr("app.config.bootstrap.DIR_FALLADOS", tmp_path / "Fallados")
monkeypatch.setattr("app.config.bootstrap.DIR_TEMP", tmp_path / "Temp")
monkeypatch.setattr("app.constants.DB_PATH", tmp_path / "config" / "data" / "app.db")
ensure_runtime_layout()
assert (tmp_path / "config" / ".env").is_file()
assert (tmp_path / "Entrada").is_dir()
assert (tmp_path / "config" / "data" / "app.db").is_file()
def test_is_panel_configured(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("CLOUDRESTORE_PANEL_API_URL", raising=False)
monkeypatch.delenv("CLOUDRESTORE_PANEL_API_TOKEN", raising=False)
monkeypatch.delenv("CLOUDRESTORE_PANEL_INSTANCE_KEY", raising=False)
assert not is_panel_configured()
monkeypatch.setenv("CLOUDRESTORE_PANEL_API_URL", "https://panel:3000")
monkeypatch.setenv("CLOUDRESTORE_PANEL_API_TOKEN", "secret")
monkeypatch.setenv("CLOUDRESTORE_PANEL_INSTANCE_KEY", "srv1")
assert is_panel_configured()
def test_get_launch_options_auto_start_does_not_minimize(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CLOUDRESTORE_AUTO_START", "true")
monkeypatch.delenv("CLOUDRESTORE_START_MINIMIZED", raising=False)
opts = get_launch_options()
assert opts.start_engine is True
assert opts.minimized is False
def test_get_launch_options_start_minimized(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CLOUDRESTORE_START_MINIMIZED", "true")
opts = get_launch_options()
assert opts.minimized is True
def test_render_env_template_documents_start_minimized(tmp_path: Path):
text = render_env_template(tmp_path)
assert "CLOUDRESTORE_START_MINIMIZED=false" in text
def test_apply_env_overrides_paths(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CLOUDRESTORE_INPUT_FOLDER", "/tmp/in")
cfg = apply_env_overrides(DEFAULT_CONFIG.copy())
assert cfg["paths"]["input_folder"] == "/tmp/in"

View File

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

381
tests/test_panel_client.py Normal file
View File

@@ -0,0 +1,381 @@
"""
Pruebas del cliente del PANEL (servidor de restauración asignado por base de datos).
Se mockea `requests` para no depender de un PANEL real.
"""
import pytest
from app.panel import panel_client
class FakeResponse:
def __init__(self, status_code, json_data=None, raise_json=False):
self.status_code = status_code
self._json = json_data
self._raise_json = raise_json
def json(self):
if self._raise_json:
raise ValueError("no es JSON")
return self._json
VALID_TARGET = {
"id": 2,
"name": "Omega",
"server": "192.168.1.100,1433",
"username": "sa",
"password": "secreto",
"data_folder": "C:\\SQLData",
"ssh_host": "192.168.1.100",
"ssh_port": 22,
"ssh_username": "Administrator",
"ssh_password": "ssh-secreto",
"remote_inbox_path": "C:\\RestoreInbox",
}
URL = "http://panel:3000"
TOKEN = "tok"
DB = "EMPRESA_DB"
def test_target_url_vacia_devuelve_none():
assert panel_client.get_target_for_database("", TOKEN, DB) is None
def test_target_url_invalida_devuelve_none():
assert panel_client.get_target_for_database("ftp://x", TOKEN, DB) is None
def test_target_token_vacio_devuelve_none():
assert panel_client.get_target_for_database(URL, "", DB) is None
def test_target_db_vacio_devuelve_none():
assert panel_client.get_target_for_database(URL, TOKEN, " ") is None
def test_target_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None):
captured["url"] = url
return FakeResponse(200, VALID_TARGET)
monkeypatch.setattr(panel_client.requests, "get", fake_get)
target = panel_client.get_target_for_database(URL, TOKEN, DB)
assert target is not None
assert target["name"] == "Omega"
# el db_name viaja como query param url-encoded
assert "database=EMPRESA_DB" in captured["url"]
def test_target_db_con_espacios_se_encodea(monkeypatch):
captured = {}
monkeypatch.setattr(
panel_client.requests, "get",
lambda url, **k: (captured.__setitem__("url", url), FakeResponse(200, VALID_TARGET))[1]
)
panel_client.get_target_for_database(URL, TOKEN, "MI BASE")
assert "MI%20BASE" in captured["url"]
def test_target_falta_campo_requerido(monkeypatch):
incompleto = dict(VALID_TARGET)
del incompleto["password"]
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(200, incompleto))
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
def test_target_campo_vacio_es_invalido(monkeypatch):
vacio = dict(VALID_TARGET, password=" ")
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(200, vacio))
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
@pytest.mark.parametrize("code", [404, 401, 500, 503])
def test_target_codigos_no_200_devuelven_none(monkeypatch, code):
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(code))
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
def test_target_error_red_devuelve_none(monkeypatch):
def boom(*a, **k):
raise panel_client.requests.exceptions.ConnectionError("caído")
monkeypatch.setattr(panel_client.requests, "get", boom)
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
def test_target_json_invalido_devuelve_none(monkeypatch):
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, raise_json=True)
)
assert panel_client.get_target_for_database(URL, TOKEN, DB) is None
def test_report_job_result_201_true(monkeypatch):
captured = {}
def fake_post(url, json=None, headers=None, timeout=None):
captured["json"] = json
return FakeResponse(201)
monkeypatch.setattr(panel_client.requests, "post", fake_post)
ok = panel_client.report_job_result(
URL, TOKEN, filename="empresa.bak", status="completed",
restore_target_id=2, db_name="EMP", duration_ms=1000
)
assert ok is True
assert captured["json"]["filename"] == "empresa.bak"
assert captured["json"]["status"] == "completed"
def test_report_job_result_otro_codigo_false(monkeypatch):
monkeypatch.setattr(panel_client.requests, "post", lambda *a, **k: FakeResponse(500))
assert panel_client.report_job_result(URL, TOKEN, "x.bak", "failed") is False
def test_report_job_result_error_red_false(monkeypatch):
def boom(*a, **k):
raise panel_client.requests.exceptions.Timeout("timeout")
monkeypatch.setattr(panel_client.requests, "post", boom)
assert panel_client.report_job_result(URL, TOKEN, "x.bak", "failed") is False
@pytest.mark.parametrize("code", [200, 404])
def test_test_connection_ok(monkeypatch, code):
# 200 o 404 → conexión y token correctos
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(code))
ok, info = panel_client.test_connection(URL, TOKEN)
assert ok is True
def test_test_connection_token_rechazado(monkeypatch):
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(401))
ok, info = panel_client.test_connection(URL, TOKEN)
assert ok is False
assert "401" in info
def test_test_connection_url_invalida():
ok, info = panel_client.test_connection("noesurl", TOKEN)
assert ok is False
def test_report_instance_config_200_true(monkeypatch):
captured = {}
def fake_post(url, json=None, headers=None, timeout=None):
captured["url"] = url
captured["json"] = json
return FakeResponse(200)
monkeypatch.setattr(panel_client.requests, "post", fake_post)
ok = panel_client.report_instance_config(
URL, TOKEN, r"D:\Backups\Entrada", host_name="WIN-01", app_version="1.0.0"
)
assert ok is True
assert captured["url"].endswith("/api/restore/instance-config")
assert captured["json"]["input_folder"] == r"D:\Backups\Entrada"
assert captured["json"]["host_name"] == "WIN-01"
def test_report_instance_config_input_vacio_false():
assert panel_client.report_instance_config(URL, TOKEN, " ") is False
def test_report_instance_config_sin_token_false():
assert panel_client.report_instance_config(URL, "", r"D:\In") is False
def test_report_instance_config_otro_codigo_false(monkeypatch):
monkeypatch.setattr(panel_client.requests, "post", lambda *a, **k: FakeResponse(500))
assert panel_client.report_instance_config(URL, TOKEN, r"D:\In") is False
def test_report_instance_config_error_red_false(monkeypatch):
def boom(*a, **k):
raise panel_client.requests.exceptions.ConnectionError("caído")
monkeypatch.setattr(panel_client.requests, "post", boom)
assert panel_client.report_instance_config(URL, TOKEN, r"D:\In") is False
def test_target_con_instance_envia_param(monkeypatch):
captured = {}
monkeypatch.setattr(
panel_client.requests, "get",
lambda url, **k: (captured.__setitem__("url", url), FakeResponse(200, VALID_TARGET))[1]
)
panel_client.get_target_for_database(URL, TOKEN, DB, instance_key="Alfa")
assert "instance=Alfa" in captured["url"]
COLOCATED_TARGET = {
"id": 1,
"name": "Alfa",
"server": "localhost",
"username": "sa",
"password": "secreto",
"data_folder": "C:\\SQLData",
}
def test_target_colocado_no_exige_ssh(monkeypatch):
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, COLOCATED_TARGET)
)
target = panel_client.get_target_for_database(URL, TOKEN, DB, instance_key="Alfa")
assert target is not None
assert target["name"] == "Alfa"
def test_report_instance_config_con_instance_key(monkeypatch):
captured = {}
monkeypatch.setattr(
panel_client.requests, "post",
lambda url, json=None, **k: (captured.update({"json": json}), FakeResponse(200))[1]
)
ok = panel_client.report_instance_config(
URL, TOKEN, r"D:\Alfa\In", instance_key="Alfa"
)
assert ok is True
assert captured["json"]["instance_key"] == "Alfa"
CATALOG_RESPONSE = {
"targets": [
{"id": 1, "name": "Alfa"},
{"id": 2, "name": "Omega"},
{"id": 4, "name": "Delta"},
]
}
def test_list_restore_target_names_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None):
captured["url"] = url
return FakeResponse(200, CATALOG_RESPONSE)
monkeypatch.setattr(panel_client.requests, "get", fake_get)
names = panel_client.list_restore_target_names(URL, TOKEN)
assert names == ["Alfa", "Omega", "Delta"]
assert captured["url"].endswith("/api/restore/target-catalog")
def test_list_restore_target_names_url_vacia():
assert panel_client.list_restore_target_names("", TOKEN) == []
def test_list_restore_target_names_token_vacio():
assert panel_client.list_restore_target_names(URL, "") == []
@pytest.mark.parametrize("code", [401, 500, 503])
def test_list_restore_target_names_codigos_no_200(monkeypatch, code):
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(code))
assert panel_client.list_restore_target_names(URL, TOKEN) == []
def test_list_restore_target_names_error_red(monkeypatch):
def boom(*a, **k):
raise panel_client.requests.exceptions.ConnectionError("caído")
monkeypatch.setattr(panel_client.requests, "get", boom)
assert panel_client.list_restore_target_names(URL, TOKEN) == []
def test_list_restore_target_names_json_invalido(monkeypatch):
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, raise_json=True)
)
assert panel_client.list_restore_target_names(URL, TOKEN) == []
def test_list_restore_target_names_sin_lista_targets(monkeypatch):
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, {"foo": []})
)
assert panel_client.list_restore_target_names(URL, TOKEN) == []
ROUTE_FORWARD = {
"action": "forward",
"db_name": "GENERICA-TEST",
"node_key": "GENERICA-TEST",
"target": {
"id": 2,
"name": "Alfa",
"server": "192.168.1.10,1433",
"username": "sa",
"password": "secreto",
"data_folder": "C:\\SQLData",
"ssh_host": "192.168.1.10",
"ssh_port": 22,
"ssh_username": "Administrator",
"ssh_password": "ssh-secreto",
"remote_inbox_path": "C:\\RestoreInbox",
"input_folder": "D:\\Restore\\Alfa\\Entrada",
},
}
ROUTE_RESTORE_LOCAL = {
"action": "restore_local",
"db_name": "GENERICA-TEST",
"node_key": "GENERICA-TEST",
"target": {
"id": 2,
"name": "Alfa",
"server": "192.168.1.10,1433",
"username": "sa",
"password": "secreto",
"data_folder": "C:\\SQLData",
"ssh_host": "192.168.1.10",
"ssh_port": 22,
"ssh_username": "Administrator",
"ssh_password": "ssh-secreto",
"remote_inbox_path": "C:\\RestoreInbox",
"input_folder": "D:\\Restore\\Alfa\\Entrada",
},
}
def test_resolve_route_forward_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None):
captured["url"] = url
return FakeResponse(200, ROUTE_FORWARD)
monkeypatch.setattr(panel_client.requests, "get", fake_get)
route = panel_client.resolve_route(URL, TOKEN, "GENERICA-TEST.ZIP", instance_key="Omega")
assert route is not None
assert route["action"] == "forward"
assert "resolve-route" in captured["url"]
assert "instance=Omega" in captured["url"]
def test_resolve_route_restore_local_ok(monkeypatch):
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, ROUTE_RESTORE_LOCAL)
)
route = panel_client.resolve_route(URL, TOKEN, "GENERICA-TEST.ZIP", instance_key="Alfa")
assert route is not None
assert route["action"] == "restore_local"
def test_resolve_route_503_devuelve_none(monkeypatch):
monkeypatch.setattr(panel_client.requests, "get", lambda *a, **k: FakeResponse(503))
assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None
def test_resolve_route_forward_sin_input_folder_invalido(monkeypatch):
bad = dict(ROUTE_FORWARD)
bad["target"] = dict(bad["target"], input_folder=" ")
monkeypatch.setattr(
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, bad)
)
assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None

138
tests/test_route_forward.py Normal file
View File

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

135
tests/test_sftp_copy.py Normal file
View File

@@ -0,0 +1,135 @@
"""
Pruebas de la transferencia SFTP al servidor remoto. Se mockea paramiko para no
requerir un servidor SSH real; se valida la conversión de rutas y el flujo de subida.
"""
import pytest
from app.transfer import sftp_copy
from app.transfer.sftp_copy import SFTPCopyError
CFG = {
"ssh_host": "192.168.1.100",
"ssh_port": 22,
"ssh_username": "Administrator",
"ssh_password": "ssh-secreto",
"remote_inbox_path": "C:\\RestoreInbox",
}
def test_windows_restore_path():
assert sftp_copy.windows_restore_path("C:\\RestoreInbox", "empresa.bak") == "C:\\RestoreInbox\\empresa.bak"
def test_sftp_path_convierte_windows_a_posix():
# ruta destino en formato Windows → SFTP usa forward slashes
assert sftp_copy._sftp_path("C:\\RestoreInbox", "empresa.bak") == "C:/RestoreInbox/empresa.bak"
# ya en posix se respeta (sin doble slash)
assert sftp_copy._sftp_path("/srv/inbox/", "x.bak") == "/srv/inbox/x.bak"
def test_upload_origen_inexistente(tmp_path):
with pytest.raises(SFTPCopyError, match="no existe"):
sftp_copy.upload_to_remote(str(tmp_path / "noexiste.bak"), CFG)
class FakeSFTP:
def __init__(self, store):
self.store = store
def put(self, local, remote):
self.store["put"] = (local, remote)
def remove(self, remote):
self.store["removed"] = remote
def close(self):
self.store["sftp_closed"] = True
class FakeClient:
def __init__(self, store):
self.store = store
def set_missing_host_key_policy(self, policy):
self.store["policy_set"] = True
def connect(self, **kwargs):
self.store["connect"] = kwargs
def open_sftp(self):
return FakeSFTP(self.store)
def close(self):
self.store["client_closed"] = True
def _fake_paramiko(store):
class FakeParamiko:
AutoAddPolicy = object
SSHClient = lambda self=None: FakeClient(store)
fp = FakeParamiko()
# SSHClient() debe devolver FakeClient
fp.SSHClient = lambda: FakeClient(store)
return fp
def test_upload_ok(tmp_path, monkeypatch):
bak = tmp_path / "empresa.bak"
bak.write_bytes(b"data")
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
remote = sftp_copy.upload_to_remote(str(bak), CFG)
assert remote == "C:/RestoreInbox/empresa.bak"
assert store["put"][1] == "C:/RestoreInbox/empresa.bak"
assert store["connect"]["hostname"] == "192.168.1.100"
assert store["connect"]["port"] == 22
assert store["client_closed"] is True
def test_cleanup_remote_borra(monkeypatch):
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
sftp_copy.cleanup_remote(CFG, "C:/RestoreInbox/empresa.bak")
assert store["removed"] == "C:/RestoreInbox/empresa.bak"
assert store["client_closed"] is True
def test_cleanup_remote_none_no_falla():
sftp_copy.cleanup_remote(CFG, None) # no debe conectar ni lanzar
def test_upload_file_to_folder_ok(tmp_path, monkeypatch):
zf = tmp_path / "backup.zip"
zf.write_bytes(b"zipdata")
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
remote = sftp_copy.upload_file_to_folder(
str(zf), CFG, "D:\\Restore\\Alfa\\Entrada"
)
assert remote == "D:/Restore/Alfa/Entrada/backup.zip"
assert store["put"][1] == "D:/Restore/Alfa/Entrada/backup.zip"
def test_upload_zip_parts_multipart(tmp_path, monkeypatch):
p1 = tmp_path / "big.zip.001"
p2 = tmp_path / "big.zip.002"
p1.write_bytes(b"a")
p2.write_bytes(b"b")
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
uploaded = sftp_copy.upload_zip_parts(
[str(p1), str(p2)], CFG, "D:\\In"
)
assert len(uploaded) == 2
assert uploaded[0].endswith("/big.zip.001")
assert uploaded[1].endswith("/big.zip.002")
def test_upload_file_to_folder_vacio_falla(tmp_path):
f = tmp_path / "x.zip"
f.write_bytes(b"x")
with pytest.raises(SFTPCopyError, match="carpeta remota"):
sftp_copy.upload_file_to_folder(str(f), CFG, " ")

View File

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