From afb76ce4a651806648643246de095051ee244514 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 5 Jun 2026 10:49:05 -0600 Subject: [PATCH 1/3] feature/integracion-panel-restore-targets --- INTEGRACION_PANEL.md | 197 ++++++++++++++++ QUICKSTART.md | 4 + app/constants.py | 11 + app/db/job_repository.py | 9 + app/engine/engine.py | 37 ++- app/engine/restore_worker.py | 263 +++++++++++++++++---- app/panel/__init__.py | 1 + app/panel/panel_client.py | 402 ++++++++++++++++++++++++++++++++ app/sql/sql_manager.py | 140 ++++++----- app/transfer/__init__.py | 1 + app/transfer/sftp_copy.py | 160 +++++++++++++ app/ui/config_tab.py | 113 ++++++++- app/ui/nodes_tab.py | 2 +- requirements.txt | 9 + tests/conftest.py | 5 + tests/test_colocated_resolve.py | 41 ++++ tests/test_panel_client.py | 381 ++++++++++++++++++++++++++++++ tests/test_route_forward.py | 138 +++++++++++ tests/test_sftp_copy.py | 135 +++++++++++ tests/test_sql_manager_move.py | 77 ++++++ 20 files changed, 2022 insertions(+), 104 deletions(-) create mode 100644 INTEGRACION_PANEL.md create mode 100644 app/panel/__init__.py create mode 100644 app/panel/panel_client.py create mode 100644 app/transfer/__init__.py create mode 100644 app/transfer/sftp_copy.py create mode 100644 tests/conftest.py create mode 100644 tests/test_colocated_resolve.py create mode 100644 tests/test_panel_client.py create mode 100644 tests/test_route_forward.py create mode 100644 tests/test_sftp_copy.py create mode 100644 tests/test_sql_manager_move.py diff --git a/INTEGRACION_PANEL.md b/INTEGRACION_PANEL.md new file mode 100644 index 0000000..abbff17 --- /dev/null +++ b/INTEGRACION_PANEL.md @@ -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=` — 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 `. + +### 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=&instance=` + +**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=&instance=` + +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**. diff --git a/QUICKSTART.md b/QUICKSTART.md index 317881f..40a3ab1 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -20,6 +20,10 @@ Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"} .\venv\Scripts\python.exe runner.py ``` +### Integración con PANEL_BASES_ANEXO24 + +Si usas el panel para asignar servidores de restauración, ver [INTEGRACION_PANEL.md](INTEGRACION_PANEL.md) para tokens, catálogo dinámico (`target-catalog`) y prueba end-to-end. + ### 3. Configuración Básica (en la aplicación) 1. **Tab Configuración**: diff --git a/app/constants.py b/app/constants.py index c69fdc4..7d9cc14 100644 --- a/app/constants.py +++ b/app/constants.py @@ -39,6 +39,8 @@ class StepType: FILELIST = "filelist" RESTORE = "restore" CLEANUP = "cleanup" + SFTP_COPY = "sftp_copy" # Transferencia del .bak al servidor SQL externo (SFTP/SSH) + FORWARD_ZIP = "forward_zip" # Reenvío del ZIP al input_folder del servidor destino # Configuración por defecto DEFAULT_CONFIG = { @@ -78,5 +80,14 @@ DEFAULT_CONFIG = { "dry_run_mode": False, "auto_scan_enabled": True, "scan_interval_seconds": 30 + }, + # Integración con PANEL_BASES_ANEXO24. Si api_url está vacío, CloudRestoreAS + # opera en modo local usando la sección "sql" (retrocompatibilidad). + "panel": { + "api_url": "", + "api_token": "", + # Nombre del restore_target en el panel (identidad de este CRA). + # Con panel configurado: enrutamiento automático restore_local | forward. + "instance_key": "", } } diff --git a/app/db/job_repository.py b/app/db/job_repository.py index ebea029..c69c7a3 100644 --- a/app/db/job_repository.py +++ b/app/db/job_repository.py @@ -176,6 +176,15 @@ class JobRepository: tuple(params) ) + @staticmethod + def delete(job_id: str): + """ + Elimina un job y su rastro de hash. Se usa para diferir un job cuando no + hay servidor de restauración activo: al borrarlo, el próximo escaneo del + FileWatcher vuelve a detectar el archivo y reintenta (G8 del plan). + """ + db.execute("DELETE FROM jobs WHERE job_id = ?", (job_id,)) + @staticmethod def exists_by_hash(source_hash: str) -> bool: """Verifica si existe un job con el hash dado.""" diff --git a/app/engine/engine.py b/app/engine/engine.py index 7a66dfd..d3c7479 100644 --- a/app/engine/engine.py +++ b/app/engine/engine.py @@ -1,15 +1,19 @@ """Motor principal de la aplicación.""" +import platform +import socket from typing import Optional from pathlib import Path from PySide6.QtCore import QObject, Signal, QThreadPool from .file_watcher import FileWatcher, FileStabilityChecker, calculate_file_hash from .restore_worker import RestoreWorker +from .. import __version__ from ..db.job_repository import JobRepository from ..db.event_repository import EventRepository from ..db.config_repository import ConfigRepository from ..constants import JobStatus, DEFAULT_CONFIG +from ..panel import panel_client from ..utils.logger import app_logger @@ -41,7 +45,8 @@ class RestoreEngine(QObject): # Configuración self._config = self._load_config() - + self._report_instance_config_to_panel() + app_logger.info("RestoreEngine inicializado") def _load_config(self) -> dict: @@ -66,7 +71,8 @@ class RestoreEngine(QObject): ConfigRepository.set("app_config", config) self._config = config app_logger.info("Configuración guardada") - + self._report_instance_config_to_panel() + # Reconfigurar file watcher si está corriendo if self._running: self._restart_file_watcher() @@ -103,7 +109,8 @@ class RestoreEngine(QObject): self._running = True self._paused = False - + self._report_instance_config_to_panel() + EventRepository.create("INFO", "Motor iniciado") app_logger.info("Motor iniciado") @@ -190,6 +197,30 @@ class RestoreEngine(QObject): return stats + def _report_instance_config_to_panel(self) -> None: + """Reporta input_folder al PANEL (best-effort, no bloquea el flujo).""" + panel_cfg = self._config.get("panel", {}) + api_url = (panel_cfg.get("api_url") or "").strip() + api_token = (panel_cfg.get("api_token") or "").strip() + if not api_url or not api_token: + return + + input_folder = (self._config.get("paths") or {}).get("input_folder") or "" + try: + host_name = socket.gethostname() or platform.node() + except Exception: + host_name = platform.node() + + instance_key = (panel_cfg.get("instance_key") or "").strip() or None + panel_client.report_instance_config( + api_url=api_url, + api_token=api_token, + input_folder=input_folder, + host_name=host_name, + app_version=__version__, + instance_key=instance_key, + ) + def _validate_config(self) -> bool: """Valida que la configuración sea correcta.""" paths = self._config["paths"] diff --git a/app/engine/restore_worker.py b/app/engine/restore_worker.py index f687a3a..b705805 100644 --- a/app/engine/restore_worker.py +++ b/app/engine/restore_worker.py @@ -14,9 +14,19 @@ from ..db.event_repository import EventRepository from ..db.node_repository import NodeRepository from ..extract.seven_zip import SevenZipExtractor from ..sql.sql_manager import SQLServerManager +from ..panel import panel_client +from ..transfer import sftp_copy from ..utils.logger import app_logger +class RestoreDeferred(Exception): + """ + Señala que el job no puede ejecutarse ahora pero NO es un error: no hay + servidor de restauración activo en el PANEL. El job se difiere (se borra para + que el próximo escaneo lo reintente), no se marca como fallido (G8 del plan). + """ + + class RestoreWorkerSignals(QObject): """Señales para comunicación con la UI.""" job_started = Signal(str) # job_id @@ -47,9 +57,20 @@ class RestoreWorker(QRunnable): self.config = config self.dry_run = dry_run self.signals = RestoreWorkerSignals() - + self._extract_dir: Optional[str] = None self._bak_path: Optional[str] = None + # Servidor de restauración activo del PANEL (None = modo local sin PANEL). + self._target: Optional[dict] = None + + def _panel_configured(self) -> bool: + panel_cfg = self.config.get("panel", {}) + return bool((panel_cfg.get("api_url") or "").strip()) + + def _legacy_panel_mode(self) -> Optional[str]: + """Detecta mode legacy en config; solo para log de deprecación.""" + mode = (self.config.get("panel", {}).get("mode") or "").strip() + return mode if mode in ("orchestrator", "hub_restore", "colocated") else None def run(self): """Ejecuta el procesamiento del job.""" @@ -58,46 +79,190 @@ class RestoreWorker(QRunnable): try: app_logger.info(f"Iniciando procesamiento de job {self.job_id}") self.signals.job_started.emit(self.job_id) - + # Obtener job job = JobRepository.get(self.job_id) if not job: raise ValueError(f"Job {self.job_id} no encontrado") - - # Pipeline de procesamiento - self._process_node_mapping(job) + + if not self._panel_configured(): + self._process_node_mapping(job) + job = JobRepository.get(self.job_id) + self._target = None + app_logger.info("PANEL no configurado: usando configuración SQL local") + else: + legacy_mode = self._legacy_panel_mode() + if legacy_mode and legacy_mode != "colocated": + app_logger.warning( + f"panel.mode={legacy_mode} está obsoleto; " + "se usa enrutamiento automático (resolve-route)" + ) + route = self._resolve_route(job) + job = JobRepository.get(self.job_id) + if route["action"] == "forward": + self._forward_zip(job, route, start_time) + return + self._target = route["target"] + + # Extraer y restaurar localmente. self._extract_backup(job) self._restore_database(job) self._cleanup(job) - + # Actualizar tiempos total_ms = int((time.time() - start_time) * 1000) JobRepository.update_timing(self.job_id, total_ms=total_ms) - + # Marcar como completado JobRepository.update_status(self.job_id, JobStatus.COMPLETED) - + app_logger.info( f"Job {self.job_id} completado exitosamente en {total_ms}ms" ) + self._report_to_panel(job, "completed", duration_ms=total_ms) self.signals.job_completed.emit(self.job_id, True) - + + except RestoreDeferred as e: + # No hay servidor activo: borrar el job para que el próximo escaneo + # vuelva a detectar el archivo y reintente. No cuenta como fallo. + app_logger.warning(f"Job {self.job_id} diferido: {e}") + EventRepository.create( + "WARNING", f"Restauración diferida (sin servidor activo): {e}", self.job_id + ) + JobRepository.delete(self.job_id) + self.signals.job_completed.emit(self.job_id, False) + except Exception as e: error_msg = str(e) app_logger.error(f"Error procesando job {self.job_id}: {error_msg}", exc_info=True) - + JobRepository.update_status( self.job_id, JobStatus.FAILED, error=error_msg, increment_attempts=True ) - + EventRepository.create("ERROR", f"Job falló: {error_msg}", self.job_id) - + + job = JobRepository.get(self.job_id) + self._report_to_panel(job, "failed", error_message=error_msg) + self.signals.error_occurred.emit(self.job_id, error_msg) self.signals.job_completed.emit(self.job_id, False) - + + def _resolve_route(self, job) -> dict: + """ + Enrutamiento automático vía panel: restore_local o forward según nodo/asignación. + Actualiza db_name del job desde el panel. + """ + panel_cfg = self.config.get("panel", {}) + api_url = (panel_cfg.get("api_url") or "").strip() + api_token = (panel_cfg.get("api_token") or "").strip() + instance_key = (panel_cfg.get("instance_key") or "").strip() + + if not instance_key: + raise RestoreDeferred( + "Falta instance_key (instancia/servidor) en la configuración del PANEL" + ) + + route = panel_client.resolve_route( + api_url, + api_token, + job.source_name, + instance_key=instance_key, + ) + if not route: + raise RestoreDeferred( + f"El PANEL no pudo resolver la ruta para '{job.source_name}'" + ) + + node_key = (route.get("node_key") or Path(job.source_name).name).upper() + JobRepository.update_node_and_db(self.job_id, node_key, route["db_name"]) + app_logger.info( + f"Ruta resuelta: action={route.get('action')}, db={route.get('db_name')}" + ) + return route + + def _collect_zip_paths(self, source_path: str) -> list[str]: + """Rutas locales del ZIP (incluye todas las partes multipart).""" + path = Path(source_path) + if SevenZipExtractor.is_multipart(str(path)): + base_name = path.stem.split(".zip")[0] + parts = sorted(path.parent.glob(f"{base_name}.zip.*")) + return [str(p) for p in parts] + return [str(path)] + + def _move_zip_to_processed(self, job): + """Mueve el ZIP (y partes multipart) a la carpeta processed.""" + processed_folder = Path(self.config["paths"]["processed_folder"]) + date_folder = processed_folder / datetime.now().strftime("%Y-%m-%d") + date_folder.mkdir(parents=True, exist_ok=True) + + for zip_path in self._collect_zip_paths(job.source_path): + src = Path(zip_path) + dest = date_folder / src.name + shutil.move(str(src), str(dest)) + app_logger.info(f"Movido: {src.name} -> {dest}") + + def _forward_zip(self, job, route: dict, start_time: float): + """Reenvía el ZIP al input_folder del servidor destino vía SFTP.""" + target = route["target"] + self._target = target + + step_id = JobStepRepository.create(self.job_id, StepType.FORWARD_ZIP) + try: + zip_paths = self._collect_zip_paths(job.source_path) + remote_folder = target["input_folder"] + uploaded = sftp_copy.upload_zip_parts(zip_paths, target, remote_folder) + JobStepRepository.complete( + step_id, + exit_code=0, + stdout=f"Destino: {target.get('name')} ({len(uploaded)} archivo(s))", + ) + except Exception as e: + JobStepRepository.complete(step_id, exit_code=1, error=str(e)) + raise + + self._move_zip_to_processed(job) + + total_ms = int((time.time() - start_time) * 1000) + JobRepository.update_timing(self.job_id, total_ms=total_ms) + JobRepository.update_status(self.job_id, JobStatus.COMPLETED) + + app_logger.info( + f"Job {self.job_id} reenviado a '{target.get('name')}' en {total_ms}ms" + ) + EventRepository.create( + "INFO", + f"ZIP reenviado por SFTP a {target.get('name')}", + self.job_id, + ) + self._report_to_panel(job, "forwarded", duration_ms=total_ms) + self.signals.job_completed.emit(self.job_id, True) + + def _report_to_panel( + self, + job, + status: str, + duration_ms: Optional[int] = None, + error_message: Optional[str] = None, + ): + """Reporta el resultado del job al PANEL (best-effort, solo en modo PANEL).""" + if not self._target: + return + panel_cfg = self.config.get("panel", {}) + panel_client.report_job_result( + api_url=(panel_cfg.get("api_url") or "").strip(), + api_token=(panel_cfg.get("api_token") or "").strip(), + filename=job.source_name if job else "", + status=status, + restore_target_id=self._target.get("id"), + db_name=job.db_name if job else None, + duration_ms=duration_ms, + error_message=error_message, + ) + def _process_node_mapping(self, job): """Procesa el mapeo de nodo a base de datos.""" step_id = JobStepRepository.create(self.job_id, StepType.NODE_MAPPING) @@ -192,95 +357,109 @@ class RestoreWorker(QRunnable): """Restaura la base de datos desde el backup.""" JobRepository.update_status(self.job_id, JobStatus.RESTORING) self.signals.job_progress.emit(self.job_id, JobStatus.RESTORING) - + # Recargar job para obtener db_name actualizado job = JobRepository.get(self.job_id) - + if not job.db_name: raise ValueError("DB name no está configurado en el job") - + + # Servidor y credenciales: del servidor activo del PANEL, o config local. + if self._target: + server = self._target["server"] + use_windows_auth = False + username = self._target["username"] + password = self._target["password"] + data_folder = self._target["data_folder"] + else: + sql_config = self.config["sql"] + server = sql_config["server"] + use_windows_auth = sql_config["use_windows_auth"] + username = sql_config.get("username") + password = sql_config.get("password") + data_folder = self.config["paths"]["data_sql_folder"] + # Conectar a SQL connect_step_id = JobStepRepository.create(self.job_id, StepType.SQL_CONNECT) - + try: - sql_config = self.config["sql"] sql_manager = SQLServerManager( - server=sql_config["server"], - use_windows_auth=sql_config["use_windows_auth"], - username=sql_config.get("username"), - password=sql_config.get("password") + server=server, + use_windows_auth=use_windows_auth, + username=username, + password=password ) - + # Test connection success, error = sql_manager.test_connection() if not success: raise RuntimeError(f"Conexión SQL falló: {error}") - + JobStepRepository.complete(connect_step_id, exit_code=0) - + except Exception as e: JobStepRepository.complete(connect_step_id, exit_code=1, error=str(e)) raise - + + sql_backup_path = self._bak_path + # Obtener FILELISTONLY filelist_step_id = JobStepRepository.create(self.job_id, StepType.FILELIST) filelist_start = time.time() - + try: logical_files, error = sql_manager.get_filelist_from_backup( - self._bak_path, + sql_backup_path, timeout_minutes=self.config["timeouts"]["restore_minutes"] ) - + filelist_ms = int((time.time() - filelist_start) * 1000) JobRepository.update_timing(self.job_id, filelist_ms=filelist_ms) - + if error: raise RuntimeError(error) - + files_info = ", ".join([f"{lf.logical_name}({lf.type})" for lf in logical_files]) JobStepRepository.complete( filelist_step_id, exit_code=0, stdout=files_info[:1000] ) - + except Exception as e: JobStepRepository.complete(filelist_step_id, exit_code=1, error=str(e)) raise - + # RESTORE DATABASE restore_step_id = JobStepRepository.create(self.job_id, StepType.RESTORE) restore_start = time.time() - + try: - data_folder = self.config["paths"]["data_sql_folder"] - success, stdout, error = sql_manager.restore_database( db_name=job.db_name, - backup_path=self._bak_path, + backup_path=sql_backup_path, data_folder=data_folder, logical_files=logical_files, timeout_minutes=self.config["timeouts"]["restore_minutes"], dry_run=self.dry_run ) - + restore_ms = int((time.time() - restore_start) * 1000) JobRepository.update_timing(self.job_id, restore_ms=restore_ms) - + if not success: raise RuntimeError(error or "RESTORE falló sin mensaje de error") - + JobStepRepository.complete( restore_step_id, exit_code=0, stdout=stdout[:1000] if stdout else None ) - + except Exception as e: JobStepRepository.complete(restore_step_id, exit_code=1, error=str(e)) raise - + def _cleanup(self, job): """Limpia archivos temporales y mueve el ZIP.""" JobRepository.update_status(self.job_id, JobStatus.CLEANING) diff --git a/app/panel/__init__.py b/app/panel/__init__.py new file mode 100644 index 0000000..f9c2ab2 --- /dev/null +++ b/app/panel/__init__.py @@ -0,0 +1 @@ +"""Integración con PANEL_BASES_ANEXO24 (servidor de restauración activo).""" diff --git a/app/panel/panel_client.py b/app/panel/panel_client.py new file mode 100644 index 0000000..cdf3db2 --- /dev/null +++ b/app/panel/panel_client.py @@ -0,0 +1,402 @@ +""" +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. +""" + +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) + +# 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=: 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) + 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) + 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) + 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) + 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) + 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) + 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}" diff --git a/app/sql/sql_manager.py b/app/sql/sql_manager.py index e05fda4..0774087 100644 --- a/app/sql/sql_manager.py +++ b/app/sql/sql_manager.py @@ -34,7 +34,8 @@ class SQLServerManager: username: Usuario SQL (si no usa Windows Auth) password: Contraseña SQL (si no usa Windows Auth) """ - self.server = server + # ODBC usa coma para el puerto (ip,puerto), no dos puntos + self.server = server.replace(":", ",") if ":" in server else server self.use_windows_auth = use_windows_auth self.username = username self.password = password @@ -168,85 +169,112 @@ class SQLServerManager: Returns: Tupla (éxito, stdout, error) """ + conn = None try: - # Construir las cláusulas MOVE - move_clauses = [] - for lf in logical_files: - if lf.type == 'D': # Data file - new_path = f"{data_folder}\\{db_name}.mdf" - elif lf.type == 'L': # Log file - new_path = f"{data_folder}\\{db_name}_log.ldf" - else: - # Archivos adicionales (filestream, etc.) - continue - - move_clauses.append(f"MOVE N'{lf.logical_name}' TO N'{new_path}'") - + # Construir las cláusulas MOVE con un destino único por archivo lógico. + # Renombrar todos los data files a {db}.mdf colisiona si el backup tiene + # varios archivos; aquí cada archivo recibe un nombre distinto (G4). + move_clauses = self._build_move_clauses(db_name, data_folder, logical_files) if not move_clauses: - return False, None, "No se pudieron determinar los archivos de datos y log" - - # Construir el comando RESTORE - restore_cmd = f""" --- Poner la base de datos en modo single user -ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + return False, None, "No se pudieron determinar los archivos del backup" --- Restaurar -RESTORE DATABASE [{db_name}] -FROM DISK = N'{backup_path}' -WITH {', '.join(move_clauses)}, REPLACE; + restore_query = ( + f"RESTORE DATABASE [{db_name}] FROM DISK = N'{backup_path}' " + f"WITH {', '.join(move_clauses)}, REPLACE" + ) + + app_logger.info(f"Comando RESTORE generado:\n{restore_query}") --- Volver a modo multi user -ALTER DATABASE [{db_name}] SET MULTI_USER; -""" - - app_logger.info(f"Comando RESTORE generado:\n{restore_cmd}") - if dry_run: app_logger.info("Modo DRY RUN: No se ejecutará el RESTORE") - return True, restore_cmd, None - + return True, restore_query, None + # Ejecutar RESTORE conn_str = self.get_connection_string() conn = pyodbc.connect(conn_str, timeout=timeout_minutes * 60) conn.autocommit = True # Necesario para ALTER DATABASE cursor = conn.cursor() - + start_time = time.time() app_logger.info(f"Ejecutando RESTORE DATABASE [{db_name}]...") - - # Ejecutar en múltiples pasos + output_lines = [] - - # 1. Single user try: - cursor.execute(f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE") - output_lines.append("Base de datos configurada en modo SINGLE_USER") - except Exception as e: - app_logger.warning(f"Error configurando SINGLE_USER (puede no existir la DB): {e}") - - # 2. RESTORE - restore_query = f"RESTORE DATABASE [{db_name}] FROM DISK = N'{backup_path}' WITH {', '.join(move_clauses)}, REPLACE" - cursor.execute(restore_query) - output_lines.append("RESTORE DATABASE completado") - - # 3. Multi user - cursor.execute(f"ALTER DATABASE [{db_name}] SET MULTI_USER") - output_lines.append("Base de datos configurada en modo MULTI_USER") - + # 1. Single user (puede no existir la DB en una primera restauración) + try: + cursor.execute( + f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE" + ) + output_lines.append("Base de datos configurada en modo SINGLE_USER") + except Exception as e: + app_logger.warning( + f"No se pudo configurar SINGLE_USER (puede no existir la DB): {e}" + ) + + # 2. RESTORE + cursor.execute(restore_query) + output_lines.append("RESTORE DATABASE completado") + finally: + # 3. Volver SIEMPRE a MULTI_USER, incluso si el RESTORE falló, para + # no dejar la BD inaccesible en un servidor remoto compartido (G7). + try: + cursor.execute(f"ALTER DATABASE [{db_name}] SET MULTI_USER") + output_lines.append("Base de datos configurada en modo MULTI_USER") + except Exception as e: + app_logger.error( + f"No se pudo volver a MULTI_USER la BD [{db_name}] " + f"(¿quedó en estado RESTORING tras un fallo?): {e}. " + "Requiere intervención manual del DBA." + ) + elapsed = time.time() - start_time output_lines.append(f"Restauración completada en {elapsed:.2f}s") - - conn.close() - + output = "\n".join(output_lines) app_logger.info(output) - + return True, output, None - + except Exception as e: error_msg = f"Error restaurando base de datos: {str(e)}" app_logger.error(error_msg) return False, None, error_msg + finally: + if conn is not None: + try: + conn.close() + except Exception: + app_logger.warning("No se pudo cerrar la conexión SQL tras el RESTORE") + + @staticmethod + def _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: """ diff --git a/app/transfer/__init__.py b/app/transfer/__init__.py new file mode 100644 index 0000000..ac474c3 --- /dev/null +++ b/app/transfer/__init__.py @@ -0,0 +1 @@ +"""Transferencia de archivos hacia los servidores SQL remotos (SMB).""" diff --git a/app/transfer/sftp_copy.py b/app/transfer/sftp_copy.py new file mode 100644 index 0000000..b1bdb00 --- /dev/null +++ b/app/transfer/sftp_copy.py @@ -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() diff --git a/app/ui/config_tab.py b/app/ui/config_tab.py index 7f55cad..ae39644 100644 --- a/app/ui/config_tab.py +++ b/app/ui/config_tab.py @@ -3,13 +3,14 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QLineEdit, QPushButton, QSpinBox, - QCheckBox, QFileDialog, QMessageBox, QScrollArea + QCheckBox, QFileDialog, QMessageBox, QScrollArea, QComboBox ) from pathlib import Path from ..utils.crypto import encrypt_password, decrypt_password from ..extract.seven_zip import SevenZipExtractor from ..sql.sql_manager import SQLServerManager +from ..panel import panel_client class ConfigTab(QWidget): @@ -20,6 +21,7 @@ class ConfigTab(QWidget): super().__init__() self.engine = engine self.config = {} + self._catalog_names: list[str] = [] self._setup_ui() def _setup_ui(self): @@ -118,7 +120,38 @@ class ConfigTab(QWidget): sql_group.setLayout(sql_layout) layout.addWidget(sql_group) - + + # Grupo: PANEL de Control (servidor de restauración activo) + panel_group = QGroupBox("PANEL de Control") + panel_layout = QFormLayout() + + self.panel_url_input = QLineEdit() + self.panel_url_input.setPlaceholderText("http://ip-del-panel:3000") + panel_layout.addRow("URL del PANEL:", self.panel_url_input) + + self.panel_token_input = QLineEdit() + self.panel_token_input.setEchoMode(QLineEdit.EchoMode.Password) + panel_layout.addRow("API Token:", self.panel_token_input) + + self.panel_instance_combo = QComboBox() + self.panel_instance_combo.setToolTip( + "Nombre de este servidor en el panel. El CRA restaurará aquí lo que le " + "corresponda y reenviará el resto por SFTP automáticamente." + ) + self.refresh_instance_btn = QPushButton("Actualizar lista de servidores") + self.refresh_instance_btn.clicked.connect(self._refresh_instance_combo) + instance_row = QHBoxLayout() + instance_row.addWidget(self.panel_instance_combo, stretch=1) + instance_row.addWidget(self.refresh_instance_btn) + panel_layout.addRow("Instancia (servidor):", instance_row) + + test_panel_btn = QPushButton("🔌 Probar Conexión al PANEL") + test_panel_btn.clicked.connect(self._test_panel_connection) + panel_layout.addRow("", test_panel_btn) + + panel_group.setLayout(panel_layout) + layout.addWidget(panel_group) + # Grupo: Concurrencia concurrency_group = QGroupBox("Concurrencia") concurrency_layout = QFormLayout() @@ -269,7 +302,42 @@ class ConfigTab(QWidget): self.dry_run_checkbox.setChecked(features.get("dry_run_mode", False)) self.auto_scan_checkbox.setChecked(features.get("auto_scan_enabled", True)) self.scan_interval_spinbox.setValue(features.get("scan_interval_seconds", 30)) + + # PANEL de Control + panel = config.get("panel", {}) + self.panel_url_input.setText(panel.get("api_url", "")) + self.panel_token_input.setText(panel.get("api_token", "")) + self._refresh_instance_combo() + def _refresh_instance_combo(self): + """Recarga los nombres de servidores desde el catálogo del panel.""" + current = self.panel_instance_combo.currentText().strip() + saved_key = (self.config.get("panel", {}).get("instance_key") or "").strip() + preserve = current or saved_key + + api_url = self.panel_url_input.text().strip() + api_token = self.panel_token_input.text().strip() + + names: list[str] = [] + if api_url and api_token: + names = panel_client.list_restore_target_names(api_url, api_token) + + self._catalog_names = names + + items = [""] + for name in names: + if name not in items: + items.append(name) + if preserve and preserve not in items: + items.append(preserve) + + self.panel_instance_combo.blockSignals(True) + self.panel_instance_combo.clear() + self.panel_instance_combo.addItems(items) + idx = self.panel_instance_combo.findText(preserve) + self.panel_instance_combo.setCurrentIndex(idx if idx >= 0 else 0) + self.panel_instance_combo.blockSignals(False) + def _save_config(self): """Guarda la configuración.""" # Construir config @@ -307,9 +375,31 @@ class ConfigTab(QWidget): "dry_run_mode": self.dry_run_checkbox.isChecked(), "auto_scan_enabled": self.auto_scan_checkbox.isChecked(), "scan_interval_seconds": self.scan_interval_spinbox.value() + }, + "panel": { + "api_url": self.panel_url_input.text().strip(), + "api_token": self.panel_token_input.text().strip(), + "instance_key": self.panel_instance_combo.currentText().strip(), } } + panel_url = self.panel_url_input.text().strip() + panel_token = self.panel_token_input.text().strip() + instance_key = self.panel_instance_combo.currentText().strip() + if panel_url and panel_token and not instance_key: + QMessageBox.warning( + self, + "Advertencia", + "Con panel configurado debe seleccionar la instancia (servidor).", + ) + if panel_url and instance_key and self._catalog_names and instance_key not in self._catalog_names: + QMessageBox.warning( + self, + "Advertencia", + f'La instancia "{instance_key}" no está en el catálogo del panel. ' + "Verifique el nombre o cree el servidor en Servidores de Restauración.", + ) + # Cifrar password si no usa Windows Auth if not self.sql_windows_auth_checkbox.isChecked(): password = self.sql_password_input.text() @@ -356,6 +446,25 @@ class ConfigTab(QWidget): "No se pudo auto-detectar 7-Zip. Por favor, selecciona manualmente." ) + def _test_panel_connection(self): + """Prueba la conexión al PANEL y muestra el servidor de restauración activo.""" + api_url = self.panel_url_input.text().strip() + api_token = self.panel_token_input.text().strip() + + if not api_url: + QMessageBox.warning(self, "Error", "Debe especificar la URL del PANEL.") + return + + success, info = panel_client.test_connection(api_url, api_token) + + if success: + self._refresh_instance_combo() + QMessageBox.information( + self, "Éxito", f"Conexión al PANEL exitosa.\n{info or ''}" + ) + else: + QMessageBox.critical(self, "Error", f"No se pudo conectar al PANEL:\n{info}") + def _on_auth_changed(self, checked: bool): """Maneja el cambio en el tipo de autenticación.""" self.sql_username_input.setEnabled(not checked) diff --git a/app/ui/nodes_tab.py b/app/ui/nodes_tab.py index adc177b..2aad9e5 100644 --- a/app/ui/nodes_tab.py +++ b/app/ui/nodes_tab.py @@ -4,7 +4,7 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, QDialog, QFormLayout, QLineEdit, QTextEdit, QCheckBox, QDialogButtonBox, - QMessageBox + QMessageBox, QLabel ) from PySide6.QtCore import Qt diff --git a/requirements.txt b/requirements.txt index 30aba27..ab8c6a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,5 +9,14 @@ pyodbc>=5.0.0 # pywin32 para DPAPI (cifrado de passwords) pywin32>=306 +# requests para consultar al PANEL el servidor de restauración de cada base +requests>=2.31.0 + +# paramiko para transferir el .bak por SFTP/SSH a los servidores SQL externos +paramiko>=3.4.0 + # Para empaquetado (opcional) pyinstaller>=6.0.0 + +# Pruebas (opcional) +pytest>=8.0.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..57495fd --- /dev/null +++ b/tests/conftest.py @@ -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__)))) diff --git a/tests/test_colocated_resolve.py b/tests/test_colocated_resolve.py new file mode 100644 index 0000000..f5d16e3 --- /dev/null +++ b/tests/test_colocated_resolve.py @@ -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 diff --git a/tests/test_panel_client.py b/tests/test_panel_client.py new file mode 100644 index 0000000..dad29ab --- /dev/null +++ b/tests/test_panel_client.py @@ -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 diff --git a/tests/test_route_forward.py b/tests/test_route_forward.py new file mode 100644 index 0000000..cb10df5 --- /dev/null +++ b/tests/test_route_forward.py @@ -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" diff --git a/tests/test_sftp_copy.py b/tests/test_sftp_copy.py new file mode 100644 index 0000000..7037545 --- /dev/null +++ b/tests/test_sftp_copy.py @@ -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, " ") diff --git a/tests/test_sql_manager_move.py b/tests/test_sql_manager_move.py new file mode 100644 index 0000000..fb422fc --- /dev/null +++ b/tests/test_sql_manager_move.py @@ -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 From 072be5b5db4f01d952f9cb1ecc1037a3675e0f68 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 5 Jun 2026 12:28:04 -0600 Subject: [PATCH 2/3] feature/integracion-panel-restore-targets --- QUICKSTART.md | 9 +++ app/constants.py | 2 + app/panel/panel_client.py | 37 +++++++-- app/sql/sql_manager.py | 159 ++++++++++++++++++++++++++++++++------ 4 files changed, 176 insertions(+), 31 deletions(-) diff --git a/QUICKSTART.md b/QUICKSTART.md index 40a3ab1..0f5d187 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -24,6 +24,15 @@ Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"} Si usas el panel para asignar servidores de restauración, ver [INTEGRACION_PANEL.md](INTEGRACION_PANEL.md) para tokens, catálogo dinámico (`target-catalog`) y prueba end-to-end. +**Prueba local en tu PC** (carpetas + config automática): + +```powershell +.\scripts\prepare_local_test.ps1 +.\venv\Scripts\python.exe runner.py +``` + +Copia el ZIP a `C:\CloudRestore\Entrada` (ver `LEEME.txt` en esa carpeta). + ### 3. Configuración Básica (en la aplicación) 1. **Tab Configuración**: diff --git a/app/constants.py b/app/constants.py index 7d9cc14..ab05e33 100644 --- a/app/constants.py +++ b/app/constants.py @@ -89,5 +89,7 @@ DEFAULT_CONFIG = { # Nombre del restore_target en el panel (identidad de este CRA). # Con panel configurado: enrutamiento automático restore_local | forward. "instance_key": "", + # False en dev: el panel local usa HTTPS con certificado propio. + "verify_ssl": False, } } diff --git a/app/panel/panel_client.py b/app/panel/panel_client.py index cdf3db2..17bd40e 100644 --- a/app/panel/panel_client.py +++ b/app/panel/panel_client.py @@ -11,6 +11,7 @@ asignado, get_target_for_database devuelve None y el worker deja el job en cola reintentar; nunca se restaura con datos inciertos. """ +import os from typing import Optional from urllib.parse import urlparse, quote @@ -21,6 +22,18 @@ 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", @@ -109,7 +122,9 @@ def get_target_for_database( headers = {"Authorization": f"Bearer {api_token.strip()}"} try: - resp = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT) + 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 @@ -158,7 +173,9 @@ def list_restore_target_names(api_url: str, api_token: str) -> list[str]: headers = {"Authorization": f"Bearer {api_token.strip()}"} try: - resp = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT) + 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 [] @@ -221,7 +238,9 @@ def resolve_route( headers = {"Authorization": f"Bearer {api_token.strip()}"} try: - resp = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT) + 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 @@ -313,7 +332,9 @@ def report_instance_config( payload["instance_key"] = key try: - resp = requests.post(url, json=payload, headers=headers, timeout=DEFAULT_TIMEOUT) + 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 @@ -360,7 +381,9 @@ def report_job_result( } try: - resp = requests.post(url, json=payload, headers=headers, timeout=DEFAULT_TIMEOUT) + 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 @@ -390,7 +413,9 @@ def test_connection(api_url: str, api_token: str) -> tuple[bool, Optional[str]]: 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) + 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}" diff --git a/app/sql/sql_manager.py b/app/sql/sql_manager.py index 0774087..baaf86f 100644 --- a/app/sql/sql_manager.py +++ b/app/sql/sql_manager.py @@ -66,6 +66,93 @@ class SQLServerManager: return ";".join(parts) + @staticmethod + def _drain_cursor(cursor) -> None: + """Consume todos los result sets de comandos largos (RESTORE, etc.).""" + while True: + try: + if cursor.description: + cursor.fetchall() + except Exception: + pass + if not cursor.nextset(): + break + + def wait_for_database_state( + self, + db_name: str, + target_state: str = "ONLINE", + timeout_seconds: int = 300, + poll_seconds: float = 2.0, + ) -> Optional[str]: + """Espera hasta que la BD alcance target_state o agote el timeout.""" + deadline = time.time() + timeout_seconds + last_state: Optional[str] = None + while time.time() < deadline: + last_state = self.get_database_state(db_name) + if last_state == target_state: + return last_state + if last_state is None and target_state == "ONLINE": + # Puede tardar en aparecer en sys.databases al inicio del RESTORE. + pass + time.sleep(poll_seconds) + return last_state + + def get_database_state(self, db_name: str) -> Optional[str]: + """Devuelve state_desc de sys.databases o None si no existe.""" + try: + conn_str = self.get_connection_string() + conn = pyodbc.connect(conn_str, timeout=10) + cursor = conn.cursor() + cursor.execute( + "SELECT state_desc FROM sys.databases WHERE name = ?", + (db_name,), + ) + row = cursor.fetchone() + conn.close() + return str(row[0]) if row else None + except Exception as e: + app_logger.error(f"Error consultando estado de DB [{db_name}]: {e}") + return None + + def _prepare_database_for_restore(self, cursor, db_name: str) -> None: + """ + Limpia una BD atascada en RESTORING u offline antes de un RESTORE nuevo. + """ + state = self.get_database_state(db_name) + if not state: + return + + if state == "RESTORING": + app_logger.warning( + f"BD [{db_name}] en RESTORING; intentando WITH RECOVERY..." + ) + try: + cursor.execute(f"RESTORE DATABASE [{db_name}] WITH RECOVERY") + self._drain_cursor(cursor) + state = self.get_database_state(db_name) + except Exception as e: + app_logger.warning(f"RECOVERY falló para [{db_name}]: {e}") + + if state == "RESTORING": + app_logger.warning( + f"BD [{db_name}] sigue en RESTORING; eliminando con DROP DATABASE..." + ) + cursor.execute(f"DROP DATABASE [{db_name}]") + self._drain_cursor(cursor) + return + + if state == "ONLINE": + try: + cursor.execute( + f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE" + ) + app_logger.info(f"BD [{db_name}] configurada en SINGLE_USER") + except Exception as e: + app_logger.warning( + f"No se pudo configurar SINGLE_USER en [{db_name}]: {e}" + ) + def test_connection(self) -> Tuple[bool, Optional[str]]: """ Prueba la conexión a SQL Server. @@ -199,40 +286,62 @@ class SQLServerManager: app_logger.info(f"Ejecutando RESTORE DATABASE [{db_name}]...") output_lines = [] - try: - # 1. Single user (puede no existir la DB en una primera restauración) - try: - cursor.execute( - f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE" - ) - output_lines.append("Base de datos configurada en modo SINGLE_USER") - except Exception as e: - app_logger.warning( - f"No se pudo configurar SINGLE_USER (puede no existir la DB): {e}" - ) + restore_error: Optional[str] = None - # 2. RESTORE + try: + # 1. Limpiar BD atascada o poner SINGLE_USER si ya existe ONLINE + self._prepare_database_for_restore(cursor, db_name) + + # 2. RESTORE (RECOVERY es el default; drenar result sets hasta completar) cursor.execute(restore_query) - output_lines.append("RESTORE DATABASE completado") - finally: - # 3. Volver SIEMPRE a MULTI_USER, incluso si el RESTORE falló, para - # no dejar la BD inaccesible en un servidor remoto compartido (G7). - try: - cursor.execute(f"ALTER DATABASE [{db_name}] SET MULTI_USER") - output_lines.append("Base de datos configurada en modo MULTI_USER") - except Exception as e: - app_logger.error( - f"No se pudo volver a MULTI_USER la BD [{db_name}] " - f"(¿quedó en estado RESTORING tras un fallo?): {e}. " - "Requiere intervención manual del DBA." + self._drain_cursor(cursor) + output_lines.append("RESTORE DATABASE ejecutado") + + # Esperar a que SQL Server termine (evita borrar el .bak demasiado pronto) + waited_state = self.wait_for_database_state( + db_name, + target_state="ONLINE", + timeout_seconds=timeout_minutes * 60, + ) + if waited_state != "ONLINE": + restore_error = ( + f"BD [{db_name}] no alcanzó ONLINE tras RESTORE " + f"(estado: {waited_state})" ) + app_logger.error(restore_error) + except Exception as e: + restore_error = str(e) + app_logger.error(f"RESTORE DATABASE falló para [{db_name}]: {e}") + finally: + # 3. MULTI_USER solo si la BD quedó ONLINE (G7) + final_state = self.get_database_state(db_name) + if final_state == "ONLINE": + try: + cursor.execute(f"ALTER DATABASE [{db_name}] SET MULTI_USER") + output_lines.append("Base de datos configurada en modo MULTI_USER") + except Exception as e: + restore_error = restore_error or ( + f"No se pudo volver a MULTI_USER en [{db_name}]: {e}" + ) + app_logger.error(restore_error) + elif final_state: + msg = ( + f"BD [{db_name}] quedó en estado {final_state} tras RESTORE" + ) + restore_error = restore_error or msg + app_logger.error(msg) elapsed = time.time() - start_time - output_lines.append(f"Restauración completada en {elapsed:.2f}s") + output_lines.append(f"Restauración finalizada en {elapsed:.2f}s") output = "\n".join(output_lines) app_logger.info(output) + if restore_error or self.get_database_state(db_name) != "ONLINE": + state = self.get_database_state(db_name) + err = restore_error or f"BD [{db_name}] no quedó ONLINE (estado: {state})" + return False, output, err + return True, output, None except Exception as e: From 034a5ca5bb04a84a10054565578a5f4ff1649ae2 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 30 Jun 2026 11:29:06 -0600 Subject: [PATCH 3/3] feature/integracion-cpanel-asrecovery --- .gitignore | 21 +- QUICKSTART.md | 8 +- README.md | 31 +- app/config/__init__.py | 36 ++ app/config/autostart.py | 88 ++++ app/config/bootstrap.py | 83 ++++ app/config/env_loader.py | 138 ++++++ app/config/odbc_setup.py | 71 +++ app/constants.py | 106 +++-- app/engine/engine.py | 10 +- app/engine/restore_worker.py | 14 +- app/extract/seven_zip.py | 124 +++-- app/sql/sql_manager.py | 27 +- app/ui/config_tab.py | 8 + app/ui/dashboard_tab.py | 62 ++- app/ui/main_window.py | 226 +++++---- app/ui/tray_assets.py | 55 +++ app/utils/crypto.py | 93 ++-- build-linux.log | 499 ++++++++++++++++++++ build-windows.log | 326 +++++++++++++ build.ps1 | 113 ++--- build.sh | 43 ++ packaging/CloudRestoreAS.spec | 97 ++++ packaging/LEEME.txt | 18 + packaging/bundled-versions.json | 9 + packaging/scripts/build-all.sh | 34 ++ packaging/scripts/docker-build-linux.sh | 19 + packaging/scripts/download-bundled-deps.ps1 | 93 ++++ packaging/scripts/download-bundled-deps.sh | 58 +++ packaging/scripts/package-release.sh | 27 ++ packaging/templates/env.default | 18 + requirements-windows.txt | 2 + requirements.txt | 16 +- runner.py | 84 +++- tests/test_bootstrap.py | 79 ++++ 35 files changed, 2416 insertions(+), 320 deletions(-) create mode 100644 app/config/__init__.py create mode 100644 app/config/autostart.py create mode 100644 app/config/bootstrap.py create mode 100644 app/config/env_loader.py create mode 100644 app/config/odbc_setup.py create mode 100644 app/ui/tray_assets.py create mode 100644 build-linux.log create mode 100644 build-windows.log create mode 100755 build.sh create mode 100644 packaging/CloudRestoreAS.spec create mode 100644 packaging/LEEME.txt create mode 100644 packaging/bundled-versions.json create mode 100644 packaging/scripts/build-all.sh create mode 100755 packaging/scripts/docker-build-linux.sh create mode 100644 packaging/scripts/download-bundled-deps.ps1 create mode 100755 packaging/scripts/download-bundled-deps.sh create mode 100644 packaging/scripts/package-release.sh create mode 100644 packaging/templates/env.default create mode 100644 requirements-windows.txt create mode 100644 tests/test_bootstrap.py diff --git a/.gitignore b/.gitignore index a11e06a..72c0961 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ MANIFEST # Virtual Environment venv/ +venv-windows/ +venv-linux/ ENV/ env/ @@ -35,16 +37,25 @@ env/ *.swo *~ -# Logs +# Logs y datos locales logs/*.log logs/*.log.* - -# Database data/*.db data/*.db-journal +config/.env +config/data/ +config/logs/ +config/odbc/ +config/7zip/ +config/.bootstrap_ok +config/.autostart_registered -# PyInstaller -*.spec +# PyInstaller / empaquetado +packaging/bundled/ +dist/installers/ + +# Variables de entorno locales +.env # OS .DS_Store diff --git a/QUICKSTART.md b/QUICKSTART.md index 0f5d187..84cfd47 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -126,10 +126,10 @@ C:\CloudRestore\ ## Para Ejecutar 24/7 -1. Inicia la aplicación -2. Menú Motor → Iniciar -3. Minimiza (se va a la bandeja del sistema) -4. La app continuará monitoreando en segundo plano +1. Inicia la aplicación (el motor arranca solo si `CLOUDRESTORE_AUTO_START=true`) +2. Cierra la ventana con **X** — se minimiza a la bandeja del sistema +3. La app continuará monitoreando en segundo plano +4. Para salir por completo: bandeja → **Salir** o menú **Archivo → Salir** --- diff --git a/README.md b/README.md index 0bbb864..9c9bf1b 100644 --- a/README.md +++ b/README.md @@ -73,19 +73,28 @@ CloudRestoreAS es una aplicación Windows de escritorio desarrollada en Python 3 python runner.py ``` -### Opción 2: Ejecutable Empaquetado +### Opción 2: Ejecutable portable (recomendado para producción) -1. **Generar el ejecutable** - ```powershell - .\build.ps1 - ``` - - Esto creará `CloudRestoreAS.exe` en la carpeta `dist\` +**Windows** — generar: +```powershell +.\build.ps1 +``` +Salida: `dist\CloudRestoreAS.exe` -2. **Distribuir** - - Copiar `CloudRestoreAS.exe` a la ubicación deseada - - Crear carpetas `data` y `logs` en el mismo directorio - - El ejecutable es portable (no requiere instalación) +**Linux** — generar: +```bash +./build.sh +``` +Salida: `dist/CloudRestoreAS` + +**Desplegar en cada equipo** (sin Python, sin instalador): +1. Copiar **solo** el ejecutable a una carpeta. +2. Ejecutarlo (doble clic o `./CloudRestoreAS`). +3. Se crean automáticamente: `config/`, `Entrada/`, `Procesados/`, `Fallados/`, `Temp/`. +4. Editar `config/.env` (URL, token e instancia del panel). +5. Reiniciar la aplicación. + +Ver `packaging/LEEME.txt` para instrucciones resumidas. ## ⚙️ Configuración Inicial diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..c1e8c59 --- /dev/null +++ b/app/config/__init__.py @@ -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", +] diff --git a/app/config/autostart.py b/app/config/autostart.py new file mode 100644 index 0000000..b342068 --- /dev/null +++ b/app/config/autostart.py @@ -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 diff --git a/app/config/bootstrap.py b/app/config/bootstrap.py new file mode 100644 index 0000000..c2e9e1d --- /dev/null +++ b/app/config/bootstrap.py @@ -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") diff --git a/app/config/env_loader.py b/app/config/env_loader.py new file mode 100644 index 0000000..e454feb --- /dev/null +++ b/app/config/env_loader.py @@ -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= +""" diff --git a/app/config/odbc_setup.py b/app/config/odbc_setup.py new file mode 100644 index 0000000..55b702e --- /dev/null +++ b/app/config/odbc_setup.py @@ -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}") diff --git a/app/constants.py b/app/constants.py index ab05e33..5f87fa1 100644 --- a/app/constants.py +++ b/app/constants.py @@ -1,13 +1,51 @@ """Constantes globales de la aplicación.""" +import sys from pathlib import Path -# Rutas base -APP_DIR = Path(__file__).parent.parent -DATA_DIR = APP_DIR / "data" -LOGS_DIR = APP_DIR / "logs" -# Base de datos +def _resolve_app_dir() -> Path: + """Directorio donde vive el ejecutable (persistente).""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent.parent + + +def _resolve_bundle_dir() -> Path: + """Recursos embebidos en el binario PyInstaller onefile.""" + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) + platform = "windows" if sys.platform == "win32" else "linux" + return _resolve_app_dir() / "packaging" / "bundled" / platform + + +APP_DIR = _resolve_app_dir() +CONFIG_DIR = APP_DIR / "config" +ENV_PATH = CONFIG_DIR / ".env" +DATA_DIR = CONFIG_DIR / "data" +LOGS_DIR = CONFIG_DIR / "logs" +ODBC_DIR = CONFIG_DIR / "odbc" +SEVEN_ZIP_DIR = CONFIG_DIR / "7zip" + +IS_WINDOWS = sys.platform == "win32" +BUNDLE_DIR = _resolve_bundle_dir() +BUNDLED_SOURCE_7ZIP = BUNDLE_DIR / "bundled" / "7zip" +BUNDLED_SOURCE_ODBC = BUNDLE_DIR / "bundled" / "odbc" +def _default_7zip_name() -> str: + if IS_WINDOWS: + return "7z.exe" + return "7zz" + + +BUNDLED_7ZIP_EXE = SEVEN_ZIP_DIR / _default_7zip_name() +BUNDLED_7ZIP_FALLBACK = SEVEN_ZIP_DIR / ("7za.exe" if IS_WINDOWS else "7zz") + +# Carpetas de trabajo junto al ejecutable +DIR_ENTRADA = APP_DIR / "Entrada" +DIR_PROCESADOS = APP_DIR / "Procesados" +DIR_FALLADOS = APP_DIR / "Fallados" +DIR_TEMP = APP_DIR / "Temp" + DB_PATH = DATA_DIR / "app.db" # Estados de jobs @@ -21,7 +59,7 @@ class JobStatus: FAILED_RESTART = "failed_restart" CANCELLED = "cancelled" -# Niveles de log + class LogLevel: DEBUG = "DEBUG" INFO = "INFO" @@ -29,7 +67,7 @@ class LogLevel: ERROR = "ERROR" CRITICAL = "CRITICAL" -# Tipos de paso de job + class StepType: STABILITY_CHECK = "stability_check" NODE_MAPPING = "node_mapping" @@ -39,57 +77,63 @@ class StepType: FILELIST = "filelist" RESTORE = "restore" CLEANUP = "cleanup" - SFTP_COPY = "sftp_copy" # Transferencia del .bak al servidor SQL externo (SFTP/SSH) - FORWARD_ZIP = "forward_zip" # Reenvío del ZIP al input_folder del servidor destino + SFTP_COPY = "sftp_copy" + FORWARD_ZIP = "forward_zip" -# Configuración por defecto -DEFAULT_CONFIG = { - "paths": { - "input_folder": "", - "processed_folder": "", - "failed_folder": "", - "extract_folder": "", + +def default_seven_zip_path() -> str: + for candidate in (BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK): + if candidate.exists(): + return str(candidate) + return "" + + +def default_work_paths() -> dict[str, str]: + return { + "input_folder": str(DIR_ENTRADA), + "processed_folder": str(DIR_PROCESADOS), + "failed_folder": str(DIR_FALLADOS), + "extract_folder": str(DIR_TEMP), "data_sql_folder": "", - "seven_zip_exe": "" - }, + "seven_zip_exe": default_seven_zip_path(), + } + + +DEFAULT_CONFIG = { + "paths": default_work_paths(), "sql": { "server": "localhost", - "use_windows_auth": True, + "use_windows_auth": IS_WINDOWS, "username": "", - "password_encrypted": "" + "password_encrypted": "", }, "concurrency": { "extract_workers": 1, - "restore_workers": 1 + "restore_workers": 1, }, "stability": { "check_enabled": True, "check_interval_seconds": 5, "stable_duration_seconds": 10, - "use_ready_marker": False + "use_ready_marker": False, }, "timeouts": { "extract_minutes": 30, - "restore_minutes": 60 + "restore_minutes": 60, }, "retries": { "max_attempts": 3, - "retry_delay_seconds": 60 + "retry_delay_seconds": 60, }, "features": { "dry_run_mode": False, "auto_scan_enabled": True, - "scan_interval_seconds": 30 + "scan_interval_seconds": 30, }, - # Integración con PANEL_BASES_ANEXO24. Si api_url está vacío, CloudRestoreAS - # opera en modo local usando la sección "sql" (retrocompatibilidad). "panel": { "api_url": "", "api_token": "", - # Nombre del restore_target en el panel (identidad de este CRA). - # Con panel configurado: enrutamiento automático restore_local | forward. "instance_key": "", - # False en dev: el panel local usa HTTPS con certificado propio. "verify_ssl": False, - } + }, } diff --git a/app/engine/engine.py b/app/engine/engine.py index d3c7479..ba656e4 100644 --- a/app/engine/engine.py +++ b/app/engine/engine.py @@ -12,6 +12,7 @@ from .. import __version__ from ..db.job_repository import JobRepository from ..db.event_repository import EventRepository from ..db.config_repository import ConfigRepository +from ..config.env_loader import apply_env_overrides, load_env_file from ..constants import JobStatus, DEFAULT_CONFIG from ..panel import panel_client from ..utils.logger import app_logger @@ -50,14 +51,15 @@ class RestoreEngine(QObject): app_logger.info("RestoreEngine inicializado") def _load_config(self) -> dict: - """Carga la configuración desde la base de datos.""" + """Carga la configuración desde la base de datos y variables de entorno.""" + load_env_file() config = ConfigRepository.get("app_config", DEFAULT_CONFIG.copy()) - - # Asegurar que tiene todas las claves + for key, value in DEFAULT_CONFIG.items(): if key not in config: config[key] = value - + + config = apply_env_overrides(config) self.signals.config_loaded.emit(config) return config diff --git a/app/engine/restore_worker.py b/app/engine/restore_worker.py index b705805..71daeae 100644 --- a/app/engine/restore_worker.py +++ b/app/engine/restore_worker.py @@ -335,11 +335,17 @@ class RestoreWorker(QRunnable): stderr=stderr[:1000] if stderr else None ) - # Buscar archivo .bak + # Buscar archivo .bak. El backup puede venir con terminaciones extra + # (p.ej. ".KNOWNWORLD") tanto en el ZIP como en el propio .bak; se + # normaliza a ".bak". El nodo es siempre el primer segmento del + # nombre del archivo de origen. locate_step_id = JobStepRepository.create(self.job_id, StepType.LOCATE_BAK) - - self._bak_path = SevenZipExtractor.find_bak_file(self._extract_dir) - + + node_base = Path(job.source_name).name.split(".")[0].strip() or None + self._bak_path = SevenZipExtractor.find_bak_file( + self._extract_dir, node_name=node_base + ) + if not self._bak_path: raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído") diff --git a/app/extract/seven_zip.py b/app/extract/seven_zip.py index 0d1db2c..29a6ead 100644 --- a/app/extract/seven_zip.py +++ b/app/extract/seven_zip.py @@ -1,9 +1,13 @@ """Gestión de extracción de archivos con 7-Zip.""" +import shutil import subprocess +import sys from pathlib import Path from typing import Optional, Tuple import time + +from ..constants import BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK, IS_WINDOWS from ..utils.logger import app_logger @@ -25,19 +29,30 @@ class SevenZipExtractor: @staticmethod def _auto_detect_7zip() -> Optional[str]: - """Auto-detecta la ubicación de 7z.exe.""" - possible_paths = [ - r"C:\Program Files\7-Zip\7z.exe", - r"D:\Program Files\7-Zip\7z.exe", - r"C:\Program Files (x86)\7-Zip\7z.exe", - r"D:\Program Files (x86)\7-Zip\7z.exe" - ] - - for path in possible_paths: - if Path(path).exists(): - app_logger.info(f"7-Zip auto-detectado en: {path}") - return path - + """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 = [ + r"C:\Program Files\7-Zip\7z.exe", + r"C:\Program Files (x86)\7-Zip\7z.exe", + ] + else: + possible_paths = [] + for name in ("7z", "7zz", "7za"): + found = shutil.which(name) + if found: + possible_paths.append(found) + + for path in possible_paths: + if Path(path).exists(): + app_logger.info(f"7-Zip detectado en: {path}") + return path + return None def extract( @@ -134,35 +149,78 @@ class SevenZipExtractor: return result.returncode, result.stdout, result.stderr @staticmethod - def find_bak_file(extract_dir: str) -> Optional[str]: + def find_bak_file(extract_dir: str, node_name: Optional[str] = None) -> Optional[str]: """ - Busca el archivo .bak extraído en el directorio. - + Busca el backup extraído y, si se indica node_name, lo normaliza a + '.bak'. + + Tolera terminaciones extra en el nombre (el backup puede venir como + 'nodo.KNOWNWORLD.bak', 'nodo.bak.unknownworld' o incluso sin extensión + '.bak'). Estrategia de búsqueda en orden: + 1. archivos que terminan exactamente en '.bak' + 2. si no hay, archivos cuyo nombre contiene '.bak' en cualquier parte + 3. si no hay, el archivo más grande de la extracción (el backup suele serlo) + Args: extract_dir: Directorio donde se extrajo - + node_name: Nombre del nodo; si se indica, el backup se renombra a + '.bak' + Returns: - Ruta al archivo .bak o None si no se encuentra - - Raises: - ValueError: Si hay múltiples archivos .bak + Ruta al archivo .bak (ya renombrado si node_name) o None si no hay archivos. """ extract_path = Path(extract_dir) - bak_files = list(extract_path.rglob("*.bak")) - - if not bak_files: - app_logger.error(f"No se encontró archivo .bak en {extract_dir}") + all_files = [p for p in extract_path.rglob("*") if p.is_file()] + + if not all_files: + app_logger.error(f"No se encontró ningún archivo en {extract_dir}") return None - - if len(bak_files) > 1: - # Si hay múltiples, tomar el más reciente + + bak_files = [p for p in all_files if p.suffix.lower() == ".bak"] + if not bak_files: + bak_files = [p for p in all_files if ".bak" in p.name.lower()] + if not bak_files: + largest = max(all_files, key=lambda p: p.stat().st_size) app_logger.warning( - f"Múltiples archivos .bak encontrados ({len(bak_files)}), " - "seleccionando el más reciente" + "No se encontró archivo con extensión .bak; usando el archivo más " + f"grande de la extracción: {largest.name}" ) - bak_files.sort(key=lambda p: p.stat().st_mtime, reverse=True) - - bak_path = str(bak_files[0]) + 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" + ) + bak_files.sort(key=lambda p: p.stat().st_mtime, reverse=True) + + bak_file = bak_files[0] + + # Normalizar el nombre a '.bak'. + if node_name: + target = bak_file.parent / f"{node_name}.bak" + if bak_file.name != target.name: + try: + if target.exists() and not bak_file.samefile(target): + target.unlink() + bak_file = bak_file.rename(target) + app_logger.info(f"Backup normalizado a: {bak_file.name}") + except OSError as e: + # No es fatal para el RESTORE: se continúa con el nombre original. + app_logger.error( + f"No se pudo renombrar el backup a '{target.name}': {e}" + ) + + bak_path = str(bak_file) app_logger.info(f"Archivo .bak encontrado: {bak_path}") return bak_path diff --git a/app/sql/sql_manager.py b/app/sql/sql_manager.py index baaf86f..5d4bb5d 100644 --- a/app/sql/sql_manager.py +++ b/app/sql/sql_manager.py @@ -4,8 +4,28 @@ import pyodbc from typing import Optional, Tuple, List import time from dataclasses import dataclass + from ..utils.logger import app_logger +_ODBC_DRIVER_CANDIDATES = ( + "ODBC Driver 18 for SQL Server", + "ODBC Driver 17 for SQL Server", + "ODBC Driver 13 for SQL Server", + "FreeTDS", +) + + +def resolve_odbc_driver() -> str: + """Devuelve el primer driver ODBC de SQL Server disponible.""" + installed = set(pyodbc.drivers()) + for name in _ODBC_DRIVER_CANDIDATES: + if name in installed: + return name + raise RuntimeError( + "No se encontró driver ODBC para SQL Server. " + f"Instalados: {', '.join(pyodbc.drivers()) or 'ninguno'}" + ) + @dataclass class LogicalFile: @@ -50,10 +70,13 @@ class SQLServerManager: Returns: Cadena de conexión ODBC """ + driver = resolve_odbc_driver() parts = [ - f"DRIVER={{ODBC Driver 17 for SQL Server}}", - f"SERVER={self.server}" + f"DRIVER={{{driver}}}", + f"SERVER={self.server}", ] + if "18" in driver: + parts.append("TrustServerCertificate=yes") if database: parts.append(f"DATABASE={database}") diff --git a/app/ui/config_tab.py b/app/ui/config_tab.py index ae39644..b44c8e8 100644 --- a/app/ui/config_tab.py +++ b/app/ui/config_tab.py @@ -1,5 +1,7 @@ """Tab de configuración.""" +import sys + from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QLineEdit, QPushButton, QSpinBox, @@ -104,6 +106,12 @@ class ConfigTab(QWidget): self.sql_windows_auth_checkbox.setChecked(True) self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed) sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox) + 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.setEnabled(False) diff --git a/app/ui/dashboard_tab.py b/app/ui/dashboard_tab.py index db48271..a7a326d 100644 --- a/app/ui/dashboard_tab.py +++ b/app/ui/dashboard_tab.py @@ -32,6 +32,14 @@ class DashboardTab(QWidget): title_font.setBold(True) title.setFont(title_font) layout.addWidget(title) + + self.panel_warning_label = QLabel("") + self.panel_warning_label.setWordWrap(True) + self.panel_warning_label.setStyleSheet( + "background-color: #fff3cd; color: #856404; padding: 8px; border-radius: 4px;" + ) + self.panel_warning_label.hide() + layout.addWidget(self.panel_warning_label) # Grid de estadísticas stats_group = QGroupBox("Estadísticas de Jobs") @@ -94,17 +102,29 @@ class DashboardTab(QWidget): times_group.setLayout(times_layout) layout.addWidget(times_group) + self.engine_status_badge = QLabel("Detenido") + badge_font = QFont() + badge_font.setPointSize(12) + badge_font.setBold(True) + self.engine_status_badge.setFont(badge_font) + self.engine_status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.engine_status_badge.setStyleSheet( + "background-color: #e9ecef; color: #495057; padding: 6px 14px; " + "border-radius: 4px;" + ) + layout.addWidget(self.engine_status_badge) + # Estado del motor motor_group = QGroupBox("Estado del Motor") motor_layout = QHBoxLayout() - + self.motor_status_label = QLabel("Detenido") motor_status_font = QFont() motor_status_font.setPointSize(14) motor_status_font.setBold(True) self.motor_status_label.setFont(motor_status_font) self.motor_status_label.setStyleSheet("color: red;") - + motor_layout.addWidget(QLabel("Estado:")) motor_layout.addWidget(self.motor_status_label) motor_layout.addStretch() @@ -136,6 +156,7 @@ class DashboardTab(QWidget): def _load_initial_stats(self): """Carga las estadísticas iniciales.""" + self.update_motor_status() stats = self.engine.get_stats() self.update_stats(stats) self._refresh_recent_jobs() @@ -193,3 +214,40 @@ class DashboardTab(QWidget): self.recent_table.setItem(i, 4, status_item) self.recent_table.resizeColumnsToContents() + + def update_motor_status(self) -> None: + """Actualiza badge y etiqueta según el estado actual del motor.""" + if not self.engine.is_running(): + text = "Detenido" + badge_style = ( + "background-color: #e9ecef; color: #495057; padding: 6px 14px; " + "border-radius: 4px;" + ) + label_style = "color: red;" + elif self.engine.is_paused(): + text = "Pausado" + badge_style = ( + "background-color: #fff3cd; color: #856404; padding: 6px 14px; " + "border-radius: 4px;" + ) + label_style = "color: orange;" + else: + text = "Activo" + badge_style = ( + "background-color: #d4edda; color: #155724; padding: 6px 14px; " + "border-radius: 4px;" + ) + label_style = "color: green;" + + self.engine_status_badge.setText(f"Motor: {text}") + self.engine_status_badge.setStyleSheet(badge_style) + self.motor_status_label.setText(text) + self.motor_status_label.setStyleSheet(label_style) + + def set_panel_warning(self, message: str) -> None: + """Muestra aviso cuando falta configuración del servicio de bases en config/.env.""" + if message: + self.panel_warning_label.setText(message) + self.panel_warning_label.show() + else: + self.panel_warning_label.hide() diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 9dff81a..d5db679 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -2,181 +2,207 @@ from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QTabWidget, - QSystemTrayIcon, QMenu + QSystemTrayIcon, QMenu, QMessageBox, QApplication, ) from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtGui import QIcon, QCloseEvent, QAction +from PySide6.QtGui import QCloseEvent, QAction from .dashboard_tab import DashboardTab from .jobs_tab import JobsTab from .nodes_tab import NodesTab from .config_tab import ConfigTab from .logs_tab import LogsTab +from .tray_assets import load_tray_icon from ..engine.engine import RestoreEngine from ..utils.logger import app_logger class MainWindow(QMainWindow): """Ventana principal de la aplicación.""" - - # Señal para minimizar a tray + minimize_to_tray = Signal() - - def __init__(self): + + def __init__( + self, + minimized: bool = False, + start_engine: bool = False, + panel_configured: bool = True, + ): """Inicializa la ventana principal.""" super().__init__() - - # Motor de restauración + + self._start_on_load = start_engine + self._minimized_on_load = minimized + self._panel_configured = panel_configured + self._force_quit = False + self._tray_hint_shown = False + self.engine = RestoreEngine() - - # Configurar ventana + self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server") self.setMinimumSize(1200, 800) - - # System tray - self.tray_icon: QSystemTrayIcon = None + + self.tray_icon: QSystemTrayIcon | None = None self._setup_tray() - - # UI + self._setup_ui() - - # Conectar señales del motor self._connect_engine_signals() - - # Timer para actualizar stats + self.stats_timer = QTimer() self.stats_timer.timeout.connect(self._update_stats) - self.stats_timer.start(5000) # Cada 5 segundos - + self.stats_timer.start(5000) + + if not self._panel_configured: + self.dashboard_tab.set_panel_warning( + "Configure la conexión al servicio de bases en config/.env " + "(CLOUDRESTORE_PANEL_API_URL, TOKEN e INSTANCE_KEY)." + ) + + if self._start_on_load: + QTimer.singleShot(500, self._start_engine) + + 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") - + def _setup_ui(self): """Configura la interfaz de usuario.""" - # Widget central central_widget = QWidget() self.setCentralWidget(central_widget) - - # Layout principal + layout = QVBoxLayout(central_widget) layout.setContentsMargins(10, 10, 10, 10) - - # Tabs + self.tabs = QTabWidget() - - # Crear tabs + self.dashboard_tab = DashboardTab(self.engine) self.jobs_tab = JobsTab(self.engine) self.nodes_tab = NodesTab(self.engine) self.config_tab = ConfigTab(self.engine) self.logs_tab = LogsTab(self.engine) - - # Agregar tabs + self.tabs.addTab(self.dashboard_tab, "📊 Dashboard") self.tabs.addTab(self.jobs_tab, "📋 Jobs") self.tabs.addTab(self.nodes_tab, "🔗 Nodos") self.tabs.addTab(self.config_tab, "⚙️ Configuración") self.tabs.addTab(self.logs_tab, "📝 Logs") - + layout.addWidget(self.tabs) - - # Menu bar self._setup_menu() - + def _setup_menu(self): """Configura el menú.""" menubar = self.menuBar() - - # Menú Motor + + file_menu = menubar.addMenu("Archivo") + + quit_action = QAction("Salir", self) + quit_action.triggered.connect(self._quit_application) + file_menu.addAction(quit_action) + motor_menu = menubar.addMenu("Motor") - + self.start_action = QAction("▶️ Iniciar", self) self.start_action.triggered.connect(self._start_engine) motor_menu.addAction(self.start_action) - + self.pause_action = QAction("⏸️ Pausar", self) self.pause_action.triggered.connect(self._pause_engine) self.pause_action.setEnabled(False) motor_menu.addAction(self.pause_action) - + self.resume_action = QAction("▶️ Continuar", self) self.resume_action.triggered.connect(self._resume_engine) self.resume_action.setEnabled(False) motor_menu.addAction(self.resume_action) - + self.stop_action = QAction("⏹️ Detener", self) self.stop_action.triggered.connect(self._stop_engine) self.stop_action.setEnabled(False) motor_menu.addAction(self.stop_action) - + motor_menu.addSeparator() - + self.scan_action = QAction("🔍 Escanear Ahora", self) self.scan_action.triggered.connect(self._scan_now) motor_menu.addAction(self.scan_action) - - # Menú Ayuda + help_menu = menubar.addMenu("Ayuda") - + about_action = QAction("Acerca de", self) about_action.triggered.connect(self._show_about) help_menu.addAction(about_action) - + def _setup_tray(self): """Configura el icono de bandeja del sistema.""" - # Crear icono (usar un icono por defecto o crear uno simple) + if not QSystemTrayIcon.isSystemTrayAvailable(): + app_logger.warning("Bandeja del sistema no disponible en este entorno") + return + self.tray_icon = QSystemTrayIcon(self) + self.tray_icon.setIcon(load_tray_icon()) self.tray_icon.setToolTip("CloudRestoreAS") - - # Menú del tray + tray_menu = QMenu() - + show_action = QAction("Mostrar", self) show_action.triggered.connect(self._show_window) tray_menu.addAction(show_action) - + hide_action = QAction("Ocultar", self) hide_action.triggered.connect(self.hide) tray_menu.addAction(hide_action) - + tray_menu.addSeparator() - + pause_tray_action = QAction("Pausar", self) pause_tray_action.triggered.connect(self._pause_engine) tray_menu.addAction(pause_tray_action) - + resume_tray_action = QAction("Continuar", self) resume_tray_action.triggered.connect(self._resume_engine) tray_menu.addAction(resume_tray_action) - + tray_menu.addSeparator() - - quit_action = QAction("Salir", self) - quit_action.triggered.connect(self._quit_application) - tray_menu.addAction(quit_action) - + + quit_tray_action = QAction("Salir", self) + quit_tray_action.triggered.connect(self._quit_application) + tray_menu.addAction(quit_tray_action) + self.tray_icon.setContextMenu(tray_menu) - - # Doble clic en tray para mostrar/ocultar self.tray_icon.activated.connect(self._on_tray_activated) - - # Mostrar tray self.tray_icon.show() - + def _connect_engine_signals(self): """Conecta las señales del motor.""" self.engine.signals.job_created.connect(self._on_job_created) self.engine.signals.stats_updated.connect(self._on_stats_updated) self.engine.signals.config_loaded.connect(self._on_config_loaded) - + def closeEvent(self, event: QCloseEvent): - """Maneja el evento de cierre de ventana (minimizar a tray).""" - if self.tray_icon.isVisible(): + """Minimiza a bandeja al cerrar la ventana (no sale de la aplicación).""" + if self._force_quit: + event.accept() + return + + if self.tray_icon and self.tray_icon.isVisible(): self.hide() event.ignore() 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: event.accept() - + def _on_tray_activated(self, reason): """Maneja la activación del icono de tray.""" if reason == QSystemTrayIcon.ActivationReason.DoubleClick: @@ -184,35 +210,56 @@ class MainWindow(QMainWindow): self.hide() else: self._show_window() - + def _show_window(self): """Muestra la ventana.""" self.show() self.activateWindow() self.raise_() - + + def _motor_status_text(self) -> str: + if not self.engine.is_running(): + return "Detenido" + if self.engine.is_paused(): + return "Pausado" + return "Activo" + + def _update_tray_status(self): + """Actualiza tooltip del icono de bandeja según estado del motor.""" + if not self.tray_icon: + return + status = self._motor_status_text() + self.tray_icon.setToolTip(f"CloudRestoreAS — Motor: {status}") + def _start_engine(self): """Inicia el motor.""" self.engine.start() self.start_action.setEnabled(False) self.pause_action.setEnabled(True) self.stop_action.setEnabled(True) + self.resume_action.setEnabled(False) + self._update_tray_status() + self.dashboard_tab.update_motor_status() app_logger.info("Motor iniciado desde UI") - + def _pause_engine(self): """Pausa el motor.""" self.engine.pause() self.pause_action.setEnabled(False) self.resume_action.setEnabled(True) + self._update_tray_status() + self.dashboard_tab.update_motor_status() app_logger.info("Motor pausado desde UI") - + def _resume_engine(self): """Reanuda el motor.""" self.engine.resume() self.resume_action.setEnabled(False) self.pause_action.setEnabled(True) + self._update_tray_status() + self.dashboard_tab.update_motor_status() app_logger.info("Motor reanudado desde UI") - + def _stop_engine(self): """Detiene el motor.""" self.engine.stop() @@ -220,48 +267,53 @@ class MainWindow(QMainWindow): self.pause_action.setEnabled(False) self.resume_action.setEnabled(False) self.start_action.setEnabled(True) + self._update_tray_status() + self.dashboard_tab.update_motor_status() app_logger.info("Motor detenido desde UI") - + def _scan_now(self): """Fuerza un escaneo manual.""" self.engine.scan_now() app_logger.info("Escaneo manual solicitado desde UI") - + def _update_stats(self): """Actualiza las estadísticas.""" + self._update_tray_status() + self.dashboard_tab.update_motor_status() if self.engine.is_running(): stats = self.engine.get_stats() self.dashboard_tab.update_stats(stats) - + def _on_job_created(self, job_id: str): """Maneja la creación de un nuevo job.""" self.jobs_tab.refresh_jobs() self._update_stats() - + def _on_stats_updated(self, stats: dict): """Maneja la actualización de estadísticas.""" self.dashboard_tab.update_stats(stats) - + def _on_config_loaded(self, config: dict): """Maneja la carga de configuración.""" self.config_tab.load_config(config) - + def _show_about(self): """Muestra el diálogo Acerca de.""" - from PySide6.QtWidgets import QMessageBox QMessageBox.about( self, "Acerca de CloudRestoreAS", "CloudRestoreAS v1.0.0\n\n" "Aplicación de restauración automática de bases de datos SQL Server.\n\n" - "© 2026 Aduanasoft" + "Al cerrar la ventana, la aplicación permanece en la bandeja del sistema.\n" + "Use Archivo → Salir o la bandeja → Salir para cerrar por completo.\n\n" + "© 2026 Aduanasoft", ) - + def _quit_application(self): """Sale de la aplicación completamente.""" app_logger.info("Saliendo de la aplicación") + self._force_quit = True self.engine.stop() - self.tray_icon.hide() - self.close() - from PySide6.QtWidgets import QApplication + if self.tray_icon: + self.tray_icon.hide() QApplication.quit() diff --git a/app/ui/tray_assets.py b/app/ui/tray_assets.py new file mode 100644 index 0000000..21acf0f --- /dev/null +++ b/app/ui/tray_assets.py @@ -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() diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 5493132..844adf7 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -1,59 +1,68 @@ -"""Utilidades de cifrado para passwords usando DPAPI en Windows.""" +"""Utilidades de cifrado para passwords (DPAPI en Windows, Fernet en Linux).""" import base64 -import win32crypt +import sys +from pathlib import Path from typing import Optional +from ..constants import DATA_DIR + +_IS_WINDOWS = sys.platform == "win32" +_SECRET_FILE = DATA_DIR / ".secret" + + +def _get_fernet(): + from cryptography.fernet import Fernet + + DATA_DIR.mkdir(parents=True, exist_ok=True) + if not _SECRET_FILE.exists(): + key = Fernet.generate_key() + _SECRET_FILE.write_bytes(key) + try: + _SECRET_FILE.chmod(0o600) + except OSError: + pass + else: + key = _SECRET_FILE.read_bytes() + return Fernet(key) + def encrypt_password(password: str) -> str: - """ - Cifra una contraseña usando DPAPI de Windows. - - Args: - password: Contraseña en texto plano - - Returns: - Contraseña cifrada en base64 - """ + """Cifra una contraseña.""" if not password: return "" - + try: - encrypted_bytes = win32crypt.CryptProtectData( - password.encode('utf-8'), - None, - None, - None, - None, - 0 - ) - return base64.b64encode(encrypted_bytes).decode('ascii') + if _IS_WINDOWS: + import win32crypt + + encrypted_bytes = win32crypt.CryptProtectData( + password.encode("utf-8"), None, None, None, None, 0 + ) + return base64.b64encode(encrypted_bytes).decode("ascii") + + fernet = _get_fernet() + return fernet.encrypt(password.encode("utf-8")).decode("ascii") except Exception as e: - raise RuntimeError(f"Error cifrando contraseña: {e}") + raise RuntimeError(f"Error cifrando contraseña: {e}") from e def decrypt_password(encrypted_password: str) -> str: - """ - Descifra una contraseña usando DPAPI de Windows. - - Args: - encrypted_password: Contraseña cifrada en base64 - - Returns: - Contraseña en texto plano - """ + """Descifra una contraseña.""" if not encrypted_password: return "" - + try: - encrypted_bytes = base64.b64decode(encrypted_password) - decrypted_bytes = win32crypt.CryptUnprotectData( - encrypted_bytes, - None, - None, - None, - 0 - )[1] - return decrypted_bytes.decode('utf-8') + if _IS_WINDOWS: + import win32crypt + + encrypted_bytes = base64.b64decode(encrypted_password) + decrypted_bytes = win32crypt.CryptUnprotectData( + encrypted_bytes, None, None, None, 0 + )[1] + return decrypted_bytes.decode("utf-8") + + fernet = _get_fernet() + return fernet.decrypt(encrypted_password.encode("ascii")).decode("utf-8") except Exception as e: - raise RuntimeError(f"Error descifrando contraseña: {e}") + raise RuntimeError(f"Error descifrando contraseña: {e}") from e diff --git a/build-linux.log b/build-linux.log new file mode 100644 index 0000000..733e06f --- /dev/null +++ b/build-linux.log @@ -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 + 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 + 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 "", line 883, in exec_module + File "", 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 + 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=== diff --git a/build-windows.log b/build-windows.log new file mode 100644 index 0000000..0d62547 --- /dev/null +++ b/build-windows.log @@ -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 "", line 198, in _run_module_as_main + File "", 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 + 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 + 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 "", line 1026, in exec_module + File "", 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 + 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 diff --git a/build.ps1 b/build.ps1 index 65dc2c8..ef48013 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,72 +1,63 @@ -# Script de build para PyInstaller -# Genera un ejecutable único de la aplicación +# Build CloudRestoreAS — PyInstaller onefile portable +$ErrorActionPreference = "Stop" +$Root = $PSScriptRoot Write-Host "===============================================" -ForegroundColor Cyan -Write-Host "CloudRestoreAS - Build Script" -ForegroundColor Cyan +Write-Host "CloudRestoreAS - Build Windows (onefile)" -ForegroundColor Cyan Write-Host "===============================================" -ForegroundColor Cyan -Write-Host "" -# Verificar que PyInstaller esté instalado -Write-Host "Verificando PyInstaller..." -ForegroundColor Yellow -$pyinstallerCheck = pip show pyinstaller 2>$null -if (-not $pyinstallerCheck) { - Write-Host "PyInstaller no está instalado. Instalando..." -ForegroundColor Yellow - pip install pyinstaller +& "$Root\packaging\scripts\download-bundled-deps.ps1" + +# Python de Windows (evitar venv creado desde WSL) +$PythonCandidates = @( + "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe", + "$env:LOCALAPPDATA\Programs\Python\Python312\python.exe", + "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" +) +$Python = $PythonCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $Python) { + throw "No se encontró Python 3.11+ en Windows. Instale desde python.org" } +Write-Host "Python: $Python" -ForegroundColor Gray -# Crear directorio de build -$buildDir = "build" -$distDir = "dist" - -Write-Host "" -Write-Host "Limpiando builds anteriores..." -ForegroundColor Yellow -if (Test-Path $buildDir) { - Remove-Item -Recurse -Force $buildDir -} -if (Test-Path $distDir) { - Remove-Item -Recurse -Force $distDir -} -if (Test-Path "*.spec") { - Remove-Item -Force *.spec +$venvDir = Join-Path $Root "venv-windows" +$venvPython = Join-Path $venvDir "Scripts\python.exe" +if (-not (Test-Path $venvPython)) { + if (Test-Path $venvDir) { Remove-Item -Recurse -Force $venvDir } + & $Python -m venv $venvDir } +& $venvPython -m pip install --upgrade pip -q +& $venvPython -m pip install -r (Join-Path $Root "requirements-windows.txt") -q -Write-Host "" -Write-Host "Ejecutando PyInstaller..." -ForegroundColor Yellow -Write-Host "" - -# Ejecutar PyInstaller -pyinstaller --name CloudRestoreAS ` - --onefile ` - --windowed ` - --icon=NONE ` - --add-data "app;app" ` - --hidden-import PySide6 ` - --hidden-import pyodbc ` - --hidden-import win32crypt ` - --collect-all PySide6 ` - runner.py - -if ($LASTEXITCODE -eq 0) { - Write-Host "" - Write-Host "===============================================" -ForegroundColor Green - Write-Host "Build completado exitosamente!" -ForegroundColor Green - Write-Host "===============================================" -ForegroundColor Green - Write-Host "" - Write-Host "El ejecutable se encuentra en:" -ForegroundColor Cyan - Write-Host " $PWD\dist\CloudRestoreAS.exe" -ForegroundColor White - Write-Host "" - Write-Host "IMPORTANTE:" -ForegroundColor Yellow - Write-Host " - Copia las carpetas 'data' y 'logs' junto al .exe" -ForegroundColor White - Write-Host " - Asegúrate de tener 7-Zip instalado" -ForegroundColor White - Write-Host " - Asegúrate de tener ODBC Driver 17 for SQL Server" -ForegroundColor White - Write-Host "" +$distDir = Join-Path $Root "dist" +$isUncRoot = $Root -like "\\*" +# PyInstaller no puede usar workpath en UNC (WSL); usar carpeta local en Windows +if ($isUncRoot) { + $buildDir = Join-Path $env:LOCALAPPDATA "Temp\cloudrestore-build" } else { - Write-Host "" - Write-Host "===============================================" -ForegroundColor Red - Write-Host "Error durante el build" -ForegroundColor Red - Write-Host "===============================================" -ForegroundColor Red - Write-Host "" + $buildDir = Join-Path $Root "build" +} +if (Test-Path $buildDir) { + Remove-Item -Recurse -Force $buildDir -ErrorAction SilentlyContinue +} +New-Item -ItemType Directory -Force -Path $distDir, $buildDir | Out-Null +if (Test-Path (Join-Path $distDir "CloudRestoreAS.exe")) { + Remove-Item -Force (Join-Path $distDir "CloudRestoreAS.exe") -ErrorAction SilentlyContinue } -# Pausar para ver resultados -Read-Host "Presiona Enter para continuar..." +Write-Host "Ejecutando PyInstaller (onefile)..." -ForegroundColor Yellow +Write-Host "Workpath: $buildDir" -ForegroundColor Gray +& $venvPython -m PyInstaller (Join-Path $Root "packaging\CloudRestoreAS.spec") ` + --clean --noconfirm ` + --distpath $distDir ` + --workpath $buildDir + +$exe = Join-Path $Root "dist\CloudRestoreAS.exe" +if (Test-Path $exe) { + Write-Host "" + Write-Host "Build OK: $exe" -ForegroundColor Green + Write-Host "Distribuir solo el .exe; al ejecutar crea config/ automaticamente." -ForegroundColor Cyan +} else { + Write-Host "Error: no se genero el ejecutable" -ForegroundColor Red + exit 1 +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..8d71b8e --- /dev/null +++ b/build.sh @@ -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 diff --git a/packaging/CloudRestoreAS.spec b/packaging/CloudRestoreAS.spec new file mode 100644 index 0000000..7f372ed --- /dev/null +++ b/packaging/CloudRestoreAS.spec @@ -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, +) diff --git a/packaging/LEEME.txt b/packaging/LEEME.txt new file mode 100644 index 0000000..557d89b --- /dev/null +++ b/packaging/LEEME.txt @@ -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. diff --git a/packaging/bundled-versions.json b/packaging/bundled-versions.json new file mode 100644 index 0000000..3a81da1 --- /dev/null +++ b/packaging/bundled-versions.json @@ -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" +} diff --git a/packaging/scripts/build-all.sh b/packaging/scripts/build-all.sh new file mode 100644 index 0000000..3a0f4bd --- /dev/null +++ b/packaging/scripts/build-all.sh @@ -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" diff --git a/packaging/scripts/docker-build-linux.sh b/packaging/scripts/docker-build-linux.sh new file mode 100755 index 0000000..eea822e --- /dev/null +++ b/packaging/scripts/docker-build-linux.sh @@ -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 +' diff --git a/packaging/scripts/download-bundled-deps.ps1 b/packaging/scripts/download-bundled-deps.ps1 new file mode 100644 index 0000000..b39ecbe --- /dev/null +++ b/packaging/scripts/download-bundled-deps.ps1 @@ -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 diff --git a/packaging/scripts/download-bundled-deps.sh b/packaging/scripts/download-bundled-deps.sh new file mode 100755 index 0000000..1202db0 --- /dev/null +++ b/packaging/scripts/download-bundled-deps.sh @@ -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" diff --git a/packaging/scripts/package-release.sh b/packaging/scripts/package-release.sh new file mode 100644 index 0000000..a0041f2 --- /dev/null +++ b/packaging/scripts/package-release.sh @@ -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" diff --git a/packaging/templates/env.default b/packaging/templates/env.default new file mode 100644 index 0000000..407ec96 --- /dev/null +++ b/packaging/templates/env.default @@ -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 diff --git a/requirements-windows.txt b/requirements-windows.txt new file mode 100644 index 0000000..c0444a2 --- /dev/null +++ b/requirements-windows.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pywin32>=306 diff --git a/requirements.txt b/requirements.txt index ab8c6a0..b5f4bfe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,13 @@ -# CloudRestoreAS - Dependencias +# CloudRestoreAS - Dependencias base (Windows y Linux) -# PySide6 para la interfaz gráfica PySide6>=6.6.0 - -# pyodbc para SQL Server pyodbc>=5.0.0 - -# pywin32 para DPAPI (cifrado de passwords) -pywin32>=306 - -# requests para consultar al PANEL el servidor de restauración de cada base requests>=2.31.0 - -# paramiko para transferir el .bak por SFTP/SSH a los servidores SQL externos paramiko>=3.4.0 +python-dotenv>=1.0.0 +cryptography>=42.0.0 -# Para empaquetado (opcional) +# Empaquetado (opcional, solo build) pyinstaller>=6.0.0 # Pruebas (opcional) diff --git a/runner.py b/runner.py index 93a053a..5bdca07 100644 --- a/runner.py +++ b/runner.py @@ -1,51 +1,91 @@ """Punto de entrada principal de la aplicación.""" +import argparse import sys from pathlib import Path -# Agregar el directorio raíz al path -ROOT_DIR = Path(__file__).parent -sys.path.insert(0, str(ROOT_DIR)) +ROOT_DIR = Path(__file__).parent.resolve() +if not getattr(sys, "frozen", False): + 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.QtCore import Qt +from app.config import get_launch_options, initialize_env, is_panel_configured from app.ui.main_window import MainWindow 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(): """Función principal.""" - # Crear directorios necesarios - DATA_DIR.mkdir(parents=True, exist_ok=True) - LOGS_DIR.mkdir(parents=True, exist_ok=True) - + args = parse_args() + launch = get_launch_options() + 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("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) - - # Configurar aplicación Qt + app = QApplication(sys.argv) app.setApplicationName("CloudRestoreAS") app.setOrganizationName("Aduanasoft") - - # Habilitar high DPI scaling app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling) - + app.setQuitOnLastWindowClosed(False) + try: - # Crear y mostrar ventana principal - window = MainWindow() - window.show() - - app_logger.info("Ventana principal mostrada") - - # Ejecutar aplicación + window = MainWindow( + minimized=minimized, + start_engine=start_engine, + panel_configured=panel_ok, + ) + if not minimized: + window.show() + app_logger.info("Ventana principal mostrada") + else: + app_logger.info("Ventana oculta; icono en bandeja del sistema") + exit_code = app.exec() - app_logger.info(f"Aplicación finalizada con código: {exit_code}") sys.exit(exit_code) - + except Exception as e: app_logger.critical(f"Error fatal en la aplicación: {e}", exc_info=True) sys.exit(1) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 0000000..392659f --- /dev/null +++ b/tests/test_bootstrap.py @@ -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"