17 Commits

Author SHA1 Message Date
9214a1feab fix(bootstrap): escribir el sello de version lo primero, y hacerla visible en el log
Actualizar a 1.1.4 fallaba con "no escribio config\.version tras la
actualizacion" aunque el binario nuevo estuviera instalado y corriendo desde la
ruta correcta. La causa era el ORDEN dentro de ensure_runtime_layout(): el sello
iba al final, detras del re-despliegue de las deps embebidas (7-Zip y ODBC). Al
cambiar de version esas deps se re-copian ENTERAS, asi que el sello quedaba por
detras de esa copia y del desempaquetado del onefile de ~254 MB con el antivirus
escaneando cada archivo. El PANEL se rendia esperandolo y daba por fallida una
actualizacion que iba bien.

- El sello se escribe lo primero, en cuanto existen las carpetas. Es tambien mas
  honesto sobre lo que significa —"que binario esta corriendo"—, que es cierto
  desde que el proceso arranca. El sello de DEPS sigue yendo al final, donde su
  comentario explica por que: si la copia falla a medias, el proximo arranque
  reintenta en vez de quedar marcado como al dia.
- La version va en la PRIMERA linea del log de arranque. Permite comprobar que
  binario corre de verdad mirando solo config/logs, sin depender del sello ni del
  reporte al panel: verificar una actualizacion deja de obligar a creerse lo que
  diga otro sistema.

La prueba nueva observa el estado del sello EN EL MOMENTO en que empieza la copia
de deps, no al final, que es la unica forma de fijar el orden. Comprobado que
muerde: devolviendo el sello al final falla con `assert None == '1.1.5'`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:11:36 -06:00
896528734b chore(version): 1.1.4
Sustituye a 1.1.3, cuyo artefacto publicado se genero antes de los arreglos: su
install.ps1 no traia -UpdateInPlace y su binario llevaba el runner.py que ignora
--headless en Windows. Actualizar con el mataba el agente, cambiaba la tarea
programada a SYSTEM y la dejaba sin arrancar, porque Qt no puede crear su
plataforma como SYSTEM en la sesion 0 sin offscreen.

Se quema el numero en lugar de reemplazar 1.1.3 con --force: los paquetes
genericos de Gitea son inmutables, y dos contenidos distintos con la misma
version fue exactamente lo que hizo caro el diagnostico.

Hay que reconstruir los binarios aunque el codigo ya estuviera arreglado, porque
__version__ va compilado dentro del ejecutable y el PANEL compara el sello
config/.version contra la version que creia estar instalando.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:42:41 -06:00
9255c6277e test(install): cubrir la ruta personalizada C:\Aduanasoft\CloudRestoreAS-win
Esa ruta es el peor caso posible y existe en produccion: la ruta por omision
C:\Aduanasoft\CloudRestoreAS es PREFIJO DE CADENA de ella, asi que cualquier
comparacion hecha con startsWith daria por iguales dos instalaciones distintas
— y el resultado seria el fallo silencioso otra vez, actualizar una carpeta y
arrancar la otra.

El flujo ya la manejaba bien (Test-SamePath compara por igualdad exacta tras
normalizar), pero nada lo probaba: la emulacion usaba declarada/otra-carpeta,
nombres sin relacion entre si, que un startsWith mal puesto pasaria sin problema.

- Escenario `sufijo` en emular-actualizacion-windows.ps1: instalacion en
  ...\CloudRestoreAS-win y tarea apuntando a ...\CloudRestoreAS. Verificado en
  Windows: reapunta la tarea, el proceso queda corriendo desde -win y el sello en
  la version nueva.
- Nuevo scripts/probar-funciones-install.ps1: extrae las funciones del instalador
  por AST y las ejercita contra una tarea simulada, sin elevacion. Cubre los casos
  limite de la comparacion de rutas (el par de prefijo en ambos sentidos, comillas,
  barra final, mayusculas, `..`, ruta vacia) y que Sync-AgentTaskPath falle cuando
  no puede corregir.

BUILD.md documenta por que se compara por igualdad exacta y no por prefijo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:09:47 -06:00
399b0483d5 docs: la tarea programada tiene que apuntar a lo que se instalo
BUILD.md gana la seccion del fallo silencioso y como reproducirlo con
scripts/emular-actualizacion-windows.ps1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:20 -06:00
0ac899f531 fix(install): la actualizacion de Windows decia que funciono y no cambiaba nada
Actualizar un servidor con el agente en una carpeta NO estandar terminaba en
verde y lo dejaba con la version anterior. Todo el camino de Windows
identificaba al agente por NOMBRE, mientras que lo unico que se actualiza se
identifica por RUTA; en cuanto las dos no coincidian, nada fallaba y nada
cambiaba.

- Se alinea la tarea programada con el binario instalado. `Start-ScheduledTask`
  ejecuta la ruta registrada en su accion, no el -Prefix: si difieren, se copiaba
  el binario nuevo en un sitio y se arrancaba el viejo del otro. Ahora se
  reapunta conservando disparador, principal, ajustes y argumentos; si no se
  puede corregir, FALLA — arrancar a sabiendas el binario anterior es peor.
- La confirmacion de arranque mira la RUTA del proceso. Un agente viejo que
  nunca se detuvo satisfacia igual de bien un `Get-Process -Name`. Si la ruta no
  es legible (un proceso de SYSTEM no la expone sin elevacion) se acepta por
  nombre y se avisa, en vez de revertir una actualizacion correcta por falta de
  informacion.
- Corregido Merge-EnvFile con un config\.env de UNA linea: al asignar la salida
  de un `if`, PowerShell desenrolla un array de un elemento a escalar, asi que
  $lines.Count reventaba con Set-StrictMode y la siembra abortaba la instalacion.

Nuevo scripts/emular-actualizacion-windows.ps1: monta un agente falso (un .exe
real que se queda vivo), una instalacion en una carpeta y una tarea apuntando a
otra, corre el instalador y dice si la actualizacion surtio efecto. Sin elevacion
y sin tocar la instalacion real de la maquina. Es lo que destapo los dos
defectos: contra el instalador anterior reproduce el sintoma exacto —codigo de
salida 0 y "El agente esta corriendo con el binario nuevo" sobre un servidor
intacto— y contra este confirma que ya surte efecto, sin tocar la tarea cuando
ya estaba bien.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:05 -06:00
0a62b7d0aa docs: documentar el instalador de Windows y corregir lo que contradecía al código
README afirmaba que la app no puede correr como servicio de Windows y sugería
NSSM, contradiciendo a install.ps1 desde que existe. LEEME.txt solo documentaba
el camino manual para Windows, mientras que para Linux ya traía el instalador.

BUILD.md gana -UpdateInPlace, las tres garantías al reemplazar el binario y el
remedio del token filtrado por UAC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:28 -06:00
c2afa52d6f fix(install): actualización desatendida en Windows, con reversión
install.sh recibió la maquinaria de actualización segura y install.ps1 nunca
recibió el equivalente. La asimetría se notaba en producción: actualizar desde
el PANEL dejaba el servidor sin agente.

- -UpdateInPlace: actualiza conservando la tarea y la configuración, sin correr
  el bootstrap (una segunda instancia purga el Temp\ de la que está viva).
- No se interrumpe una restauración en curso: sale con 75 (EX_TEMPFAIL), que el
  PANEL traduce a "reintenta luego". Windows no tenía esta guarda y una
  reinstalación a destiempo dejaba el respaldo vetado y la base en SINGLE_USER.
- Respaldo del binario anterior y reversión automática si el nuevo no arranca.
- Rearranque garantizado en TODOS los modos: la detención corría siempre, pero
  solo -Service volvía a arrancar algo.
- Espera de liberación del .exe de 5s a 30s con reintentos de la copia.
- Se distingue "no es administrador" de "es administrador con el token filtrado
  por UAC", que es lo que recibe una sesión de OpenSSH. Se veían idénticos y el
  remedio es el opuesto.

Y --headless deja de ser un no-op en Windows: _ensure_qt_platform() salía de
inmediato en win32, así que la tarea ONSTART arrancaba como SYSTEM en la sesión 0
con el plugin Qt 'windows' intentando crear una ventana real. En Linux el unit
fija QT_QPA_PLATFORM=offscreen por fuera, y esa asimetría escondió el defecto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:12 -06:00
c8133a5108 chore(version): 1.1.2 2026-07-31 07:27:33 -06:00
874fe0c46f fix(install): devolver la propiedad del árbol al usuario del servicio
Instalando con sudo, el agente quedaba sin poder leer ni escribir NADA de lo
suyo, y tanto el panel como su sonda lo reportaban como éxito.

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

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

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

Con dos cuidados que no son opcionales:

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

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

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

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

Robustez, que es donde estaba el trabajo real:

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

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

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

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

Detalles que costaron una prueba cada uno:

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

Reinstalar es idempotente: no duplica entradas de cron.
2026-07-30 13:24:50 -06:00
fd077dad42 Merge branch 'development' into feature/generador-instaladores-linux-windows 2026-07-30 07:34:58 -06:00
c3f1d70e23 feature/generador-instaladores-linux-windows 2026-07-30 07:34:17 -06:00
ce38956e3e Merge pull request 'feature/generador-instaladores-linux-windows' (#2) from feature/generador-instaladores-linux-windows into development
Reviewed-on: #2
2026-07-01 16:37:35 +00:00
cafe3f1b87 Merge branch 'development' into feature/generador-instaladores-linux-windows 2026-07-01 10:36:29 -06:00
0c14f1166b feature/generador-instaladores-linux-windows 2026-07-01 10:33:05 -06:00
0d8d80d61d feature/build-y-ui-contraible 2026-06-30 16:40:01 -06:00
54 changed files with 6612 additions and 938 deletions

375
BUILD.md Normal file
View File

@@ -0,0 +1,375 @@
# BUILD.md — Compilación, empaquetado y despliegue
Referencia de **todos** los comandos, ejecutables y scripts para generar, empaquetar,
instalar y verificar CloudRestoreAS en Windows y Linux.
El ejecutable es **autocontenido**: no requiere Python, 7-Zip, driver ODBC ni librerías
Qt instaladas en el equipo destino. Todo se embebe dentro del binario en tiempo de build.
---
## 1. Todo en una tarea (recomendado) — `build-all.sh`
Orquestador único. **Se ejecuta desde WSL** (bash). Genera Windows + Linux + los paquetes.
```bash
./build-all.sh # Windows + Linux + paquetes versionados en dist/release/
./build-all.sh --linux-only # solo Linux (Docker)
./build-all.sh --windows-only # solo Windows (PowerShell + build.ps1)
./build-all.sh --no-package # compila sin generar .tar.gz/.zip
./build-all.sh --clean # rebuild desde cero (borra venvs, bundled, dist)
./build-all.sh --publish # además publica en Gitea (ver §5; requiere GITEA_TOKEN)
./build-all.sh --help
```
**Requisitos:**
- Linux: **Docker** (usa `docker-build-linux.sh` en ubuntu:22.04).
- Windows: **`powershell.exe`** accesible desde WSL + **Python 3.11+** instalado en Windows
(`build.ps1` lo auto-detecta en `%LOCALAPPDATA%\Programs\Python\Python31X`).
- Es *fail-soft*: si no hay `powershell.exe`, compila solo Linux y avisa.
> El build de Windows se hace sobre la ruta `\\wsl.localhost\...`, por eso tarda
> (~15-18 min: pip install de PySide6 + PyInstaller). El de Linux (Docker, con deps
> cacheadas) es rápido.
`packaging/scripts/build-all.sh` es un wrapper que delega en este mismo script.
---
## 2. Builds individuales
| Objetivo | Comando | Salida | Notas |
|---|---|---|---|
| **Linux (Docker, recomendado)** | `bash packaging/scripts/docker-build-linux.sh` | `dist/CloudRestoreAS` | ubuntu:22.04 con todas las deps + `patchelf`; garantiza autocontención |
| **Linux (host)** | `./build.sh` | `dist/CloudRestoreAS` | Requiere que el host tenga las libs Qt/ODBC; usar solo si no hay Docker |
| **Windows** | `.\build.ps1` (en Windows/PowerShell) | `dist\CloudRestoreAS.exe` | Auto-detecta Python 3.11+; crea `venv-windows` |
Ambos usan el mismo spec: [packaging/CloudRestoreAS.spec](packaging/CloudRestoreAS.spec) (PyInstaller **onefile**, `console=False`).
---
## 3. Dependencias embebidas (build-time)
Se descargan e integran al binario. No se instalan en el destino.
| Script | Qué embebe |
|---|---|
| `packaging/scripts/download-bundled-deps.sh` (Linux) | `7zz` (7-Zip Linux), driver **MS ODBC 18** + unixODBC + Kerberos/GSSAPI + libltdl + OpenSSL, y cluster **Qt xcb/X11 + EGL**. Aplica `patchelf --set-rpath '$ORIGIN'` a las `.so` del cluster ODBC para que resuelvan entre sí. |
| `packaging/scripts/download-bundled-deps.ps1` (Windows) | `7z.exe` y `msodbcsql18.dll`. |
Versiones/URLs en [packaging/bundled-versions.json](packaging/bundled-versions.json).
Los binarios quedan en `packaging/bundled/{linux,windows}/` (git-ignored, se generan en el build).
**Ubicación en runtime** (creada por el bootstrap en la primera ejecución):
- `config/7zip/7zz` — extractor.
- `config/odbc/lib/` — driver ODBC + toda su cadena (resuelven por `$ORIGIN`).
- Cluster Qt xcb/EGL — dentro del onefile, en `PySide6/Qt/lib`.
---
## 4. Empaquetado — `package-release.sh`
```bash
bash packaging/scripts/package-release.sh
```
La versión sale de `app/__init__.py` (fuente única) y va en el nombre de cada paquete.
El script aborta si la versión no es puntos-y-números: el PANEL las compara como tuplas
de enteros y otro formato rompería en silencio la detección de "hay versión nueva".
Genera en `dist/release/`:
| Archivo | Contenido |
|---|---|
| `CloudRestoreAS-<version>-linux-<arch>.tar.gz` | `CloudRestoreAS/` → binario + `install.sh` + `packaging/linux/cloudrestoreas.service` + `LEEME.txt` |
| `CloudRestoreAS-<version>-win-<arch>.zip` | `CloudRestoreAS/``CloudRestoreAS.exe` + `install.ps1` + `LEEME.txt` |
| `SHA256SUMS` | Checksums de los dos paquetes |
| `release.json` | Manifiesto: versión, fecha, artefactos (platform/arch/tamaño/sha256) y deps embebidas. Es lo que lee `publish-release.sh` para saber qué subir |
Además deja copias crudas sin versión (`CloudRestoreAS.exe`, `CloudRestoreAS-linux`) para
la verificación de autocontención de la §9. Esas **no** se publican.
`arch` se declara con `CLOUDRESTORE_TARGET_ARCH` (default `x86_64`): el `.exe` lo compila
el host Windows y desde WSL no hay forma de inferir su arquitectura.
---
## 5. Publicación a Gitea — `publish-release.sh`
Los binarios viven en el **registro de paquetes genéricos de Gitea**, que es la fuente de
verdad que consume el PANEL. Gitea calcula y expone el `sha256` de cada archivo, así que
no hace falta mantener un manifiesto de integridad propio: el PANEL verifica sus descargas
contra ese hash.
```bash
# Requiere un PAT de Gitea con scope write:package
export GITEA_TOKEN=xxxxxxxx
bash packaging/scripts/publish-release.sh --dry-run # lista qué subiría
bash packaging/scripts/publish-release.sh # sube y verifica
bash packaging/scripts/publish-release.sh --notify-panel # y avisa al PANEL
```
Destino: `https://git.aduanasoft.com/api/packages/ADUANASOFT/generic/cloudrestoreas/<version>/`
Tras subir, el script **relee la API de Gitea y compara el sha256 y el tamaño de cada
artefacto** contra `release.json`. Si no coinciden falla: una publicación a medias no debe
pasar por buena, porque el PANEL rechazaría la descarga por hash y el error aparecería
mucho después, al intentar instalar.
Los paquetes genéricos son **inmutables**: reintentar la misma versión da HTTP 409. Lo
correcto es subir una versión nueva; `--force` borra y reemplaza, y solo aplica cuando la
versión anterior nunca se instaló en ningún servidor.
Todo en una sola tarea:
```bash
GITEA_TOKEN=xxxx ./build-all.sh --publish --notify-panel
```
`--publish` exige el build de **ambas** plataformas: publicar una versión a la que le falta
una dejaría en el PANEL un release que no se le puede instalar a la mitad de los servidores,
y corregirlo obligaría a quemar el número de versión.
| Variable | Default | Para qué |
|---|---|---|
| `GITEA_TOKEN` | — | **Requerida.** PAT con scope `write:package` |
| `GITEA_BASE_URL` | `https://git.aduanasoft.com` | Instancia de Gitea |
| `GITEA_OWNER` | `ADUANASOFT` | Organización dueña del paquete |
| `CRAS_PACKAGE` | `cloudrestoreas` | Nombre del paquete genérico |
| `PANEL_API_URL` | — | Solo con `--notify-panel` |
| `CLOUDRESTORE_API_TOKEN` | — | Solo con `--notify-panel` (token de servicio del PANEL) |
Siguiente paso, en el PANEL: **/versiones-cras → Sincronizar con Gitea → Activar**, y de
ahí **Instalar / Actualizar** por servidor.
---
## 6. Instalación / despliegue
Lo normal es que el PANEL instale por SSH desde `/versiones-cras`, sembrando además las
credenciales. Lo de abajo es el camino manual y lo que el PANEL ejecuta por dentro.
### Linux — `install.sh` (no instala nada del sistema)
```bash
tar xzf CloudRestoreAS-<version>-linux-x86_64.tar.gz && cd CloudRestoreAS
sudo ./install.sh --service # servicio systemd 24/7 headless (recomendado en servidor)
./install.sh --user-service # 24/7 SIN privilegios: unit de systemd de usuario
./install.sh --update-in-place # actualiza una instalación existente SIN privilegios
./install.sh --desktop # autostart .desktop (requiere sesión gráfica)
./install.sh # solo instala + bootstrap; lo corres a mano
./install.sh --help
```
Variables: `PREFIX=/opt/cloudrestoreas` (destino), `SERVICE_USER=<usuario>` (usuario del servicio).
Detiene el servicio antes de reemplazar el binario en los modos de servicio, para que el apagado
sea ordenado. **No** porque la copia lo exija: `install` desvincula el destino antes de crearlo —a
diferencia de `cp`, que abre con `O_TRUNC` y sí da `ETXTBSY`—, y por eso `make install` funciona
sobre binarios en ejecución. Comprobado. Lo que hace falta para reemplazar el binario es permiso de
escritura en el **directorio**, no en el archivo. (Esta nota decía lo contrario y mandó a más de
uno por la pista equivocada al diagnosticar un `EACCES`.)
### Sin privilegios
`--user-service` instala bajo el home con un unit de systemd **de usuario** (lingering, y si el
destino no lo permite, `@reboot` en el crontab del usuario más un vigilante). Sirve para
instalaciones nuevas donde nunca vas a tener root.
`--update-in-place` actualiza una instalación **que ya existe**, dejando su unit intacto: solo
reemplaza el binario y señaliza al proceso para que `Restart=always` lo relevante. Exige que el
directorio sea escribible por la cuenta, que el unit corra con ese mismo usuario y que tenga
`Restart=always`. Se niega si hay una restauración en curso (sale con **75**, `EX_TEMPFAIL`) y
**revierte al binario anterior** si el nuevo no arranca.
Servicio systemd:
```bash
systemctl status cloudrestoreas
journalctl -u cloudrestoreas -f
sudo systemctl restart cloudrestoreas # tras editar config/.env
```
### Windows — `install.ps1` (autocontenido, sin NSSM ni descargas)
```powershell
Expand-Archive CloudRestoreAS-<version>-win-x86_64.zip -DestinationPath .
cd CloudRestoreAS
.\install.ps1 -Service # tarea programada ONSTART como SYSTEM (24/7 headless)
.\install.ps1 -Desktop # arranque al iniciar sesión (tarea ONLOGON de la app)
.\install.ps1 -UpdateInPlace # actualiza una instalación existente, conservando su tarea
.\install.ps1 # solo instala + bootstrap
Get-Help .\install.ps1 -Detailed
```
Parámetros: `-Prefix` (default `C:\Aduanasoft\CloudRestoreAS`), `-PanelEnvFile`.
`-Service` requiere PowerShell **como Administrador** (la tarea corre como SYSTEM). El
arranque 24/7 se resuelve con una tarea programada, no con NSSM: descargarlo violaría la
regla de que en el servidor destino no se instala ni se baja nada.
Si la cuenta pertenece a Administradores y aun así se rechaza, el mensaje lo dice explícitamente:
es el **token filtrado por UAC**, que es lo que recibe una sesión de OpenSSH. No se arregla
cambiando de cuenta sino con `LocalAccountTokenFilterPolicy=1` (DWORD) en
`HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System`.
### Garantías al reemplazar el binario (las mismas en los dos instaladores)
Reemplazar el binario de un servidor en producción no puede dejarlo sin restaurador:
1. **No se actúa si hay una restauración en curso** (`Temp\` no vacío): se sale con **75**
(`EX_TEMPFAIL`), que el PANEL traduce a "reintenta luego" y no a "falló la instalación".
Interrumpirla dejaría ese respaldo vetado en cada escaneo posterior y la base en `SINGLE_USER`.
2. **Se respalda el binario anterior** antes de pisarlo (`.CloudRestoreAS.exe.prev`).
3. **Se confirma que el agente volvió a arrancar** y, si no, se **revierte** al binario anterior.
El respaldo solo se descarta tras esa confirmación.
### La tarea programada tiene que apuntar a lo que se instaló
Reemplazar el binario es una operación por **ruta**; `Start-ScheduledTask` es por **nombre** y
ejecuta la ruta que la tarea lleva registrada en su acción. Cuando las dos no coinciden —una
instalación fuera de la carpeta por omisión, o movida de sitio— se copiaba el binario nuevo en un
lado y se arrancaba el viejo del otro: **el run terminaba en verde y el servidor seguía igual**.
`install.ps1` compara la acción de la tarea con el `-Prefix` y la **reapunta** si difieren,
conservando disparador, principal, ajustes y argumentos. Si no puede corregirla, falla: arrancar a
sabiendas el binario anterior es peor que abortar. Y la confirmación de arranque mira la **ruta**
del proceso, no solo su nombre — un agente viejo que nunca se detuvo satisface igual de bien un
`Get-Process -Name CloudRestoreAS`.
### Rutas de instalación personalizadas
`C:\Aduanasoft\CloudRestoreAS-win` es el caso a tener presente, y existe en producción: la ruta por
omisión `C:\Aduanasoft\CloudRestoreAS` es **prefijo de cadena** de ella. Por eso las rutas se
comparan por **igualdad exacta tras normalizar** (comillas, barra final, mayúsculas) y nunca con
`startsWith` — que daría por iguales dos instalaciones distintas. En el panel eso vive en un solo
sitio, `sameWindowsPath()`; en el instalador, en `Test-SamePath`.
Para reproducirlo y comprobarlo sin un servidor, desde WSL o Windows:
```powershell
scripts\emular-actualizacion-windows.ps1 -Installer .\install.ps1 # caso roto
scripts\emular-actualizacion-windows.ps1 -Installer .\install.ps1 -Escenario alineada # caso normal
scripts\emular-actualizacion-windows.ps1 -Installer .\install.ps1 -Escenario sufijo # ...-win
scripts\probar-funciones-install.ps1 # casos límite
```
La emulación monta un agente falso (un `.exe` real que se queda vivo), una instalación en una
carpeta y una tarea apuntando a otra, corre el instalador y dice si la actualización surtió efecto.
`probar-funciones-install.ps1` extrae las funciones del instalador por AST y las ejercita contra una
tarea simulada. Ninguno de los dos necesita elevación ni toca la instalación real de la máquina.
`-UpdateInPlace` además no vuelve a registrar la tarea (así no pisa ajustes hechos sobre ella) y
se salta el bootstrap: una segunda instancia purgaría el `Temp\` de la que está viva. Es el modo
que usa el PANEL para actualizar.
El arranque headless no depende del entorno de la tarea: `--headless` hace que el binario elija
el plugin Qt `offscreen` en cualquier plataforma, que es lo que le permite correr como SYSTEM en
la sesión 0, donde no hay escritorio interactivo.
> `scripts/dev-setup.ps1` es otra cosa: prepara el entorno de **desarrollo** (Python, venv,
> `requirements.txt`) para correr `python runner.py`. No sirve para desplegar el binario.
### Siembra de credenciales del PANEL
Ambos instaladores aceptan un archivo `KEY=valor` con las claves `CLOUDRESTORE_PANEL_*`,
que fusionan en `config/.env` (replace-or-append, idempotente, con lista blanca) y luego
borran:
```bash
./install.sh --service --panel-env-file /tmp/panel.env
```
```powershell
.\install.ps1 -Service -PanelEnvFile C:\Temp\panel.env
```
Va por archivo y no por argumentos a propósito: un token en la línea de comandos queda
visible en `ps` y en el historial del servidor destino.
---
## 7. Ejecución manual y flags del binario
```bash
# Linux servidor sin pantalla (headless): motor de restauración sin GUI
QT_QPA_PLATFORM=offscreen ./CloudRestoreAS --start-engine --headless
# Linux con escritorio o Windows: abre la GUI normal
./CloudRestoreAS
```
| Flag / variable | Efecto |
|---|---|
| `--start-engine` | Inicia el motor de restauración al arrancar |
| `--headless` | Fuerza modo sin interfaz (Qt `offscreen`), para servidores sin display |
| `--minimized` | Inicia minimizado en la bandeja |
| `--version` | Imprime `CloudRestoreAS <version> (<platform>/<arch>)` y termina. En Windows requiere consola del padre (el `.exe` es `console=False`); el instalador remoto prefiere leer `config/.version` |
| `QT_QPA_PLATFORM=offscreen` | Plataforma Qt sin display (el binario ya cae a esto automáticamente si no hay `DISPLAY`/`WAYLAND_DISPLAY` en Linux) |
En Linux sin `DISPLAY`, el binario selecciona `offscreen` **solo**; con display usa `xcb`.
---
## 8. Configuración (primera ejecución)
El binario crea automáticamente: `config/`, `config/.env`, `Entrada/`, `Procesados/`,
`Fallados/`, `Temp/`. Editar `config/.env`:
```
CLOUDRESTORE_AUTO_START=true # el motor arranca solo
CLOUDRESTORE_PANEL_API_URL=... # servicio PANEL_BASES_ANEXO24 (enrutamiento)
CLOUDRESTORE_PANEL_API_TOKEN=...
CLOUDRESTORE_PANEL_INSTANCE_KEY=...
```
El PANEL entrega el servidor SQL destino y su `data_folder`. Con SQL Server sobre Linux
serán rutas POSIX (p. ej. `/var/opt/mssql/data`); el `RESTORE ... MOVE` adapta el separador
automáticamente según el formato del `data_folder`.
---
## 9. Verificación de autocontención (contenedor pelado)
Confirma que el binario Linux corre sin instalar NADA del sistema:
```bash
docker run --rm -v "$PWD/dist:/dist:ro" debian:12-slim bash -c '
cp /dist/CloudRestoreAS /root/app && cd /root
QT_QPA_PLATFORM=offscreen timeout 12 ./app --headless --start-engine 2>&1 | \
grep -iE "offscreen|Ventana principal|Traceback|platform plugin"
# ODBC: driver + cadena resuelven por $ORIGIN (debe dar 0)
ldd /root/config/odbc/lib/libmsodbcsql-18*.so* 2>&1 | grep -c "not found"
# 7-Zip embebido
/root/config/7zip/7zz | head -1
'
```
Esperado: arranca en `offscreen` sin errores Qt, `0` deps ODBC faltantes, `7zz` ejecuta.
---
## 10. Artefactos y ubicaciones
| Ruta | Qué es |
|---|---|
| `dist/CloudRestoreAS` | Binario Linux onefile |
| `dist/CloudRestoreAS.exe` | Ejecutable Windows onefile |
| `dist/release/CloudRestoreAS-<version>-*.{tar.gz,zip}` | Paquetes de despliegue publicables (binario + instalador + docs) |
| `dist/release/release.json` | Manifiesto de la versión (lo consume `publish-release.sh`) |
| `dist/release/SHA256SUMS` | Checksums de los paquetes |
| `config/.version` | Sello de la versión que corrió (lo lee el instalador remoto por SFTP) |
| `config/.bundled_deps` | Sello del `bundled-versions.json` desplegado; si cambia, se re-copian `config/7zip` y `config/odbc` |
| `packaging/bundled/{linux,windows}/` | Deps embebidas (generadas en build; git-ignored) |
| `venv-linux/`, `venv-windows/` | Entornos virtuales de build (git-ignored) |
---
## 11. Scripts de referencia rápida
| Script | Propósito |
|---|---|
| `build-all.sh` | **Todo en uno** (Windows + Linux + paquetes) desde WSL |
| `build.sh` / `build.ps1` | Build individual Linux / Windows |
| `packaging/scripts/docker-build-linux.sh` | Build Linux en contenedor controlado |
| `packaging/scripts/download-bundled-deps.sh` / `.ps1` | Descarga+embebe deps |
| `packaging/scripts/package-release.sh` | Genera los paquetes versionados + `SHA256SUMS` + `release.json` |
| `packaging/scripts/publish-release.sh` | Publica en los paquetes genéricos de Gitea y verifica el sha256 |
| `install.sh` / `install.ps1` | Instalador de **despliegue** Linux / Windows (autocontenidos) |
| `scripts/dev-setup.ps1` | Entorno de **desarrollo** en Windows (Python + venv). No sirve para desplegar |
| `packaging/linux/cloudrestoreas.service` | Unit systemd (24/7 headless) |

View File

@@ -1,5 +1,149 @@
# Changelog
## [1.1.5] - 2026-07-31
### La versión ahora es comprobable, y el sello deja de llegar tarde
Actualizar a 1.1.4 fallaba con *"no escribió `config\.version` tras la actualización"* aunque el
binario nuevo sí estuviera instalado y corriendo desde la ruta correcta. La causa era el **orden**
dentro de `ensure_runtime_layout()`: el sello de versión se escribía al final, detrás del
re-despliegue de las dependencias embebidas (7-Zip y ODBC). Y al cambiar de versión esas deps se
re-copian **enteras**, así que el sello quedaba por detrás de esa copia *y* del desempaquetado del
onefile de ~254 MB, con el antivirus escaneando cada archivo. El PANEL se rendía esperándolo y daba
por fallida una actualización que iba bien.
- **El sello se escribe lo primero**, en cuanto existen las carpetas. Es además más honesto sobre lo
que significa —"qué binario está corriendo"—, que es cierto desde que el proceso arranca. Si el
despliegue de deps fallara después, su propio sello (que sigue yendo al final) lo detecta y el
siguiente arranque reintenta.
- **La versión va en la primera línea del log de arranque.** Permite comprobar qué binario corre de
verdad mirando solo `config/logs`, sin depender del sello ni del reporte al panel. Verificar una
actualización ya no obliga a creerse lo que diga otro sistema.
## [1.1.4] - 2026-07-31
Recoge todo lo que sigue de esta sección y **sustituye a 1.1.3**, cuyo artefacto publicado se generó
antes de los arreglos: su `install.ps1` no traía `-UpdateInPlace` y su binario llevaba el `runner.py`
que ignoraba `--headless` en Windows. Actualizar con él mataba el agente, cambiaba la tarea
programada a SYSTEM y la dejaba sin arrancar, porque Qt no puede crear su plataforma como SYSTEM en
la sesión 0 sin `offscreen`.
Los paquetes genéricos de Gitea son inmutables, así que se quema el número en lugar de reemplazar
1.1.3: dos contenidos distintos con la misma versión fue exactamente lo que hizo caro el
diagnóstico. Hay que reconstruir los binarios aunque el código ya estuviera arreglado, porque
`__version__` va compilado dentro del ejecutable y el PANEL compara el sello `config/.version`
contra la versión que creía estar instalando.
## [Sin publicar]
### La actualización de Windows decía que funcionó y no cambiaba nada
Actualizar sobre un servidor con el agente en una carpeta **no estándar** terminaba en verde y
dejaba el servidor con la versión anterior. La causa: todo el camino de Windows identificaba al
agente por **nombre**, mientras que lo único que se actualiza se identifica por **ruta**.
- **`install.ps1` alinea la tarea programada con el binario instalado.** `Start-ScheduledTask`
ejecuta la ruta registrada en la acción de la tarea, no el `-Prefix`: si difieren, se copiaba el
binario nuevo en un sitio y se arrancaba el viejo del otro. Ahora se reapunta la tarea
conservando disparador, principal, ajustes y argumentos; si no se puede corregir, **falla**.
- **La confirmación de arranque mira la ruta del proceso**, no solo su nombre. Un agente viejo que
nunca se detuvo satisfacía igual de bien un `Get-Process -Name CloudRestoreAS`. Si la ruta no es
legible —un proceso de SYSTEM no la expone a una cuenta sin elevación— se acepta por nombre y se
avisa, en vez de revertir una actualización correcta por falta de información.
- **Nuevo `scripts/emular-actualizacion-windows.ps1`**: reproduce el escenario completo con un
agente falso, sin elevación y sin tocar la instalación real de la máquina. Es lo que destapó este
defecto y el siguiente.
- **Corregido `Merge-EnvFile` con un `config\.env` de una sola línea.** Al asignar la salida de un
`if`, PowerShell desenrolla un array de un elemento a escalar, así que `$lines.Count` reventaba
bajo `Set-StrictMode` y la siembra de credenciales abortaba la instalación.
### Instalación y actualización desatendidas en Windows
El instalador de Windows nunca recibió la maquinaria de actualización segura que sí tiene
`install.sh`, y la asimetría se notaba en producción: actualizar desde el PANEL dejaba el
servidor sin agente, o fallaba con un error que no correspondía.
- **`install.ps1 -UpdateInPlace`**: actualiza una instalación existente conservando su tarea
programada y su configuración, y sin correr el bootstrap (una segunda instancia purgaría el
`Temp\` de la que está viva). Es el modo que usa el PANEL para actualizar.
- **No se interrumpe una restauración en curso** (`Temp\` no vacío): sale con **75**
(`EX_TEMPFAIL`), que el PANEL traduce a "reintenta luego". Windows no tenía esta guarda y una
reinstalación a destiempo se llevaba por delante el respaldo que se estuviera restaurando,
dejándolo vetado y la base en `SINGLE_USER`.
- **Respaldo y reversión automática**: si el binario nuevo no arranca, se vuelve al anterior.
El respaldo solo se descarta tras confirmar que la versión nueva corre.
- **Rearranque garantizado en todos los modos.** La detención corría siempre, pero solo
`-Service` volvía a arrancar algo: actualizar con `desktop` o `none` mataba el agente y se iba
sin dejar señal.
- La espera a que el SO libere el `.exe` pasa de 5 s a 30 s con reintentos de la copia: con un
antivirus escaneando un onefile de ~270 MB, 5 s se quedaban cortos y la copia abortaba.
- Se distingue **"no es administrador"** de **"es administrador con el token filtrado por UAC"**,
que es lo que recibe una sesión de OpenSSH. Se veían idénticos y el remedio es el opuesto: el
segundo no se arregla cambiando de cuenta sino con `LocalAccountTokenFilterPolicy`.
### `--headless` ahora funciona en Windows
`_ensure_qt_platform()` salía de inmediato en `win32`, así que la bandera no hacía nada ahí. La
tarea ONSTART corre como SYSTEM en la sesión 0, sin escritorio interactivo, y arrancaba con el
plugin Qt `windows` intentando crear una ventana real. En Linux el mismo modo funcionaba porque
el unit de systemd fija `QT_QPA_PLATFORM=offscreen` por fuera, y esa asimetría escondió el
defecto. Ahora `--headless` fuerza `offscreen` en todas las plataformas y no se muestra ventana.
### Documentación
- `README.md` afirmaba que la app no puede correr como servicio de Windows y sugería NSSM, lo
que contradecía a `install.ps1` desde que existe. Corregido.
- `packaging/LEEME.txt` solo documentaba el camino manual para Windows; ahora incluye el
instalador, igual que ya hacía para Linux.
## [1.1.0] - 2026-07-29
### Distribución e instalación automatizada vía Gitea + PANEL
#### Publicación de versiones
- Artefactos con versión en el nombre: `CloudRestoreAS-<version>-{linux,win}-<arch>.{tar.gz,zip}`,
más `SHA256SUMS` y `release.json` (manifiesto con sha256, tamaño y deps embebidas).
- `packaging/scripts/publish-release.sh`: publica en los paquetes genéricos de Gitea
(`ADUANASOFT/generic/cloudrestoreas/<version>`) y **verifica el sha256 contra la propia
API de Gitea** antes de dar la publicación por buena. Soporta `--dry-run`, `--force` y
`--notify-panel`.
- `build-all.sh --publish` encadena build → empaquetado → publicación. Se niega a publicar
si falta una plataforma: los paquetes genéricos son inmutables y corregirlo quemaría el
número de versión.
#### Contrato con el PANEL
- `POST /api/restore/instance-config` ahora reporta también `platform` y `arch`, para que
el PANEL sepa qué artefacto le corresponde a cada servidor.
- `processed_folder` ya se envía en ese mismo reporte (antes se calculaba, no se mandaba).
#### Instaladores
- **Nuevo `install.ps1`**: instalador de despliegue Windows, autocontenido. Registra una
tarea programada ONSTART como SYSTEM para el 24/7 headless — sin NSSM ni descargas en el
servidor destino. Detiene la instancia en ejecución antes de reemplazar el `.exe`.
- El antiguo `install.ps1` (preparación del entorno de desarrollo: Python, venv, pip) se
movió a `scripts/dev-setup.ps1`. **Se estaba empaquetando por error** en el zip del
ejecutable autocontenido, que no necesita nada de eso.
- `install.sh` e `install.ps1` aceptan `--panel-env-file` / `-PanelEnvFile`: fusionan las
claves `CLOUDRESTORE_PANEL_*` en `config/.env` (replace-or-append, idempotente, con lista
blanca) y borran el archivo. El token viaja por archivo 0600, nunca por argumentos, para
que no quede visible en `ps` ni en el historial del destino.
- `install.sh` detiene el servicio antes de reemplazar el binario y lo vuelve a levantar si
estaba activo. (La razón que se dio aquí —que un ELF en ejecución da `ETXTBSY`— era incorrecta:
eso le pasa a `cp`, no a `install`, que desvincula el destino antes de crearlo. Ver BUILD.md.)
#### Versionado
- `app/__init__.py` es la fuente única de la versión; el diálogo *Acerca de* ya no la trae
hardcodeada.
- Flag `--version` en el binario, y sello `config/.version` que escribe el bootstrap (el
instalador remoto lo lee por SFTP, porque el `.exe` se compila con `console=False`).
- El `.exe` ya lleva metadatos de versión de Windows (`VSVersionInfo`).
#### Correcciones
- `config/7zip` y `config/odbc` se re-despliegan cuando el build trae otras versiones
embebidas, comparando un sello con el sha256 de `bundled-versions.json`. Antes solo se
copiaban si la carpeta estaba vacía, así que una actualización con driver ODBC nuevo
conservaba el viejo indefinidamente.
## [1.0.0] - 2026-01-25
### Lanzamiento Inicial

View File

@@ -132,15 +132,69 @@ Usado por utilidades legacy; el flujo principal de jobs usa `resolve-route`.
### POST `/api/restore/instance-config`
Reporte de carpeta de entrada (`instance_key` = nombre del servidor).
Reporte de carpeta de entrada e identidad del agente (`instance_key` = nombre del servidor).
Es *best-effort*: un fallo aquí nunca bloquea una restauración.
```jsonc
{
"input_folder": "D:\\Backups\\Entrada",
"processed_folder": "D:\\Backups\\Procesados", // opcional; el panel la deriva si falta
"host_name": "WIN-RESTORE-01",
"app_version": "1.1.0", // versión instalada → cloudrestore_status.app_version
"platform": "windows", // "windows" | "linux"
"arch": "x86_64", // "x86_64" | "arm64"
"instance_key": "Alfa" // = restore_targets.name
}
```
`platform` y `arch` le dicen al panel **qué artefacto le corresponde a este servidor** al
instalar o actualizar: `a24c.cras_releases` se llavea por `version + platform + arch`. Un
agente viejo que no las mande sigue funcionando; el panel cae al texto libre de
`restore_targets.os` para la primera instalación.
Respuesta: `200 { "ok": true, "trace_id": "…" }`.
### POST `/api/restore/agent-sync`
Dispara la sincronización del catálogo de versiones contra Gitea. Lo usa
`publish-release.sh --notify-panel` para que una versión recién publicada aparezca de
inmediato, sin esperar a que un admin abra `/versiones-cras`.
Body vacío (`{}`). Respuesta: `200 { "ok": true, "discovered": N, "versions": N }`.
---
## Distribución de versiones (Gitea → PANEL → servidor)
Los binarios se publican en el registro de paquetes genéricos de Gitea; el panel los
descubre leyendo su API, los cachea verificando el `sha256` que Gitea calcula, e instala
por SSH/SFTP en el servidor destino.
```
build local → Gitea (generic packages) → PANEL (caché + instalador SSH) → servidor
```
- **Publicar:** ver [BUILD.md](BUILD.md) §5 (`publish-release.sh`).
- **Instalar/actualizar:** panel → **Versiones CRAS** (`/versiones-cras`) → *Sincronizar con
Gitea* → *Activar**Instalar* en el servidor.
- El panel siembra `config/.env` con `api_url`, `api_token` e `instance_key` durante la
instalación, así que el servidor queda operativo sin configuración manual.
- El servidor destino **no descarga nada de internet**: el binario es autocontenido y los
bytes llegan del panel por SFTP.
Tablas involucradas: `a24c.cras_releases` (catálogo de versiones publicadas) y
`a24c.cras_install_runs` (bitácora de instalaciones con progreso paso a paso).
---
## Agregar servidores adicionales
1. Panel → **Servidores de Restauración → + Nuevo servidor**
1. Panel → **Servidores de Restauración → + Nuevo servidor** (incluye credenciales SSH)
2. **Gestión de Bases de Datos** → dropdown servidor por base
3. Instalar CRA → Config → instancia → Guardar (card **Reportada**)
3. Panel → **Versiones CRAS***Instalar* en ese servidor (siembra el `.env` solo)
El camino manual sigue disponible: instalar el CRA a mano → Config → instancia → Guardar
(card **Reportada**).
---

View File

@@ -53,7 +53,7 @@ CloudRestoreAs/
├── runner.py # Punto de entrada principal
├── requirements.txt # Dependencias Python
├── install.ps1 # Script de instalación automática
├── install.ps1 # Instalador de DESPLIEGUE Windows (binario autocontenido)
├── start.ps1 # Script de inicio rápido (PowerShell)
├── start.bat # Script de inicio rápido (Batch)
├── build.ps1 # Script para generar ejecutable con PyInstaller

View File

@@ -13,13 +13,16 @@ Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"}
### 2. Instala y Ejecuta
```powershell
# Ejecuta el script de instalación
.\install.ps1
# Prepara el entorno de desarrollo (Python + venv + requirements)
.\scripts\dev-setup.ps1
# Ejecuta la aplicación
.\venv\Scripts\python.exe runner.py
```
> Para **desplegar** el binario compilado en un servidor usa `install.ps1` (raíz), que es
> autocontenido y no necesita Python. Ver [BUILD.md](BUILD.md) §6.
### Integración con PANEL_BASES_ANEXO24
Si usas el panel para asignar servidores de restauración, ver [INTEGRACION_PANEL.md](INTEGRACION_PANEL.md) para tokens, catálogo dinámico (`target-catalog`) y prueba end-to-end.

View File

@@ -24,6 +24,9 @@ CloudRestoreAS es una aplicación Windows de escritorio desarrollada en Python 3
### Sistema Operativo
- Windows 10/11
- Windows Server 2019/2022 o superior
- Linux x86-64 (servidor o escritorio). En servidor **headless** (sin pantalla) corre
automáticamente en modo Qt `offscreen`. El binario Linux es **autocontenido**: no
requiere instalar Python, 7-Zip, driver ODBC ni librerías Qt en el destino.
### Software Requerido
@@ -75,25 +78,46 @@ CloudRestoreAS es una aplicación Windows de escritorio desarrollada en Python 3
### Opción 2: Ejecutable portable (recomendado para producción)
**Windows** — generar:
```powershell
.\build.ps1
```
Salida: `dist\CloudRestoreAS.exe`
**Linux** — generar:
**Todo de una vez (desde WSL)** — genera Windows + Linux + los paquetes de release:
```bash
./build.sh
./build-all.sh # ambos + dist/release/*.tar.gz y *.zip
./build-all.sh --linux-only # solo Linux (Docker)
./build-all.sh --windows-only # solo Windows (PowerShell + build.ps1)
./build-all.sh --clean # rebuild desde cero
```
Salida: `dist/CloudRestoreAS`
Requiere Docker (para Linux) y, para Windows, `powershell.exe` accesible desde WSL con Python 3.11+ instalado en Windows.
**Desplegar en cada equipo** (sin Python, sin instalador):
**Solo Windows** — generar: `.\build.ps1` → `dist\CloudRestoreAS.exe`
**Solo Linux** — generar: `./build.sh` (o `packaging/scripts/docker-build-linux.sh` para el entorno controlado) → `dist/CloudRestoreAS`
> 📦 Referencia completa de compilación, empaquetado, instalación (`install.sh`/systemd),
> flags del binario y verificación: **[BUILD.md](BUILD.md)**.
> El build de Linux embebe dentro del binario, además de la app: las libs de sistema
> de Qt (cluster xcb/X11 + EGL), el driver **ODBC 18** con sus dependencias
> (unixODBC, Kerberos/GSSAPI, OpenSSL) y el **7-Zip** (`7zz`). Por eso el destino no
> necesita `apt install` de nada. (El build se hace en un entorno con esas libs; ver
> `packaging/scripts/download-bundled-deps.sh` y `docker-build-linux.sh`.)
**Desplegar en cada equipo** (sin Python, sin instalador, sin dependencias de sistema):
1. Copiar **solo** el ejecutable a una carpeta.
2. Ejecutarlo (doble clic o `./CloudRestoreAS`).
3. Se crean automáticamente: `config/`, `Entrada/`, `Procesados/`, `Fallados/`, `Temp/`.
4. Editar `config/.env` (URL, token e instancia del panel).
5. Reiniciar la aplicación.
**Despliegue en servidor Linux (headless / SQL Server sobre Linux):**
```bash
sudo ./install.sh --service # servicio systemd 24/7 (offscreen, motor auto-inicio)
# o manual:
QT_QPA_PLATFORM=offscreen ./CloudRestoreAS --start-engine --headless
```
`install.sh` **no instala ni descarga nada** del sistema: solo coloca el binario, hace
el bootstrap de `config/` y registra el arranque (systemd headless o autostart de
escritorio con `--desktop`). El `data_folder` del destino viene del PANEL; con SQL
Server sobre Linux serán rutas POSIX (p. ej. `/var/opt/mssql/data`) y el `RESTORE`
las adapta automáticamente.
Ver `packaging/LEEME.txt` para instrucciones resumidas.
## ⚙️ Configuración Inicial
@@ -448,7 +472,7 @@ R: Sí, especifica el nombre o IP del servidor remoto en Configuración → SQL
R: Sí, la aplicación usa RESTORE FILELISTONLY para detectar todos los archivos lógicos (MDF, LDF, y archivos adicionales). Solo mantiene el archivo principal de datos (.mdf) y de log (.ldf).
**P: ¿Puedo ejecutar esto como Windows Service?**
R: No, la aplicación está diseñada como "daemon de usuario" con interfaz gráfica. Para ejecutar como servicio, considera usar NSSM (Non-Sucking Service Manager) para envolver el ejecutable, pero perderás la UI.
R: Sí, con `install.ps1 -Service`: registra una tarea programada ONSTART que corre como SYSTEM, sin sesión iniciada y sin UI. **No** se usa NSSM ni ningún envoltorio descargado — el servidor destino no instala ni baja nada, y una tarea programada ya viene en el SO. Requiere PowerShell como Administrador. Ver [BUILD.md](BUILD.md) §6.
## 📄 Licencia

View File

@@ -1,4 +1,8 @@
"""CloudRestoreAS - Aplicación de restauración automática de bases de datos SQL Server."""
__version__ = "1.0.0"
# Fuente ÚNICA de la versión. La leen: el spec de PyInstaller (metadatos del .exe),
# package-release.sh (nombres de artefacto y release.json) y el reporte al PANEL.
# Formato obligatorio: puntos y números, monotónico creciente — el PANEL compara
# versiones como tuplas de enteros para detectar si hay una más nueva.
__version__ = "1.1.5"
__author__ = "Aduanasoft"

View File

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

View File

@@ -15,6 +15,7 @@ from ..constants import (
ENV_PATH,
default_seven_zip_path,
)
from ..utils.logger import app_logger
def _env_bool(name: str, default: bool = False) -> bool:
@@ -24,6 +25,31 @@ def _env_bool(name: str, default: bool = False) -> bool:
return raw in ("1", "true", "yes", "on")
def _env_int(
name: str,
default: int,
*,
min_value: int | None = None,
max_value: int | None = None,
) -> int:
"""Lee un entero de entorno con validación; ante valor inválido loguea y usa el default."""
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
app_logger.warning(f"{name}='{raw}' no es un entero válido; se usa {default}")
return default
if min_value is not None and value < min_value:
app_logger.warning(f"{name}={value} < {min_value} (mínimo); se usa {default}")
return default
if max_value is not None and value > max_value:
app_logger.warning(f"{name}={value} > {max_value} (máximo); se usa {default}")
return default
return value
def load_env_file() -> bool:
"""Carga config/.env si existe."""
if ENV_PATH.is_file():
@@ -101,6 +127,33 @@ def apply_env_overrides(config: dict) -> dict:
if os.getenv("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH"):
sql["use_windows_auth"] = _env_bool("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH", False)
retention = config.setdefault("retention", {})
if os.getenv("CLOUDRESTORE_RETENTION_ENABLED"):
retention["enabled"] = _env_bool("CLOUDRESTORE_RETENTION_ENABLED", True)
if os.getenv("CLOUDRESTORE_RETENTION_DAYS"):
retention["days"] = _env_int(
"CLOUDRESTORE_RETENTION_DAYS", retention.get("days", 2), min_value=0
)
if os.getenv("CLOUDRESTORE_RETENTION_FAILED_DAYS"):
retention["failed_days"] = _env_int(
"CLOUDRESTORE_RETENTION_FAILED_DAYS", retention.get("failed_days", 7), min_value=0
)
if os.getenv("CLOUDRESTORE_RETENTION_RUN_AT_HOUR"):
retention["run_at_hour"] = _env_int(
"CLOUDRESTORE_RETENTION_RUN_AT_HOUR",
retention.get("run_at_hour", 3),
min_value=0,
max_value=23,
)
if os.getenv("CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS"):
retention["check_interval_seconds"] = _env_int(
"CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS",
retention.get("check_interval_seconds", 3600),
min_value=60,
)
if os.getenv("CLOUDRESTORE_RETENTION_DRY_RUN"):
retention["dry_run"] = _env_bool("CLOUDRESTORE_RETENTION_DRY_RUN", True)
return config
@@ -114,7 +167,8 @@ def render_env_template(app_dir: Path | None = None) -> str:
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)
# Arranca el motor al abrir. La ventana SIEMPRE se muestra al iniciar; cerrar (X) la
# minimiza a la bandeja del sistema (START_MINIMIZED ya no oculta la ventana).
CLOUDRESTORE_AUTO_START=true
CLOUDRESTORE_START_MINIMIZED=false
CLOUDRESTORE_REGISTER_AUTOSTART=true
@@ -135,4 +189,12 @@ CLOUDRESTORE_EXTRACT_FOLDER={p("Temp")}
# CLOUDRESTORE_SQL_SERVER=localhost
# CLOUDRESTORE_SQL_USERNAME=
# CLOUDRESTORE_DATA_SQL_FOLDER=
# Retención (limpieza diaria de respaldos obsoletos para no saturar el disco)
# CLOUDRESTORE_RETENTION_ENABLED=true
# CLOUDRESTORE_RETENTION_DAYS=2 # Procesados: por nodo, respecto al más reciente
# CLOUDRESTORE_RETENTION_FAILED_DAYS=7 # Fallados: por antigüedad absoluta
# CLOUDRESTORE_RETENTION_RUN_AT_HOUR=3 # hora local de la corrida diaria (0-23)
# CLOUDRESTORE_RETENTION_CHECK_INTERVAL_SECONDS=3600
# CLOUDRESTORE_RETENTION_DRY_RUN=true # true = solo simula; poner false para borrar
"""

View File

@@ -27,11 +27,24 @@ Driver={driver_line}
UsageCount=1
"""
else:
# Los .so embebidos viven en config/odbc/lib (no en la raíz). Buscar ahí
# primero y usar SIEMPRE la ruta absoluta del archivo versionado real
# (p. ej. libmsodbcsql-18.5.so.1.1); un nombre suelto como
# "libmsodbcsql-18.so" no resuelve porque ese symlink apunta a /opt.
lib_dir = odbc_dir / "lib"
search_dirs = [d for d in (lib_dir, odbc_dir) if d.is_dir()]
so_path = None
for pattern in ("libmsodbcsql-18*.so*", "msodbcsql-18*.so*"):
matches = list(odbc_dir.glob(pattern))
if matches:
so_path = matches[0]
for base in search_dirs:
# Preferir el archivo real versionado sobre symlinks (que pueden estar rotos).
candidates = sorted(
(p for p in base.glob("libmsodbcsql-18*.so*") if p.is_file() and not p.is_symlink()),
key=lambda p: len(p.name),
reverse=True,
)
if not candidates:
candidates = [p for p in base.glob("libmsodbcsql-18*.so*") if p.exists()]
if candidates:
so_path = candidates[0]
break
driver_line = str(so_path.resolve()) if so_path else "libmsodbcsql-18.so"
content = f"""[ODBC Driver 18 for SQL Server]

View File

@@ -1,8 +1,36 @@
"""Constantes globales de la aplicación."""
import platform
import sys
from pathlib import Path
IS_WINDOWS = sys.platform == "win32"
# Plataforma y arquitectura de ESTE build, en el vocabulario que usa el PANEL para
# decidir qué artefacto le corresponde a cada servidor (a24c.cras_releases).
# Se reportan en POST /api/restore/instance-config junto con app_version.
APP_PLATFORM = "windows" if IS_WINDOWS else "linux"
# platform.machine() varía por SO para la misma arquitectura ("AMD64" en Windows,
# "x86_64" en Linux); se normaliza a un solo vocabulario.
_ARCH_ALIASES = {
"x86_64": "x86_64",
"amd64": "x86_64",
"x86": "x86",
"i386": "x86",
"i686": "x86",
"aarch64": "arm64",
"arm64": "arm64",
}
def _resolve_arch() -> str:
raw = (platform.machine() or "").strip().lower()
return _ARCH_ALIASES.get(raw, raw or "unknown")
APP_ARCH = _resolve_arch()
def _resolve_app_dir() -> Path:
"""Directorio donde vive el ejecutable (persistente)."""
@@ -15,19 +43,27 @@ 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
return _resolve_app_dir() / "packaging" / "bundled" / APP_PLATFORM
APP_DIR = _resolve_app_dir()
CONFIG_DIR = APP_DIR / "config"
ENV_PATH = CONFIG_DIR / ".env"
# Sello con la versión que corrió por última vez en esta instalación. El bootstrap lo
# reescribe en cada arranque; el instalador remoto del PANEL lo lee por SFTP para
# verificar el despliegue sin depender de stdout (el .exe se compila con console=False).
VERSION_PATH = CONFIG_DIR / ".version"
# Sello con el hash de packaging/bundled-versions.json del build que desplegó 7zip/odbc.
# Si cambia, el bootstrap re-copia esas carpetas (ver _copy_bundled_tree).
BUNDLED_STAMP_PATH = CONFIG_DIR / ".bundled_deps"
DATA_DIR = CONFIG_DIR / "data"
LOGS_DIR = CONFIG_DIR / "logs"
ODBC_DIR = CONFIG_DIR / "odbc"
SEVEN_ZIP_DIR = CONFIG_DIR / "7zip"
IS_WINDOWS = sys.platform == "win32"
BUNDLE_DIR = _resolve_bundle_dir()
BUNDLED_SOURCE_7ZIP = BUNDLE_DIR / "bundled" / "7zip"
BUNDLED_SOURCE_ODBC = BUNDLE_DIR / "bundled" / "odbc"
@@ -136,4 +172,18 @@ DEFAULT_CONFIG = {
"instance_key": "",
"verify_ssl": False,
},
"retention": {
# Limpieza diaria de respaldos obsoletos para no saturar el disco del servidor.
"enabled": True,
# Procesados/: por nodo, borra los aplicados con finished_at < (ref_del_nodo - days).
"days": 2,
# Fallados/: por antigüedad absoluta, borra los más viejos que (hoy - failed_days).
"failed_days": 7,
# Hora local (0-23) a la que corre la limpieza diaria.
"run_at_hour": 3,
# Cada cuánto despierta el hilo para evaluar si toca correr.
"check_interval_seconds": 3600,
# Arranca en SECO: solo reporta qué borraría. El operador lo desactiva tras validar.
"dry_run": True,
},
}

View File

@@ -1,37 +1,54 @@
"""Esquema y gestión de la base de datos SQLite."""
import sqlite3
import threading
from pathlib import Path
from typing import Optional
from datetime import datetime
from ..constants import DB_PATH
# Espera máxima (s) por un lock de SQLite antes de fallar. Con WAL + busy_timeout los
# lectores (UI cada 5s) y escritores (workers + file_watcher) dejan de chocar con
# "database is locked": esperan en vez de fallar de inmediato.
_SQLITE_TIMEOUT_SECONDS = 30.0
_SQLITE_BUSY_TIMEOUT_MS = 5000
class DatabaseManager:
"""Gestor de la base de datos SQLite."""
def __init__(self, db_path: Optional[Path] = None):
"""
Inicializa el gestor de base de datos.
Args:
db_path: Ruta a la base de datos (usa DB_PATH por defecto)
"""
self.db_path = db_path or DB_PATH
self.db_path.parent.mkdir(parents=True, exist_ok=True)
# Serializa las escrituras del propio proceso (varios workers + file_watcher) para
# eliminar las colisiones write-write; WAL cubre la concurrencia lectura/escritura.
self._write_lock = threading.Lock()
self._initialize_schema()
def get_connection(self) -> sqlite3.Connection:
"""Obtiene una conexión a la base de datos."""
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
"""Obtiene una conexión a SQLite con protecciones de concurrencia (WAL + timeouts)."""
conn = sqlite3.connect(
str(self.db_path), check_same_thread=False, timeout=_SQLITE_TIMEOUT_SECONDS
)
conn.row_factory = sqlite3.Row
# WAL: lectores concurrentes con un escritor (persistente en el archivo, idempotente).
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def _initialize_schema(self):
"""Crea las tablas si no existen."""
with self.get_connection() as conn:
conn = self.get_connection()
try:
cursor = conn.cursor()
# Tabla de jobs
cursor.execute("""
CREATE TABLE IF NOT EXISTS jobs (
@@ -51,7 +68,8 @@ class DatabaseManager:
total_ms INTEGER,
extract_ms INTEGER,
restore_ms INTEGER,
filelist_ms INTEGER
filelist_ms INTEGER,
purged_at TEXT
)
""")
@@ -104,36 +122,62 @@ class DatabaseManager:
)
""")
# Migración idempotente: columna purged_at para marcar los respaldos que la
# retención ya borró del disco. Se conserva la fila (historial/stats/UI) y solo
# se anota que su archivo físico dejó de existir. Backward-compatible: columna
# nullable que las versiones anteriores de la app simplemente ignoran.
cursor.execute("PRAGMA table_info(jobs)")
job_columns = {row[1] for row in cursor.fetchall()}
if "purged_at" not in job_columns:
cursor.execute("ALTER TABLE jobs ADD COLUMN purged_at TEXT")
# Índices
cursor.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)")
# Soporta la retención por nodo (GROUP BY node_name + filtro por finished_at).
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_jobs_node_finished "
"ON jobs(node_name, finished_at)"
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_job ON events(job_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_job_steps_job ON job_steps(job_id)")
conn.commit()
finally:
conn.close()
def execute(self, query: str, params: tuple = ()) -> sqlite3.Cursor:
"""Ejecuta una consulta SQL."""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(query, params)
conn.commit()
return cursor
"""Ejecuta una escritura, serializada por lock de proceso y con conexión cerrada."""
with self._write_lock:
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute(query, params)
conn.commit()
return cursor
finally:
conn.close()
def fetchone(self, query: str, params: tuple = ()) -> Optional[sqlite3.Row]:
"""Ejecuta una consulta y devuelve una fila."""
with self.get_connection() as conn:
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute(query, params)
return cursor.fetchone()
finally:
conn.close()
def fetchall(self, query: str, params: tuple = ()) -> list[sqlite3.Row]:
"""Ejecuta una consulta y devuelve todas las filas."""
with self.get_connection() as conn:
conn = self.get_connection()
try:
cursor = conn.cursor()
cursor.execute(query, params)
return cursor.fetchall()
finally:
conn.close()
# Instancia global

View File

@@ -28,6 +28,8 @@ class Job:
extract_ms: Optional[int]
restore_ms: Optional[int]
filelist_ms: Optional[int]
# Fecha en que la retención borró del disco el archivo de este job (None = aún en disco).
purged_at: Optional[str] = None
class JobRepository:
@@ -185,14 +187,109 @@ class JobRepository:
"""
db.execute("DELETE FROM jobs WHERE job_id = ?", (job_id,))
@staticmethod
def get_obsolete_completed_by_node(days: int = 2) -> List[Job]:
"""Jobs 'completed' obsoletos por nodo, candidatos a que se borre su archivo del disco.
Para cada node_name (no NULL) toma su restauración más reciente (MAX(finished_at))
como referencia y devuelve los jobs completados de ese nodo con finished_at anterior a
(referencia days). Como la referencia es el máximo, la comparación estricta '<' NUNCA
incluye la restauración más reciente; los nodos con una sola restauración quedan fuera.
Se excluyen node_name NULL y los ya purgados (purged_at no nulo).
Args:
days: días de antigüedad respecto al más reciente de cada nodo.
Returns:
Lista de jobs obsoletos ordenada por nodo y fecha ascendente.
"""
modifier = f"-{int(days)} days"
rows = db.fetchall(
"""
WITH refs AS (
SELECT node_name, MAX(finished_at) AS ref_finished_at
FROM jobs
WHERE status = ? AND finished_at IS NOT NULL AND node_name IS NOT NULL
GROUP BY node_name
)
SELECT j.* FROM jobs j
JOIN refs r ON r.node_name = j.node_name
WHERE j.status = ?
AND j.finished_at IS NOT NULL
AND j.purged_at IS NULL
AND datetime(j.finished_at) < datetime(r.ref_finished_at, ?)
ORDER BY j.node_name, j.finished_at
""",
(JobStatus.COMPLETED, JobStatus.COMPLETED, modifier),
)
return [Job(**dict(row)) for row in rows]
@staticmethod
def get_latest_completed_per_node() -> dict:
"""node_name -> finished_at (MAX) de las restauraciones completadas de cada nodo.
Sirve como salvaguarda de la retención: la carpeta-fecha de esta referencia (la más
reciente de cada nodo) nunca debe tocarse.
"""
rows = db.fetchall(
"""
SELECT node_name, MAX(finished_at) AS ref_finished_at
FROM jobs
WHERE status = ? AND finished_at IS NOT NULL AND node_name IS NOT NULL
GROUP BY node_name
""",
(JobStatus.COMPLETED,),
)
return {row["node_name"]: row["ref_finished_at"] for row in rows}
@staticmethod
def mark_purged(job_id: str) -> None:
"""Marca que la retención ya borró del disco el archivo de este job.
No borra la fila: conserva el historial, las estadísticas y la vista de la UI; solo
evita que la retención vuelva a intentar borrar un archivo que ya no existe.
"""
now = datetime.utcnow().isoformat()
db.execute(
"UPDATE jobs SET purged_at = ?, updated_at = ? WHERE job_id = ?",
(now, now, job_id),
)
@staticmethod
def exists_by_hash(source_hash: str) -> bool:
"""Verifica si existe un job con el hash dado."""
"""Verifica si existe un job con el hash dado (cualquier estado)."""
row = db.fetchone(
"SELECT COUNT(*) as count FROM jobs WHERE source_hash = ?",
(source_hash,)
)
return row["count"] > 0
@staticmethod
def has_blocking_job_by_hash(source_hash: str) -> bool:
"""True si hay un job con ese hash EXITOSO o EN CURSO (dedup real).
Los estados terminales fallidos (failed/failed_restart/cancelled) NO bloquean: así,
un fallo transitorio (p.ej. 'database is locked') deja de impedir para siempre el
reproceso del archivo si reaparece.
"""
row = db.fetchone(
"SELECT COUNT(*) as count FROM jobs "
"WHERE source_hash = ? AND status NOT IN (?, ?, ?)",
(source_hash, JobStatus.FAILED, JobStatus.FAILED_RESTART, JobStatus.CANCELLED),
)
return row["count"] > 0
@staticmethod
def delete_failed_by_hash(source_hash: str) -> int:
"""Elimina jobs terminales fallidos/cancelados con ese hash (permite un reintento fresco)."""
cursor = db.execute(
"DELETE FROM jobs WHERE source_hash = ? AND status IN (?, ?, ?)",
(source_hash, JobStatus.FAILED, JobStatus.FAILED_RESTART, JobStatus.CANCELLED),
)
try:
return cursor.rowcount if cursor else 0
except Exception:
return 0
@staticmethod
def get_stats() -> dict:

View File

@@ -2,18 +2,21 @@
import platform
import socket
from threading import Thread
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 .maintenance_scheduler import DailyMaintenanceScheduler
from .restore_worker import RestoreWorker
from .retention import RetentionCleaner
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 ..constants import APP_ARCH, APP_DIR, APP_PLATFORM, JobStatus, DEFAULT_CONFIG
from ..panel import panel_client
from ..utils.logger import app_logger
@@ -39,7 +42,10 @@ class RestoreEngine(QObject):
# File watcher
self._file_watcher: Optional[FileWatcher] = None
# Mantenimiento diario (retención de respaldos obsoletos)
self._maintenance: Optional[DailyMaintenanceScheduler] = None
# Estado
self._running = False
self._paused = False
@@ -75,9 +81,10 @@ class RestoreEngine(QObject):
app_logger.info("Configuración guardada")
self._report_instance_config_to_panel()
# Reconfigurar file watcher si está corriendo
# Reconfigurar file watcher y mantenimiento si está corriendo
if self._running:
self._restart_file_watcher()
self._restart_maintenance()
def get_config(self) -> dict:
"""Obtiene la configuración actual."""
@@ -93,7 +100,12 @@ class RestoreEngine(QObject):
if not self._validate_config():
app_logger.error("Configuración inválida, no se puede iniciar el motor")
return
# Barrido de Temp: al arrancar no hay jobs corriendo, así que cualquier subcarpeta en
# extract_folder es un remanente huérfano de una corrida previa (los caminos de
# fallo/diferido no siempre limpiaban). Se elimina para que Temp no se acumule.
self._purge_stale_temp()
# Configurar thread pool
extract_workers = self._config["concurrency"]["extract_workers"]
restore_workers = self._config["concurrency"]["restore_workers"]
@@ -108,7 +120,10 @@ class RestoreEngine(QObject):
# Iniciar file watcher
if self._config["features"]["auto_scan_enabled"]:
self._start_file_watcher()
# Iniciar el mantenimiento diario (limpieza de respaldos obsoletos)
self._start_maintenance()
self._running = True
self._paused = False
self._report_instance_config_to_panel()
@@ -116,6 +131,28 @@ class RestoreEngine(QObject):
EventRepository.create("INFO", "Motor iniciado")
app_logger.info("Motor iniciado")
def _purge_stale_temp(self):
"""Elimina subcarpetas huérfanas en la carpeta temporal de extracción (al arrancar)."""
import shutil
extract_folder = (self._config.get("paths") or {}).get("extract_folder")
if not extract_folder:
return
base = Path(extract_folder)
if not base.is_dir():
return
removed = 0
for child in base.iterdir():
if not child.is_dir():
continue
try:
shutil.rmtree(child, ignore_errors=True)
removed += 1
except Exception as e:
app_logger.warning(f"No se pudo limpiar Temp huérfano {child}: {e}")
if removed:
app_logger.info(f"Temp: {removed} carpeta(s) huérfana(s) eliminada(s) al iniciar")
def stop(self):
"""Detiene el motor."""
if not self._running:
@@ -125,7 +162,10 @@ class RestoreEngine(QObject):
if self._file_watcher:
self._file_watcher.stop()
self._file_watcher = None
# Detener el mantenimiento diario
self._stop_maintenance()
# Esperar a que terminen los workers
self._thread_pool.waitForDone(msecs=30000) # 30s timeout
@@ -207,28 +247,45 @@ class RestoreEngine(QObject):
if not api_url or not api_token:
return
input_folder = (self._config.get("paths") or {}).get("input_folder") or ""
paths = self._config.get("paths") or {}
input_folder = paths.get("input_folder") or ""
processed_folder = paths.get("processed_folder") or ""
try:
host_name = socket.gethostname() or platform.node()
except Exception:
host_name = platform.node()
instance_key = (panel_cfg.get("instance_key") or "").strip() or None
# platform/arch le dicen al PANEL qué artefacto le toca a este servidor cuando
# instala o actualiza (a24c.cras_releases se llavea por version+platform+arch).
panel_client.report_instance_config(
api_url=api_url,
api_token=api_token,
input_folder=input_folder,
processed_folder=processed_folder,
host_name=host_name,
app_version=__version__,
instance_key=instance_key,
platform_name=APP_PLATFORM,
arch=APP_ARCH,
# Dónde vive el ejecutable. El PANEL actualiza en esta ruta; si instalara en la
# default crearía una segunda instalación y dejaría huérfano este config/.env.
install_path=str(APP_DIR),
)
def _validate_config(self) -> bool:
"""Valida que la configuración sea correcta."""
paths = self._config["paths"]
# Validar carpetas requeridas
required_paths = ["input_folder", "extract_folder", "data_sql_folder"]
# Validar carpetas requeridas. processed_folder/failed_folder se incluyen para que la
# reubicación de ZIP (éxito/fallo) nunca falle por carpeta inexistente.
required_paths = [
"input_folder",
"extract_folder",
"data_sql_folder",
"processed_folder",
"failed_folder",
]
for key in required_paths:
if not paths.get(key):
app_logger.error(f"Falta configurar: {key}")
@@ -280,6 +337,52 @@ class RestoreEngine(QObject):
"""Reinicia el file watcher con nueva configuración."""
if self._running and not self._paused:
self._start_file_watcher()
def _start_maintenance(self):
"""Inicia el mantenimiento diario si la retención está habilitada."""
retention_cfg = self._config.get("retention", {})
if not retention_cfg.get("enabled", True):
app_logger.info("Retención deshabilitada; no se inicia el mantenimiento diario")
return
self._maintenance = DailyMaintenanceScheduler(
task=self._run_retention,
check_interval_seconds=retention_cfg.get("check_interval_seconds", 3600),
run_at_hour=retention_cfg.get("run_at_hour", 3),
)
self._maintenance.start()
def _stop_maintenance(self):
"""Detiene el mantenimiento diario."""
if self._maintenance:
self._maintenance.stop()
self._maintenance = None
def _restart_maintenance(self):
"""Reinicia el mantenimiento diario con nueva configuración."""
if self._running:
self._stop_maintenance()
self._start_maintenance()
def _run_retention(self):
"""Ejecuta una corrida de retención (tarea del mantenimiento diario)."""
try:
RetentionCleaner(self._config).run()
except Exception as e:
app_logger.error(f"Error en la retención: {e}", exc_info=True)
EventRepository.create("ERROR", f"Error en la retención: {e}")
def trigger_maintenance_now(self):
"""Corre la retención de inmediato en segundo plano (acción manual de la UI).
Respeta el dry_run de la configuración y no consume el turno del día calendario.
"""
def _run():
if self._maintenance:
self._maintenance.trigger_now()
else:
self._run_retention()
Thread(target=_run, daemon=True).start()
def _on_file_ready(self, file_path: str):
"""
@@ -296,10 +399,18 @@ class RestoreEngine(QObject):
# Calcular hash para evitar duplicados
file_hash = calculate_file_hash(file_path)
if JobRepository.exists_by_hash(file_hash):
app_logger.warning(f"Archivo ya procesado (hash duplicado): {file_path}")
if JobRepository.has_blocking_job_by_hash(file_hash):
app_logger.warning(f"Archivo ya procesado o en curso (hash duplicado): {file_path}")
return
# Limpia intentos FALLIDOS previos con este hash para permitir un reintento fresco
# (antes un FAILED transitorio bloqueaba el reproceso de forma permanente).
removed = JobRepository.delete_failed_by_hash(file_hash)
if removed:
app_logger.info(
f"Reintento de {Path(file_path).name}: {removed} job(s) fallido(s) previo(s) eliminado(s)"
)
# Crear job
file_name = Path(file_path).name
job_id = JobRepository.create(file_path, file_name, file_hash)

View File

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

View File

@@ -146,11 +146,23 @@ class RestoreWorker(QRunnable):
EventRepository.create("ERROR", f"Job falló: {error_msg}", self.job_id)
job = JobRepository.get(self.job_id)
# Mover el ZIP fallido a Fallados/<fecha>/ para diagnóstico y descarga desde el panel.
try:
if job:
self._move_zip_to_failed(job)
except Exception as move_err:
app_logger.warning(f"No se pudo mover ZIP a Fallados: {move_err}")
self._report_to_panel(job, "failed", error_message=error_msg)
self.signals.error_occurred.emit(self.job_id, error_msg)
self.signals.job_completed.emit(self.job_id, False)
finally:
# Garantiza la limpieza del Temp en TODOS los caminos (éxito, fallo, diferido,
# forward). En éxito _cleanup ya lo eliminó; aquí es red de seguridad.
self._purge_extract_dir()
def _resolve_route(self, job) -> dict:
"""
Enrutamiento automático vía panel: restore_local o forward según nodo/asignación.
@@ -185,12 +197,41 @@ class RestoreWorker(QRunnable):
return route
def _collect_zip_paths(self, source_path: str) -> list[str]:
"""Rutas locales del ZIP (incluye todas las partes multipart)."""
"""
Rutas locales del ZIP (incluye todas las partes multipart).
La comparación es insensible a mayúsculas en TODOS los pasos, porque en este dominio
los respaldos llegan como .ZIP con frecuencia. Antes se hacía
`path.stem.split(".zip")[0]`, que con "EMPRESA.ZIP.001" dejaba base_name="EMPRESA.ZIP"
y armaba el glob "EMPRESA.ZIP.zip.*": no encontraba nada y devolvía lista vacía. Como
_move_zip_to_processed y _move_zip_to_failed iteran sobre este resultado, las partes
nunca salían de Entrada y se acumulaban mezcladas con los pendientes.
Tampoco se usa glob(): en Linux distingue mayúsculas, así que un patrón en minúsculas
seguiría sin encontrar las partes en MAYÚSCULAS. Se filtra iterdir() comparando en
minúsculas, que funciona igual en Windows y en Linux.
"""
path = Path(source_path)
if 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]
if not SevenZipExtractor.is_multipart(str(path)):
return [str(path)]
# "EMPRESA.ZIP.001" -> stem "EMPRESA.ZIP" -> base "EMPRESA" (sin importar la caja).
stem = path.stem
base_name = stem[:-4] if stem.lower().endswith(".zip") else stem
prefix = f"{base_name}.zip.".lower()
parts = sorted(
(item for item in path.parent.iterdir() if item.name.lower().startswith(prefix)),
key=lambda item: item.name.lower(),
)
if parts:
return [str(item) for item in parts]
# Sin partes localizadas se devuelve el archivo original: es preferible mover solo esa
# parte a no mover nada y dejarla atorada en Entrada para siempre.
app_logger.warning(
f"No se localizaron las partes multipart de {path.name}; se usa solo ese archivo"
)
return [str(path)]
def _move_zip_to_processed(self, job):
@@ -205,15 +246,53 @@ class RestoreWorker(QRunnable):
shutil.move(str(src), str(dest))
app_logger.info(f"Movido: {src.name} -> {dest}")
def _move_zip_to_failed(self, job):
"""Mueve el ZIP (y partes multipart) a Fallados/<fecha>/ para diagnóstico y descarga.
Antes los fallidos quedaban en Entrada (mezclados con pendientes y bloqueando el
pickup por dedup). Al moverlos a Fallados, el panel puede listarlos/descargarlos por
restaurador con la misma lógica relativa que Procesados.
"""
failed_folder = Path(self.config["paths"]["failed_folder"])
date_folder = failed_folder / datetime.now().strftime("%Y-%m-%d")
date_folder.mkdir(parents=True, exist_ok=True)
for zip_path in self._collect_zip_paths(job.source_path):
src = Path(zip_path)
if not src.exists():
continue
dest = date_folder / src.name
shutil.move(str(src), str(dest))
app_logger.info(f"Movido a Fallados: {src.name} -> {dest}")
def _purge_extract_dir(self):
"""Elimina la carpeta temporal de extracción si quedó (best-effort, cualquier salida).
_cleanup() solo corre en éxito; sin esto, los caminos de fallo/diferido dejan
`Temp/<job_id>/` huérfano acumulándose. Se invoca en el `finally` de run().
"""
try:
if self._extract_dir and Path(self._extract_dir).exists():
shutil.rmtree(self._extract_dir, ignore_errors=True)
app_logger.info(f"Temp de extracción purgado: {self._extract_dir}")
except Exception as e:
app_logger.warning(f"No se pudo purgar Temp {self._extract_dir}: {e}")
def _forward_zip(self, job, route: dict, start_time: float):
"""Reenvía el ZIP al input_folder del servidor destino vía SFTP."""
"""Reenvía el ZIP al input_folder del servidor destino vía SFTP.
La entrega exitosa por SFTP es el punto de no retorno: en cuanto la subida se confirma,
el job se marca COMPLETED/forwarded ANTES de cualquier tarea de limpieza local. Así, un
error POSTERIOR a la entrega (p.ej. mover el ZIP a Procesados) ya no degrada el job a
fallido ni reporta 'failed' al panel — el respaldo sí llegó al destino.
"""
target = route["target"]
self._target = target
remote_folder = target["input_folder"]
zip_paths = self._collect_zip_paths(job.source_path)
step_id = JobStepRepository.create(self.job_id, StepType.FORWARD_ZIP)
try:
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,
@@ -221,15 +300,18 @@ class RestoreWorker(QRunnable):
stdout=f"Destino: {target.get('name')} ({len(uploaded)} archivo(s))",
)
except Exception as e:
# Envío parcial: limpia best-effort las partes ya subidas para no dejar una
# restauración a medias en el destino, marca el step fallido y re-lanza.
partial = getattr(e, "uploaded", None)
if partial:
self._cleanup_partial_forward(target, partial)
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
raise
self._move_zip_to_processed(job)
# --- Entrega confirmada: commit del éxito ANTES de cualquier limpieza local ---
total_ms = int((time.time() - start_time) * 1000)
JobRepository.update_timing(self.job_id, total_ms=total_ms)
JobRepository.update_status(self.job_id, JobStatus.COMPLETED)
app_logger.info(
f"Job {self.job_id} reenviado a '{target.get('name')}' en {total_ms}ms"
)
@@ -239,8 +321,33 @@ class RestoreWorker(QRunnable):
self.job_id,
)
self._report_to_panel(job, "forwarded", duration_ms=total_ms)
# Housekeeping best-effort: si el move falla, el job SIGUE siendo forwarded y el ZIP
# queda en Entrada (bloqueado por dedup de hash 'completed', no se reenvía en bucle).
try:
self._move_zip_to_processed(job)
except Exception as e:
app_logger.warning(
f"Reenvío OK pero no se pudo mover el ZIP a Procesados: {e}"
)
EventRepository.create(
"WARNING",
f"Reenvío exitoso; el ZIP quedó en Entrada (no se pudo mover a Procesados): {e}",
self.job_id,
)
self.signals.job_completed.emit(self.job_id, True)
def _cleanup_partial_forward(self, target: dict, uploaded: list) -> None:
"""Borra best-effort del destino las partes ya subidas tras un fallo de reenvío."""
for remote_path in uploaded:
try:
sftp_copy.cleanup_remote(target, remote_path)
except Exception as e:
app_logger.warning(
f"No se pudo limpiar la parte remota {remote_path}: {e}"
)
def _report_to_panel(
self,
job,
@@ -479,28 +586,9 @@ class RestoreWorker(QRunnable):
shutil.rmtree(self._extract_dir)
app_logger.info(f"Carpeta de extracción eliminada: {self._extract_dir}")
# Mover ZIP a 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)
source_path = Path(job.source_path)
dest_path = date_folder / source_path.name
# Si es multipart, mover todas las partes
if SevenZipExtractor.is_multipart(str(source_path)):
# Buscar todas las partes
base_name = source_path.stem.split('.zip')[0]
parts = list(source_path.parent.glob(f"{base_name}.zip.*"))
for part in parts:
part_dest = date_folder / part.name
shutil.move(str(part), str(part_dest))
app_logger.info(f"Movido: {part.name} -> {part_dest}")
else:
shutil.move(str(source_path), str(dest_path))
app_logger.info(f"Movido: {source_path.name} -> {dest_path}")
# Mover ZIP (y partes multipart) a Processed y registrar rel_path/tamaño.
self._move_zip_to_processed(job)
JobStepRepository.complete(step_id, exit_code=0)
except Exception as e:

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

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

View File

@@ -305,11 +305,24 @@ def report_instance_config(
host_name: Optional[str] = None,
app_version: Optional[str] = None,
instance_key: Optional[str] = None,
processed_folder: Optional[str] = None,
platform_name: Optional[str] = None,
arch: Optional[str] = None,
install_path: Optional[str] = None,
) -> bool:
"""
Reporta la carpeta de entrada vigente a POST /api/restore/instance-config (best-effort).
El panel solo la muestra; CloudRestoreAS es la única fuente de escritura.
platform_name y arch identifican el build de ESTA instalación ("windows"/"linux",
"x86_64"/"arm64"). El PANEL los usa para saber qué artefacto le toca a este servidor
cuando instala o actualiza; sin ellos cae al texto libre de restore_targets.os.
install_path es la carpeta donde vive el ejecutable (APP_DIR). Es un espacio de rutas
DISTINTO de input_folder: ahí abajo está config/.env con las rutas de trabajo que el
operador haya personalizado. El PANEL la necesita para actualizar en el lugar correcto
en vez de crear una segunda instalación con la configuración por omisión.
Returns:
True si el PANEL aceptó el reporte (200), False en cualquier otro caso.
"""
@@ -325,8 +338,12 @@ def report_instance_config(
key = (instance_key or "").strip() or None
payload = {
"input_folder": input_folder.strip(),
"processed_folder": (processed_folder or "").strip() or None,
"host_name": (host_name or "").strip() or None,
"app_version": (app_version or "").strip() or None,
"platform": (platform_name or "").strip() or None,
"arch": (arch or "").strip() or None,
"install_path": (install_path or "").strip() or None,
}
if key:
payload["instance_key"] = key

View File

@@ -378,6 +378,24 @@ class SQLServerManager:
except Exception:
app_logger.warning("No se pudo cerrar la conexión SQL tras el RESTORE")
@staticmethod
def _sql_path_sep(data_folder: str) -> str:
"""
Separador de rutas que espera el SQL Server destino, deducido del formato
de data_folder. Windows (C:\\..., o contiene '\\') usa '\\'; SQL Server
sobre Linux (rutas POSIX '/var/opt/mssql/...') usa '/'. Las rutas del MOVE
son del filesystem del SERVIDOR, no de donde corre la app.
"""
if "\\" in data_folder:
return "\\"
# Ruta POSIX explícita ("/...") o sin separador Windows → '/'.
if data_folder.startswith("/"):
return "/"
# Heurística: "C:" u otra letra de unidad ⇒ Windows.
if len(data_folder) >= 2 and data_folder[1] == ":":
return "\\"
return "/"
@staticmethod
def _build_move_clauses(db_name, data_folder, logical_files) -> list:
"""
@@ -386,7 +404,13 @@ class SQLServerManager:
- 1er data → {db}.mdf, siguientes → {db}_N.ndf
- 1er log → {db}_log.ldf, siguientes → {db}_log_N.ldf
- otros tipos (FILESTREAM/full-text) → {db}_{nombre_lógico_saneado}
El separador se adapta al SO del SQL Server destino (deducido de
data_folder), de modo que funciona tanto con SQL Server en Windows
(C:\\...\\db.mdf) como en Linux (/var/opt/mssql/data/db.mdf).
"""
sep = SQLServerManager._sql_path_sep(data_folder)
base = data_folder.rstrip("\\/")
clauses = []
data_idx = 0
log_idx = 0
@@ -394,16 +418,16 @@ class SQLServerManager:
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}"
new_path = f"{base}{sep}{db_name}{suffix}.{ext}"
data_idx += 1
elif lf.type == 'L':
suffix = "" if log_idx == 0 else f"_{log_idx}"
new_path = f"{data_folder}\\{db_name}_log{suffix}.ldf"
new_path = f"{base}{sep}{db_name}_log{suffix}.ldf"
log_idx += 1
else:
# No descartar otros tipos: moverlos preservando el nombre lógico.
safe = "".join(c if c.isalnum() else "_" for c in lf.logical_name)
new_path = f"{data_folder}\\{db_name}_{safe}"
new_path = f"{base}{sep}{db_name}_{safe}"
clauses.append(f"MOVE N'{lf.logical_name}' TO N'{new_path}'")
return clauses

View File

@@ -12,6 +12,7 @@ producción conviene fijar/known_hosts las claves de cada servidor.
import ntpath
import posixpath
import time
from pathlib import Path
from typing import Optional
@@ -22,6 +23,12 @@ from ..utils.logger import app_logger
# Timeout de conexión SSH en segundos.
SSH_TIMEOUT = 30
# Verificación de la subida: tras escribir los bytes se confirma el tamaño remoto con
# reintentos. Tolera stats flaky (buffering/AV/locking, típico en OpenSSH/Windows) que de
# otro modo producirían un FALSO FALLO aunque el archivo sí se entregó.
VERIFY_ATTEMPTS = 3
VERIFY_DELAY_SECONDS = 1.0
class SFTPCopyError(Exception):
"""Error al transferir o limpiar el .bak en el servidor remoto vía SFTP."""
@@ -102,12 +109,17 @@ def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str
raise SFTPCopyError("La carpeta remota destino está vacía")
remote_sftp = _sftp_path(str(remote_folder).strip(), src.name)
expected_size = src.stat().st_size
client = _connect(cfg)
try:
sftp = client.open_sftp()
try:
app_logger.info(f"Subiendo archivo por SFTP a {cfg['ssh_host']}: {remote_sftp}")
sftp.put(str(src), remote_sftp)
# confirm=False: la verificación de paramiko hace un stat inmediato que en
# OpenSSH/Windows suele fallar por buffering aunque el archivo sí se escribió.
# Se verifica el tamaño aparte, con reintentos (ver _verify_remote_size).
sftp.put(str(src), remote_sftp, confirm=False)
_verify_remote_size(sftp, remote_sftp, expected_size)
finally:
sftp.close()
except SFTPCopyError:
@@ -120,16 +132,45 @@ def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str
return remote_sftp
def _verify_remote_size(sftp, remote_path: str, expected_size: int) -> None:
"""Confirma que el archivo remoto tiene el tamaño esperado, con reintentos.
Un stat transitoriamente fallido o con tamaño aún incompleto (flush/AV en curso) se
reintenta; solo tras agotar los intentos se considera fallo genuino de entrega.
"""
last_error: Optional[str] = None
for attempt in range(VERIFY_ATTEMPTS):
try:
remote_size = sftp.stat(remote_path).st_size
if remote_size == expected_size:
return
last_error = f"tamaño remoto {remote_size} != esperado {expected_size}"
except Exception as e: # noqa: BLE001 — se reintenta y, si persiste, se eleva abajo
last_error = str(e)
if attempt < VERIFY_ATTEMPTS - 1:
time.sleep(VERIFY_DELAY_SECONDS)
raise SFTPCopyError(
f"No se pudo verificar la subida de {remote_path}: {last_error}"
)
def upload_zip_parts(local_paths: list[str], cfg: dict, remote_folder: str) -> list[str]:
"""
Sube uno o más archivos ZIP (incl. multipart) al input_folder del destino.
Ante un fallo, adjunta a la excepción la lista de partes ya subidas (atributo
``uploaded``) para que el llamador pueda limpiar el envío parcial en el destino.
Returns:
Lista de rutas SFTP subidas.
"""
uploaded: list[str] = []
for local_path in local_paths:
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
try:
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
except SFTPCopyError as e:
e.uploaded = uploaded
raise
return uploaded

View File

@@ -104,24 +104,29 @@ class ConfigTab(QWidget):
self.sql_windows_auth_checkbox = QCheckBox()
self.sql_windows_auth_checkbox.setChecked(True)
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox)
self.sql_username_input = QLineEdit()
self.sql_username_input.setEnabled(False)
sql_layout.addRow("Usuario SQL:", self.sql_username_input)
self.sql_password_input = QLineEdit()
self.sql_password_input.setEchoMode(QLineEdit.EchoMode.Password)
self.sql_password_input.setEnabled(False)
sql_layout.addRow("Contraseña SQL:", self.sql_password_input)
# Conectar el handler DESPUÉS de crear usuario/contraseña: en Linux se
# desmarca el checkbox (abajo), lo que dispararía _on_auth_changed antes de
# que existan esos campos si se conectara arriba.
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
if sys.platform != "win32":
# Windows Auth no aplica en Linux: forzar SQL Auth y habilitar campos.
self.sql_windows_auth_checkbox.setChecked(False)
self.sql_windows_auth_checkbox.setEnabled(False)
self.sql_windows_auth_checkbox.setToolTip(
"Windows Auth no está disponible en Linux; use SQL Auth."
)
self.sql_username_input = QLineEdit()
self.sql_username_input.setEnabled(False)
sql_layout.addRow("Usuario SQL:", self.sql_username_input)
self.sql_password_input = QLineEdit()
self.sql_password_input.setEchoMode(QLineEdit.EchoMode.Password)
self.sql_password_input.setEnabled(False)
sql_layout.addRow("Contraseña SQL:", self.sql_password_input)
test_sql_btn = QPushButton("🔌 Probar Conexión")
test_sql_btn.clicked.connect(self._test_sql_connection)
sql_layout.addRow("", test_sql_btn)
@@ -431,12 +436,17 @@ class ConfigTab(QWidget):
line_edit.setText(folder)
def _browse_seven_zip(self):
"""Abre el diálogo para seleccionar 7z.exe."""
"""Abre el diálogo para seleccionar el ejecutable de 7-Zip."""
if sys.platform == "win32":
title = "Seleccionar 7z.exe"
default_dir = "C:\\Program Files\\7-Zip"
file_filter = "Ejecutables (*.exe)"
else:
title = "Seleccionar 7z / 7zz"
default_dir = ""
file_filter = "Ejecutables (*)"
file_path, _ = QFileDialog.getOpenFileName(
self,
"Seleccionar 7z.exe",
"C:\\Program Files\\7-Zip",
"Ejecutables (*.exe)"
self, title, default_dir, file_filter
)
if file_path:
self.seven_zip_input.setText(file_path)
@@ -475,6 +485,9 @@ class ConfigTab(QWidget):
def _on_auth_changed(self, checked: bool):
"""Maneja el cambio en el tipo de autenticación."""
# Guard: la señal puede dispararse durante la construcción de la UI.
if not hasattr(self, "sql_username_input"):
return
self.sql_username_input.setEnabled(not checked)
self.sql_password_input.setEnabled(not checked)

View File

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

View File

@@ -13,6 +13,8 @@ from .nodes_tab import NodesTab
from .config_tab import ConfigTab
from .logs_tab import LogsTab
from .tray_assets import load_tray_icon
from .. import __version__
from ..constants import APP_ARCH, APP_PLATFORM
from ..engine.engine import RestoreEngine
from ..utils.logger import app_logger
@@ -41,6 +43,7 @@ class MainWindow(QMainWindow):
self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server")
self.setMinimumSize(1200, 800)
self.setWindowIcon(load_tray_icon())
self.tray_icon: QSystemTrayIcon | None = None
self._setup_tray()
@@ -61,9 +64,6 @@ class MainWindow(QMainWindow):
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")
@@ -130,16 +130,26 @@ class MainWindow(QMainWindow):
self.scan_action.triggered.connect(self._scan_now)
motor_menu.addAction(self.scan_action)
self.cleanup_action = QAction("🧹 Limpiar Respaldos Ahora", self)
self.cleanup_action.triggered.connect(self._run_cleanup_now)
motor_menu.addAction(self.cleanup_action)
help_menu = menubar.addMenu("Ayuda")
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."""
def _setup_tray(self, _attempt: int = 0):
"""Configura el icono de bandeja del sistema (reintenta si aún no está listo)."""
if not QSystemTrayIcon.isSystemTrayAvailable():
app_logger.warning("Bandeja del sistema no disponible en este entorno")
if _attempt < 20:
# La bandeja puede no estar lista justo tras el login: reintentar ~10s.
QTimer.singleShot(500, lambda: self._setup_tray(_attempt + 1))
else:
app_logger.warning(
"Bandeja del sistema no disponible tras varios reintentos"
)
return
self.tray_icon = QSystemTrayIcon(self)
@@ -183,14 +193,14 @@ class MainWindow(QMainWindow):
self.engine.signals.config_loaded.connect(self._on_config_loaded)
def closeEvent(self, event: QCloseEvent):
"""Minimiza a bandeja al cerrar la ventana (no sale de la aplicación)."""
"""Nunca cierra la app: minimiza a la bandeja (o a la barra de tareas si no hay bandeja)."""
if self._force_quit:
event.accept()
return
event.ignore()
if self.tray_icon and self.tray_icon.isVisible():
self.hide()
event.ignore()
app_logger.info("Ventana minimizada a bandeja del sistema")
if not self._tray_hint_shown:
self.tray_icon.showMessage(
@@ -201,7 +211,9 @@ class MainWindow(QMainWindow):
)
self._tray_hint_shown = True
else:
event.accept()
# Sin bandeja disponible: minimizar a la barra de tareas, nunca cerrar.
self.showMinimized()
app_logger.info("Ventana minimizada a la barra de tareas (sin bandeja)")
def _on_tray_activated(self, reason):
"""Maneja la activación del icono de tray."""
@@ -276,6 +288,11 @@ class MainWindow(QMainWindow):
self.engine.scan_now()
app_logger.info("Escaneo manual solicitado desde UI")
def _run_cleanup_now(self):
"""Dispara la retención de respaldos obsoletos (respeta el dry_run de la config)."""
self.engine.trigger_maintenance_now()
app_logger.info("Limpieza de respaldos solicitada desde UI")
def _update_stats(self):
"""Actualiza las estadísticas."""
self._update_tray_status()
@@ -302,7 +319,7 @@ class MainWindow(QMainWindow):
QMessageBox.about(
self,
"Acerca de CloudRestoreAS",
"CloudRestoreAS v1.0.0\n\n"
f"CloudRestoreAS v{__version__} ({APP_PLATFORM}/{APP_ARCH})\n\n"
"Aplicación de restauración automática de bases de datos SQL Server.\n\n"
"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"

170
build-all.sh Executable file
View File

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

View File

@@ -8,75 +8,75 @@ CloudRestoreAS - Build Linux (onefile)
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):
46 INFO: PyInstaller: 6.20.0, contrib hooks: 2026.6
46 INFO: Python: 3.10.12
47 INFO: Platform: Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.35
47 INFO: Python environment: /app/venv-linux
49 INFO: Removing temporary files and cleaning cache in /root/.cache/pyinstaller
77 WARNING: Failed to collect submodules for 'PySide6.scripts.deploy_lib' because importing 'PySide6.scripts.deploy_lib' raised: ModuleNotFoundError: No module named 'project_lib'
604 INFO: Module search paths (PYTHONPATH):
['/app',
'/usr/lib/python310.zip',
'/usr/lib/python3.10',
'/usr/lib/python3.10/lib-dynload',
'/app/venv-linux/lib/python3.10/site-packages',
'/app']
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'
684 INFO: Appending 'binaries' from .spec
688 INFO: Appending 'datas' from .spec
745 INFO: checking Analysis
745 INFO: Building Analysis because Analysis-00.toc is non existent
745 INFO: Looking for Python shared library...
753 INFO: Using Python shared library: /lib/x86_64-linux-gnu/libpython3.10.so.1.0
753 INFO: Running Analysis Analysis-00.toc
753 INFO: Target bytecode optimization level: 0
753 INFO: Initializing module dependency graph...
753 INFO: Initializing module graph hook caches...
759 INFO: Analyzing modules for base_library.zip ...
989 INFO: Processing standard module hook 'hook-heapq.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
1019 INFO: Processing standard module hook 'hook-encodings.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
1674 INFO: Processing standard module hook 'hook-pickle.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2243 INFO: Caching module dependency graph...
2266 INFO: Analyzing /app/runner.py
2280 INFO: Processing standard module hook 'hook-PySide6.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2378 INFO: Processing standard module hook 'hook-shiboken6.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2455 INFO: Processing standard module hook 'hook-PySide6.QtNetwork.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
2973 INFO: Processing standard module hook 'hook-PySide6.QtCore.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
3365 INFO: Processing standard module hook 'hook-PySide6.QtWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
3866 INFO: Processing standard module hook 'hook-PySide6.QtGui.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
4329 INFO: Processing standard module hook 'hook-sqlite3.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
4522 INFO: Processing standard module hook 'hook-platform.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
4540 INFO: Processing standard module hook 'hook-cryptography.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
4807 INFO: hook-cryptography: cryptography does not seem to be using dynamically linked OpenSSL.
4855 INFO: Processing standard module hook 'hook-pyodbc.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
4893 INFO: Processing standard module hook 'hook-urllib3.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
4961 INFO: Processing pre-safe-import-module hook 'hook-backports.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/pre_safe_import_module'
4962 INFO: SetuptoolsInfo: initializing cached setuptools info...
5130 INFO: Processing pre-safe-import-module hook 'hook-typing_extensions.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/pre_safe_import_module'
5213 INFO: Processing standard module hook 'hook-multiprocessing.util.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
5241 INFO: Processing standard module hook 'hook-xml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
5365 INFO: Processing standard module hook 'hook-_ctypes.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
5819 INFO: Processing standard module hook 'hook-certifi.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
5839 INFO: Processing standard module hook 'hook-charset_normalizer.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
6028 INFO: Processing standard module hook 'hook-bcrypt.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
6348 INFO: Processing standard module hook 'hook-difflib.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
6488 INFO: Processing standard module hook 'hook-nacl.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/stdhooks'
6645 INFO: Analyzing hidden import 'PySide6.Qt3DAnimation'
6658 INFO: Processing standard module hook 'hook-PySide6.Qt3DAnimation.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
6777 INFO: Processing standard module hook 'hook-PySide6.Qt3DCore.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
6965 INFO: Processing standard module hook 'hook-PySide6.Qt3DRender.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8337 INFO: Processing standard module hook 'hook-PySide6.QtOpenGL.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8430 INFO: Analyzing hidden import 'PySide6.Qt3DExtras'
8463 INFO: Processing standard module hook 'hook-PySide6.Qt3DExtras.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8542 INFO: Analyzing hidden import 'PySide6.Qt3DInput'
8552 INFO: Processing standard module hook 'hook-PySide6.Qt3DInput.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8628 INFO: Analyzing hidden import 'PySide6.Qt3DLogic'
8629 INFO: Processing standard module hook 'hook-PySide6.Qt3DLogic.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8708 INFO: Analyzing hidden import 'PySide6.QtAsyncio'
8724 INFO: Analyzing hidden import 'PySide6.QtBluetooth'
8756 INFO: Processing standard module hook 'hook-PySide6.QtBluetooth.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
8838 INFO: Analyzing hidden import 'PySide6.QtCanvasPainter'
8909 INFO: Processing standard module hook 'hook-PySide6.QtQuick.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
9019 INFO: Processing standard module hook 'hook-PySide6.QtQml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
--- Logging error ---
Traceback (most recent call last):
File "/usr/lib/python3.10/logging/__init__.py", line 1100, in emit
@@ -165,334 +165,335 @@ Call stack:
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
10247 INFO: Analyzing hidden import 'PySide6.QtCharts'
10302 INFO: Processing standard module hook 'hook-PySide6.QtCharts.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10383 INFO: Analyzing hidden import 'PySide6.QtConcurrent'
10385 INFO: Processing standard module hook 'hook-PySide6.QtConcurrent.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10457 INFO: Analyzing hidden import 'PySide6.QtDBus'
10474 INFO: Processing standard module hook 'hook-PySide6.QtDBus.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10549 INFO: Analyzing hidden import 'PySide6.QtDataVisualization'
10610 INFO: Processing standard module hook 'hook-PySide6.QtDataVisualization.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10687 INFO: Analyzing hidden import 'PySide6.QtDesigner'
10701 INFO: Processing standard module hook 'hook-PySide6.QtDesigner.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10809 INFO: Analyzing hidden import 'PySide6.QtGraphs'
10882 INFO: Processing standard module hook 'hook-PySide6.QtGraphs.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
10963 INFO: Analyzing hidden import 'PySide6.QtGraphsWidgets'
10985 INFO: Processing standard module hook 'hook-PySide6.QtGraphsWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11060 INFO: Processing standard module hook 'hook-PySide6.QtQuickWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11138 INFO: Analyzing hidden import 'PySide6.QtHelp'
11145 INFO: Processing standard module hook 'hook-PySide6.QtHelp.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11233 INFO: Analyzing hidden import 'PySide6.QtHttpServer'
11239 INFO: Processing standard module hook 'hook-PySide6.QtHttpServer.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11318 INFO: Analyzing hidden import 'PySide6.QtLocation'
11345 INFO: Processing standard module hook 'hook-PySide6.QtLocation.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11461 INFO: Processing standard module hook 'hook-PySide6.QtPositioning.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11557 INFO: Analyzing hidden import 'PySide6.QtMultimedia'
11595 INFO: Processing standard module hook 'hook-PySide6.QtMultimedia.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11688 INFO: Analyzing hidden import 'PySide6.QtMultimediaWidgets'
11690 INFO: Processing standard module hook 'hook-PySide6.QtMultimediaWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11769 INFO: Analyzing hidden import 'PySide6.QtNetworkAuth'
11781 INFO: Processing standard module hook 'hook-PySide6.QtNetworkAuth.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11861 INFO: Analyzing hidden import 'PySide6.QtNfc'
11870 INFO: Processing standard module hook 'hook-PySide6.QtNfc.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
11945 INFO: Analyzing hidden import 'PySide6.QtOpenGLWidgets'
11947 INFO: Processing standard module hook 'hook-PySide6.QtOpenGLWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12070 INFO: Analyzing hidden import 'PySide6.QtPdf'
12103 INFO: Processing standard module hook 'hook-PySide6.QtPdf.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12211 INFO: Analyzing hidden import 'PySide6.QtPdfWidgets'
12214 INFO: Processing standard module hook 'hook-PySide6.QtPdfWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12294 INFO: Analyzing hidden import 'PySide6.QtPrintSupport'
12302 INFO: Processing standard module hook 'hook-PySide6.QtPrintSupport.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12389 INFO: Analyzing hidden import 'PySide6.QtQuick3D'
12395 INFO: Processing standard module hook 'hook-PySide6.QtQuick3D.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12474 INFO: Analyzing hidden import 'PySide6.QtQuickControls2'
12475 INFO: Processing standard module hook 'hook-PySide6.QtQuickControls2.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12555 INFO: Analyzing hidden import 'PySide6.QtQuickTest'
12556 INFO: Analyzing hidden import 'PySide6.QtRemoteObjects'
12566 INFO: Processing standard module hook 'hook-PySide6.QtRemoteObjects.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12642 INFO: Analyzing hidden import 'PySide6.QtScxml'
12650 INFO: Processing standard module hook 'hook-PySide6.QtScxml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12739 INFO: Analyzing hidden import 'PySide6.QtSensors'
12753 INFO: Processing standard module hook 'hook-PySide6.QtSensors.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12847 INFO: Analyzing hidden import 'PySide6.QtSerialBus'
12864 INFO: Processing standard module hook 'hook-PySide6.QtSerialBus.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
12975 INFO: Analyzing hidden import 'PySide6.QtSerialPort'
12979 INFO: Processing standard module hook 'hook-PySide6.QtSerialPort.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13071 INFO: Analyzing hidden import 'PySide6.QtSpatialAudio'
13081 INFO: Processing standard module hook 'hook-PySide6.QtSpatialAudio.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13194 INFO: Analyzing hidden import 'PySide6.QtSql'
13212 INFO: Processing standard module hook 'hook-PySide6.QtSql.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13330 INFO: Analyzing hidden import 'PySide6.QtStateMachine'
13338 INFO: Processing standard module hook 'hook-PySide6.QtStateMachine.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13425 INFO: Analyzing hidden import 'PySide6.QtSvg'
13429 INFO: Processing standard module hook 'hook-PySide6.QtSvg.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13515 INFO: Analyzing hidden import 'PySide6.QtSvgWidgets'
13517 INFO: Processing standard module hook 'hook-PySide6.QtSvgWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13604 INFO: Analyzing hidden import 'PySide6.QtTest'
13615 INFO: Processing standard module hook 'hook-PySide6.QtTest.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13693 INFO: Analyzing hidden import 'PySide6.QtTextToSpeech'
13698 INFO: Processing standard module hook 'hook-PySide6.QtTextToSpeech.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13795 INFO: Analyzing hidden import 'PySide6.QtUiTools'
13796 INFO: Processing standard module hook 'hook-PySide6.QtUiTools.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13886 INFO: Analyzing hidden import 'PySide6.QtWebChannel'
13888 INFO: Processing standard module hook 'hook-PySide6.QtWebChannel.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
13970 INFO: Analyzing hidden import 'PySide6.QtWebEngineCore'
14022 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineCore.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14173 INFO: Analyzing hidden import 'PySide6.QtWebEngineQuick'
14176 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineQuick.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14316 INFO: Analyzing hidden import 'PySide6.QtWebEngineWidgets'
14320 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineWidgets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14411 INFO: Analyzing hidden import 'PySide6.QtWebSockets'
14416 INFO: Processing standard module hook 'hook-PySide6.QtWebSockets.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14492 INFO: Analyzing hidden import 'PySide6.QtWebView'
14495 INFO: Analyzing hidden import 'PySide6.QtXml'
14505 INFO: Processing standard module hook 'hook-PySide6.QtXml.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14580 INFO: Analyzing hidden import 'PySide6._config'
14581 INFO: Analyzing hidden import 'PySide6._git_pyside_version'
14581 INFO: Analyzing hidden import 'PySide6.scripts'
14582 INFO: Analyzing hidden import 'PySide6.scripts.android_deploy'
14584 INFO: Analyzing hidden import 'PySide6.scripts.deploy'
14587 INFO: Analyzing hidden import 'PySide6.scripts.metaobjectdump'
14593 INFO: Analyzing hidden import 'PySide6.scripts.project'
14599 INFO: Analyzing hidden import 'PySide6.scripts.project_lib'
14629 INFO: Processing standard module hook 'hook-xml.etree.cElementTree.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14633 INFO: Processing pre-safe-import-module hook 'hook-tomli.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/pre_safe_import_module'
14654 INFO: Analyzing hidden import 'PySide6.scripts.pyside_tool'
14666 INFO: Processing standard module hook 'hook-sysconfig.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
14670 INFO: Analyzing hidden import 'PySide6.scripts.qml'
14673 INFO: Analyzing hidden import 'PySide6.scripts.qtpy2cpp'
14675 INFO: Analyzing hidden import 'PySide6.support'
14675 INFO: Analyzing hidden import 'PySide6.support.deprecated'
14675 INFO: Analyzing hidden import 'PySide6.support.generate_pyi'
14677 INFO: Processing module hooks (post-graph stage)...
14758 INFO: Performing binary vs. data reclassification (3662 entries)
15617 INFO: Looking for ctypes DLLs
15620 INFO: Analyzing run-time hooks ...
15623 INFO: Including run-time hook 'pyi_rth_inspect.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
15624 INFO: Including run-time hook 'pyi_rth_pkgutil.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
15625 INFO: Including run-time hook 'pyi_rth_multiprocessing.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
15625 INFO: Including run-time hook 'pyi_rth_cryptography_openssl.py' from '/app/venv-linux/lib/python3.10/site-packages/_pyinstaller_hooks_contrib/rthooks'
15626 INFO: Including run-time hook 'pyi_rth_pyside6.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/rthooks'
15627 INFO: Processing pre-find-module-path hook 'hook-_pyi_rth_utils.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks/pre_find_module_path'
15628 INFO: Processing standard module hook 'hook-_pyi_rth_utils.py' from '/app/venv-linux/lib/python3.10/site-packages/PyInstaller/hooks'
15694 INFO: Creating base_library.zip...
15704 INFO: Looking for dynamic libraries
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6FFmpegStub-crypto.so.3'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6FFmpegStub-ssl.so.3'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6FFmpegStub-va-drm.so.2'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6FFmpegStub-va-x11.so.2'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6FFmpegStub-va.so.2'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6Multimedia.so.6'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6MultimediaQuick.so.6'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6MultimediaWidgets.so.6'.
20131 WARNING: Library not found: could not resolve 'libpcsclite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6Nfc.so.6'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6Quick3DSpatialAudio.so.6'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6SpatialAudio.so.6'.
20131 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6TextToSpeech.so.6'.
20131 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20131 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20132 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20132 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20132 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineCore.so.6'.
20132 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineQuick.so.6'.
20132 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6WebEngineWidgets.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-icccm.so.4', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-keysyms.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-util.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-image.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-render.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-cursor.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-render-util.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxkbcommon-x11.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libxcb-shape.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libQt6XcbQpa.so.6'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavcodec.so.61'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavcodec.so.61.19.101'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavformat.so.61'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavformat.so.61.7.100'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavutil.so.59'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavutil.so.59.39.100'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswresample.so.5'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswresample.so.5.3.100'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswscale.so.8'.
20132 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswscale.so.8.3.100'.
20132 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20132 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/libexec/QtWebEngineProcess'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavcodec.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavformat.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libavutil.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswresample.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/lib/libswscale.so'.
20133 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/designer/libqwebengineview.so'.
20133 WARNING: Library not found: could not resolve 'libQt6EglFsKmsGbmSupport.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/egldeviceintegrations/libqeglfs-kms-integration.so'.
20133 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/multimedia/libffmpegmediaplugin.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/multimedia/libffmpegmediaplugin.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-icccm.so.4', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-keysyms.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-util.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-image.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-render.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-cursor.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-render-util.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxkbcommon-x11.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-shape.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platforms/libqxcb.so'.
20133 WARNING: Library not found: could not resolve 'libgtk-3.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libcairo.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libpango-1.0.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libgdk-3.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libharfbuzz.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libgdk_pixbuf-2.0.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libpangocairo-1.0.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libatk-1.0.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libcairo-gobject.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/platformthemes/libqgtk3.so'.
20133 WARNING: Library not found: could not resolve 'libcups.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/printsupport/libcupsprintersupport.so'.
20133 WARNING: Library not found: could not resolve 'libfbclient.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/sqldrivers/libqsqlibase.so'.
20133 WARNING: Library not found: could not resolve 'libmimerapi.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/sqldrivers/libqsqlmimer.so'.
20133 WARNING: Library not found: could not resolve 'libmysqlclient.so.21', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/sqldrivers/libqsqlmysql.so'.
20133 WARNING: Library not found: could not resolve 'libclntsh.so.23.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/sqldrivers/libqsqloci.so'.
20133 WARNING: Library not found: could not resolve 'libpq.so.5', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/sqldrivers/libqsqlpsql.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/texttospeech/libqtexttospeech_mock.so'.
20133 WARNING: Library not found: could not resolve 'libspeechd.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/texttospeech/libqtexttospeech_speechd.so'.
20133 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/texttospeech/libqtexttospeech_speechd.so'.
20133 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/webview/libqtwebview_webengine.so'.
20133 WARNING: Library not found: could not resolve 'libxcb-icccm.so.4', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-keysyms.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-util.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-image.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-render.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-cursor.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-render-util.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxkbcommon-x11.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-shape.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-egl-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-icccm.so.4', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-keysyms.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-util.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-image.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-render.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-cursor.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-render-util.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxkbcommon-x11.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libxcb-shape.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/plugins/xcbglintegrations/libqxcb-glx-integration.so'.
20134 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtMultimedia/libquickmultimediaplugin.so'.
20134 WARNING: Library not found: could not resolve 'libQt6QuickShapesDesignHelpers.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtQuick/Shapes/DesignHelpers/libqtquickshapesdesignhelpersplugin.so'.
20134 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtQuick/VirtualKeyboard/Components/libqtvkbcomponentsplugin.so'.
20134 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtQuick3D/SpatialAudio/libquick3dspatialaudioplugin.so'.
20134 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtTextToSpeech/libtexttospeechqmlplugin.so'.
20134 WARNING: Library not found: could not resolve 'libQt6WaylandCompositorIviapplication.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWayland/Compositor/IviApplication/libwaylandcompositoriviapplicationplugin.so'.
20134 WARNING: Library not found: could not resolve 'libQt6WaylandCompositorPresentationTime.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWayland/Compositor/PresentationTime/libwaylandcompositorpresentationtimeplugin.so'.
20134 WARNING: Library not found: could not resolve 'libQt6WaylandCompositorWLShell.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWayland/Compositor/WlShell/libwaylandcompositorwlshellplugin.so'.
20134 WARNING: Library not found: could not resolve 'libQt6WaylandCompositorXdgShell.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWayland/Compositor/XdgShell/libwaylandcompositorxdgshellplugin.so'.
20134 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/Qt/qml/QtWebEngine/libqtwebenginequickplugin.so'.
20134 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineWidgets.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20134 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineQuick.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXcomposite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libsmime3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libnssutil3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libnspr4.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXdamage.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libasound.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libnss3.so', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXrandr.so.2', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libXtst.so.6', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libxkbfile.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtWebEngineCore.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtTextToSpeech.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtSpatialAudio.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libpcsclite.so.1', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtNfc.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtMultimediaWidgets.abi3.so'.
20135 WARNING: Library not found: could not resolve 'libpulse.so.0', dependency of '/app/venv-linux/lib/python3.10/site-packages/PySide6/QtMultimedia.abi3.so'.
20194 INFO: Warnings written to /tmp/cloudrestore-build/CloudRestoreAS/warn-CloudRestoreAS.txt
20218 INFO: Graph cross-reference written to /tmp/cloudrestore-build/CloudRestoreAS/xref-CloudRestoreAS.html
20316 INFO: checking PYZ
20316 INFO: Building PYZ because PYZ-00.toc is non existent
20316 INFO: Building PYZ (ZlibArchive) /tmp/cloudrestore-build/CloudRestoreAS/PYZ-00.pyz
20520 INFO: Building PYZ (ZlibArchive) /tmp/cloudrestore-build/CloudRestoreAS/PYZ-00.pyz completed successfully.
20524 WARNING: Ignoring icon; supported only on Windows and macOS!
20569 INFO: checking PKG
20569 INFO: Building PKG because PKG-00.toc is non existent
20569 INFO: Building PKG (CArchive) CloudRestoreAS.pkg
88430 INFO: Building PKG (CArchive) CloudRestoreAS.pkg completed successfully.
88476 INFO: Bootloader /app/venv-linux/lib/python3.10/site-packages/PyInstaller/bootloader/Linux-64bit-intel/run
88476 INFO: checking EXE
88476 INFO: Building EXE because EXE-00.toc is non existent
88476 INFO: Building EXE from EXE-00.toc
88476 INFO: Copying bootloader EXE to /app/dist/CloudRestoreAS
88477 INFO: Appending PKG archive to custom ELF section in EXE
90592 INFO: Building EXE from EXE-00.toc completed successfully.
90652 INFO: Build complete! The results are available in: /app/dist
Build OK: /app/dist/CloudRestoreAS
Distribuir solo el binario; al ejecutar crea config/ automaticamente.

View File

@@ -12,13 +12,13 @@ Python: C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\python.exe
[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):
937 INFO: PyInstaller: 6.20.0, contrib hooks: 2026.6
937 INFO: Python: 3.13.3
963 INFO: Platform: Windows-11-10.0.26200-SP0
963 INFO: Python environment: \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows
971 INFO: Removing temporary files and cleaning cache in C:\Users\Hugo Reyes\AppData\Local\pyinstaller
1653 WARNING: Failed to collect submodules for 'PySide6.scripts.deploy_lib' because importing 'PySide6.scripts.deploy_lib' raised: ModuleNotFoundError: No module named 'project_lib'
41112 INFO: Module search paths (PYTHONPATH):
['\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS',
'C:\\Users\\Hugo '
'Reyes\\AppData\\Local\\Programs\\Python\\Python313\\python313.zip',
@@ -31,84 +31,84 @@ Workpath: C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build
'\\\\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'
42264 INFO: Appending 'binaries' from .spec
43050 INFO: Appending 'datas' from .spec
51557 INFO: checking Analysis
51557 INFO: Building Analysis because Analysis-00.toc is non existent
51557 INFO: Looking for Python shared library...
51557 INFO: Using Python shared library: C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\python313.dll
51557 INFO: Running Analysis Analysis-00.toc
51557 INFO: Target bytecode optimization level: 0
51557 INFO: Initializing module dependency graph...
51558 INFO: Initializing module graph hook caches...
51674 INFO: Analyzing modules for base_library.zip ...
54725 INFO: Processing standard module hook 'hook-encodings.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
56081 INFO: Processing standard module hook 'hook-heapq.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
58920 INFO: Processing standard module hook 'hook-pickle.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
63112 INFO: Caching module dependency graph...
63151 INFO: Analyzing \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\runner.py
63182 INFO: Processing standard module hook 'hook-PySide6.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
63885 INFO: Processing standard module hook 'hook-shiboken6.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
64039 INFO: Processing standard module hook 'hook-PySide6.QtNetwork.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
68821 INFO: Processing standard module hook 'hook-PySide6.QtCore.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
73034 INFO: Processing standard module hook 'hook-PySide6.QtWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
77556 INFO: Processing standard module hook 'hook-PySide6.QtGui.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
106235 INFO: Processing standard module hook 'hook-sqlite3.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
108350 INFO: Processing standard module hook 'hook-platform.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
108409 INFO: Processing standard module hook 'hook-_ctypes.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
108712 INFO: Processing standard module hook 'hook-cryptography.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
112516 INFO: hook-cryptography: cryptography does not seem to be using dynamically linked OpenSSL.
112922 INFO: Processing standard module hook 'hook-pyodbc.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
113246 INFO: Processing standard module hook 'hook-urllib3.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
113483 INFO: Processing pre-safe-import-module hook 'hook-backports.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
113491 INFO: SetuptoolsInfo: initializing cached setuptools info...
117037 INFO: Setuptools: 'backports' appears to be a full setuptools-vendored copy - creating alias to 'setuptools._vendor.backports'!
117060 INFO: Processing standard module hook 'hook-setuptools.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
117128 INFO: Processing pre-safe-import-module hook 'hook-distutils.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
117267 INFO: Processing standard module hook 'hook-sysconfig.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
117294 INFO: Processing pre-safe-import-module hook 'hook-jaraco.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
117301 INFO: Setuptools: 'jaraco' appears to be a full setuptools-vendored copy - creating alias to 'setuptools._vendor.jaraco'!
117334 INFO: Processing pre-safe-import-module hook 'hook-more_itertools.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
117337 INFO: Setuptools: 'more_itertools' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.more_itertools'!
117657 INFO: Processing pre-safe-import-module hook 'hook-typing_extensions.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
117854 INFO: Processing pre-safe-import-module hook 'hook-packaging.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
118406 INFO: Processing standard module hook 'hook-setuptools._vendor.jaraco.text.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
118438 INFO: Processing pre-safe-import-module hook 'hook-importlib_resources.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
119734 INFO: Processing pre-safe-import-module hook 'hook-importlib_metadata.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
119737 INFO: Setuptools: 'importlib_metadata' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.importlib_metadata'!
119800 INFO: Processing standard module hook 'hook-setuptools._vendor.importlib_metadata.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
120023 INFO: Processing pre-safe-import-module hook 'hook-zipp.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
120028 INFO: Setuptools: 'zipp' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.zipp'!
120782 INFO: Processing pre-safe-import-module hook 'hook-tomli.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
120786 INFO: Setuptools: 'tomli' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.tomli'!
121992 INFO: Processing pre-safe-import-module hook 'hook-wheel.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module'
121995 INFO: Setuptools: 'wheel' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.wheel'!
123153 INFO: Processing standard module hook 'hook-certifi.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
123371 INFO: Processing standard module hook 'hook-charset_normalizer.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
124596 INFO: Processing standard module hook 'hook-bcrypt.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
126239 INFO: Processing standard module hook 'hook-difflib.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
126586 INFO: Processing standard module hook 'hook-multiprocessing.util.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
126737 INFO: Processing standard module hook 'hook-xml.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
128497 INFO: Processing standard module hook 'hook-nacl.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks'
130237 INFO: Analyzing hidden import 'PySide6.Qt3DAnimation'
130274 INFO: Processing standard module hook 'hook-PySide6.Qt3DAnimation.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
131678 INFO: Processing standard module hook 'hook-PySide6.Qt3DCore.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
133194 INFO: Processing standard module hook 'hook-PySide6.Qt3DRender.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
144160 INFO: Processing standard module hook 'hook-PySide6.QtOpenGL.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
148253 INFO: Analyzing hidden import 'PySide6.Qt3DExtras'
148333 INFO: Processing standard module hook 'hook-PySide6.Qt3DExtras.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
150312 INFO: Analyzing hidden import 'PySide6.Qt3DInput'
150345 INFO: Processing standard module hook 'hook-PySide6.Qt3DInput.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
151944 INFO: Analyzing hidden import 'PySide6.Qt3DLogic'
151963 INFO: Processing standard module hook 'hook-PySide6.Qt3DLogic.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
153091 INFO: Analyzing hidden import 'PySide6.QtAsyncio'
153174 INFO: Analyzing hidden import 'PySide6.QtAxContainer'
153200 INFO: Processing standard module hook 'hook-PySide6.QtAxContainer.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
154345 INFO: Analyzing hidden import 'PySide6.QtBluetooth'
154413 INFO: Processing standard module hook 'hook-PySide6.QtBluetooth.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
155394 INFO: Analyzing hidden import 'PySide6.QtCanvasPainter'
155709 INFO: Processing standard module hook 'hook-PySide6.QtQuick.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
159855 INFO: Processing standard module hook 'hook-PySide6.QtQml.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
--- Logging error ---
Traceback (most recent call last):
File "C:\Users\Hugo Reyes\AppData\Local\Programs\Python\Python313\Lib\logging\__init__.py", line 1150, in emit
@@ -198,129 +198,146 @@ Call stack:
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
230199 INFO: Analyzing hidden import 'PySide6.QtCharts'
230330 INFO: Processing standard module hook 'hook-PySide6.QtCharts.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
232540 INFO: Analyzing hidden import 'PySide6.QtConcurrent'
232556 INFO: Processing standard module hook 'hook-PySide6.QtConcurrent.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
233299 INFO: Analyzing hidden import 'PySide6.QtDBus'
233342 INFO: Processing standard module hook 'hook-PySide6.QtDBus.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
234446 INFO: Analyzing hidden import 'PySide6.QtDataVisualization'
234568 INFO: Processing standard module hook 'hook-PySide6.QtDataVisualization.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
236233 INFO: Analyzing hidden import 'PySide6.QtDesigner'
236274 INFO: Processing standard module hook 'hook-PySide6.QtDesigner.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
241104 INFO: Analyzing hidden import 'PySide6.QtGraphs'
241324 INFO: Processing standard module hook 'hook-PySide6.QtGraphs.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
243536 INFO: Analyzing hidden import 'PySide6.QtGraphsWidgets'
243565 INFO: Processing standard module hook 'hook-PySide6.QtGraphsWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
244563 INFO: Processing standard module hook 'hook-PySide6.QtQuickWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
246244 INFO: Analyzing hidden import 'PySide6.QtHelp'
246273 INFO: Processing standard module hook 'hook-PySide6.QtHelp.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
247638 INFO: Analyzing hidden import 'PySide6.QtHttpServer'
247669 INFO: Processing standard module hook 'hook-PySide6.QtHttpServer.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
249385 INFO: Analyzing hidden import 'PySide6.QtLocation'
249581 INFO: Processing standard module hook 'hook-PySide6.QtLocation.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
252459 INFO: Processing standard module hook 'hook-PySide6.QtPositioning.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
254747 INFO: Analyzing hidden import 'PySide6.QtMultimedia'
254834 INFO: Processing standard module hook 'hook-PySide6.QtMultimedia.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
258359 INFO: Analyzing hidden import 'PySide6.QtMultimediaWidgets'
258375 INFO: Processing standard module hook 'hook-PySide6.QtMultimediaWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
260168 INFO: Analyzing hidden import 'PySide6.QtNetworkAuth'
260207 INFO: Processing standard module hook 'hook-PySide6.QtNetworkAuth.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
261657 INFO: Analyzing hidden import 'PySide6.QtNfc'
261690 INFO: Processing standard module hook 'hook-PySide6.QtNfc.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
262702 INFO: Analyzing hidden import 'PySide6.QtOpenGLWidgets'
262719 INFO: Processing standard module hook 'hook-PySide6.QtOpenGLWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
264327 INFO: Analyzing hidden import 'PySide6.QtPdf'
264355 INFO: Processing standard module hook 'hook-PySide6.QtPdf.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
265791 INFO: Analyzing hidden import 'PySide6.QtPdfWidgets'
265809 INFO: Processing standard module hook 'hook-PySide6.QtPdfWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
267252 INFO: Analyzing hidden import 'PySide6.QtPrintSupport'
267279 INFO: Processing standard module hook 'hook-PySide6.QtPrintSupport.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
269442 INFO: Analyzing hidden import 'PySide6.QtQuick3D'
269489 INFO: Processing standard module hook 'hook-PySide6.QtQuick3D.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
271627 INFO: Analyzing hidden import 'PySide6.QtQuickControls2'
271640 INFO: Processing standard module hook 'hook-PySide6.QtQuickControls2.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
273064 INFO: Analyzing hidden import 'PySide6.QtQuickTest'
273078 INFO: Analyzing hidden import 'PySide6.QtRemoteObjects'
273107 INFO: Processing standard module hook 'hook-PySide6.QtRemoteObjects.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
274340 INFO: Analyzing hidden import 'PySide6.QtScxml'
274392 INFO: Processing standard module hook 'hook-PySide6.QtScxml.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
275886 INFO: Analyzing hidden import 'PySide6.QtSensors'
275927 INFO: Processing standard module hook 'hook-PySide6.QtSensors.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
277621 INFO: Analyzing hidden import 'PySide6.QtSerialBus'
277722 INFO: Processing standard module hook 'hook-PySide6.QtSerialBus.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
281072 INFO: Analyzing hidden import 'PySide6.QtSerialPort'
281104 INFO: Processing standard module hook 'hook-PySide6.QtSerialPort.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
282168 INFO: Analyzing hidden import 'PySide6.QtSpatialAudio'
282208 INFO: Processing standard module hook 'hook-PySide6.QtSpatialAudio.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
283945 INFO: Analyzing hidden import 'PySide6.QtSql'
284080 INFO: Processing standard module hook 'hook-PySide6.QtSql.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
288123 INFO: Analyzing hidden import 'PySide6.QtStateMachine'
288150 INFO: Processing standard module hook 'hook-PySide6.QtStateMachine.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
289986 INFO: Analyzing hidden import 'PySide6.QtSvg'
290006 INFO: Processing standard module hook 'hook-PySide6.QtSvg.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
291280 INFO: Analyzing hidden import 'PySide6.QtSvgWidgets'
291297 INFO: Processing standard module hook 'hook-PySide6.QtSvgWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
292814 INFO: Analyzing hidden import 'PySide6.QtTest'
292847 INFO: Processing standard module hook 'hook-PySide6.QtTest.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
294027 INFO: Analyzing hidden import 'PySide6.QtTextToSpeech'
294048 INFO: Processing standard module hook 'hook-PySide6.QtTextToSpeech.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
296163 INFO: Analyzing hidden import 'PySide6.QtUiTools'
296177 INFO: Processing standard module hook 'hook-PySide6.QtUiTools.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
297703 INFO: Analyzing hidden import 'PySide6.QtWebChannel'
297732 INFO: Processing standard module hook 'hook-PySide6.QtWebChannel.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
299086 INFO: Analyzing hidden import 'PySide6.QtWebEngineCore'
299287 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineCore.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
302818 INFO: Analyzing hidden import 'PySide6.QtWebEngineQuick'
302842 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineQuick.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
304326 INFO: Analyzing hidden import 'PySide6.QtWebEngineWidgets'
304347 INFO: Processing standard module hook 'hook-PySide6.QtWebEngineWidgets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
306188 INFO: Analyzing hidden import 'PySide6.QtWebSockets'
306220 INFO: Processing standard module hook 'hook-PySide6.QtWebSockets.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
307657 INFO: Analyzing hidden import 'PySide6.QtWebView'
307698 INFO: Analyzing hidden import 'PySide6.QtXml'
307731 INFO: Processing standard module hook 'hook-PySide6.QtXml.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
309023 INFO: Analyzing hidden import 'PySide6._config'
309037 INFO: Analyzing hidden import 'PySide6._git_pyside_version'
309066 INFO: Analyzing hidden import 'PySide6.scripts'
309077 INFO: Analyzing hidden import 'PySide6.scripts.deploy'
309198 INFO: Analyzing hidden import 'PySide6.scripts.metaobjectdump'
309272 INFO: Analyzing hidden import 'PySide6.scripts.project'
309432 INFO: Analyzing hidden import 'PySide6.scripts.project_lib'
309645 INFO: Processing standard module hook 'hook-xml.etree.cElementTree.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
309737 INFO: Analyzing hidden import 'PySide6.scripts.pyside_tool'
309757 INFO: Analyzing hidden import 'PySide6.scripts.qml'
309783 INFO: Analyzing hidden import 'PySide6.scripts.qtpy2cpp'
309805 INFO: Analyzing hidden import 'PySide6.support'
309816 INFO: Analyzing hidden import 'PySide6.support.deprecated'
309829 INFO: Analyzing hidden import 'PySide6.support.generate_pyi'
309853 INFO: Processing module hooks (post-graph stage)...
311602 INFO: Performing binary vs. data reclassification (3706 entries)
374194 INFO: Looking for ctypes DLLs
374269 INFO: Analyzing run-time hooks ...
374273 INFO: Including run-time hook 'pyi_rth_inspect.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks'
374295 INFO: Including run-time hook 'pyi_rth_setuptools.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks'
374315 INFO: Including run-time hook 'pyi_rth_pkgutil.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks'
374345 INFO: Including run-time hook 'pyi_rth_multiprocessing.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks'
374370 INFO: Including run-time hook 'pyi_rth_cryptography_openssl.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks'
374387 INFO: Including run-time hook 'pyi_rth_pyside6.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks'
374409 INFO: Processing pre-find-module-path hook 'hook-_pyi_rth_utils.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks\\pre_find_module_path'
374429 INFO: Processing standard module hook 'hook-_pyi_rth_utils.py' from '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PyInstaller\\hooks'
374573 INFO: Creating base_library.zip...
374601 INFO: Looking for dynamic libraries
388194 INFO: Extra DLL search directories (AddDllDirectory): ['\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\shiboken6']
388194 INFO: Extra DLL search directories (PATH): ['\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6']
550512 WARNING: Library not found: could not resolve 'fbclient.dll', dependency of '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\plugins\\sqldrivers\\qsqlibase.dll'.
550537 WARNING: Library not found: could not resolve 'MIMAPI64.dll', dependency of '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\plugins\\sqldrivers\\qsqlmimer.dll'.
550564 WARNING: Library not found: could not resolve 'OCI.dll', dependency of '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\plugins\\sqldrivers\\qsqloci.dll'.
550599 WARNING: Library not found: could not resolve 'LIBPQ.dll', dependency of '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\plugins\\sqldrivers\\qsqlpsql.dll'.
551377 WARNING: Library not found: could not resolve 'Qt6QuickShapesDesignHelpers.dll', dependency of '\\\\wsl.localhost\\Debian\\home\\hugo_reyes\\dev\\CloudRecoveryAS\\venv-windows\\Lib\\site-packages\\PySide6\\qml\\QtQuick\\Shapes\\DesignHelpers\\qtquickshapesdesignhelpersplugin.dll'.
557267 INFO: Warnings written to C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\warn-CloudRestoreAS.txt
557345 INFO: Graph cross-reference written to C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\xref-CloudRestoreAS.html
557674 INFO: checking PYZ
557674 INFO: Building PYZ because PYZ-00.toc is non existent
557674 INFO: Building PYZ (ZlibArchive) C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\PYZ-00.pyz
558391 INFO: Building PYZ (ZlibArchive) C:\Users\Hugo Reyes\AppData\Local\Temp\cloudrestore-build\CloudRestoreAS\PYZ-00.pyz completed successfully.
558559 INFO: checking PKG
558559 INFO: Building PKG because PKG-00.toc is non existent
558559 INFO: Building PKG (CArchive) CloudRestoreAS.pkg
770535 INFO: Building PKG (CArchive) CloudRestoreAS.pkg completed successfully.
770717 INFO: Bootloader \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\venv-windows\Lib\site-packages\PyInstaller\bootloader\Windows-64bit-intel\runw.exe
770717 INFO: checking EXE
770717 INFO: Building EXE because EXE-00.toc is non existent
770717 INFO: Building EXE from EXE-00.toc
770723 INFO: Copying bootloader EXE to \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\dist\CloudRestoreAS.exe
770978 INFO: Copying icon to EXE
771258 INFO: Copying 0 resources to EXE
771258 INFO: Embedding manifest in EXE
771481 INFO: Appending PKG archive to EXE
774801 INFO: Fixing EXE headers
1021508 INFO: Building EXE from EXE-00.toc completed successfully.
1021631 INFO: Build complete! The results are available in: \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\dist
Build OK: \\wsl.localhost\Debian\home\hugo_reyes\dev\CloudRecoveryAS\dist\CloudRestoreAS.exe
Distribuir solo el .exe; al ejecutar crea config/ automaticamente.
===WINDOWS_BUILD_EXIT=0===

View File

@@ -1,124 +1,634 @@
# Script de instalación rápida
<#
.SYNOPSIS
Instalador Windows de CloudRestoreAS.
Write-Host "===============================================" -ForegroundColor Cyan
Write-Host "CloudRestoreAS - Instalación Rápida" -ForegroundColor Cyan
Write-Host "===============================================" -ForegroundColor Cyan
Write-Host ""
.DESCRIPTION
NO instala ni descarga NADA en el sistema: el binario es 100% autocontenido (Qt,
driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo coloca el
.exe, hace el bootstrap de config\, opcionalmente siembra las credenciales del PANEL
y registra el arranque automático.
# Verificar Python
Write-Host "1. Verificando Python..." -ForegroundColor Yellow
$pythonVersion = python --version 2>$null
if (-not $pythonVersion) {
Write-Host "❌ Python no está instalado o no está en el PATH" -ForegroundColor Red
Write-Host " Descarga Python 3.11+ desde: https://www.python.org/downloads/" -ForegroundColor Yellow
Read-Host "Presiona Enter para salir..."
exit 1
}
Write-Host "$pythonVersion" -ForegroundColor Green
Por eso tampoco se usa NSSM ni ningún envoltorio de servicio descargado: el arranque
24/7 se resuelve con una tarea programada ONSTART, que ya viene en el SO.
# Verificar versión de Python
$versionString = $pythonVersion -replace "Python ", ""
$version = [version]($versionString.Split()[0])
if ($version -lt [version]"3.11") {
Write-Host "❌ Python $version es demasiado antiguo. Se requiere 3.11+" -ForegroundColor Red
Read-Host "Presiona Enter para salir..."
exit 1
}
Reemplazar el binario de un servidor en producción no puede dejarlo sin restaurador, así
que el script se sostiene sobre tres garantías, las mismas que install.sh:
1. No actúa si hay una restauración en curso (sale con 75, EX_TEMPFAIL).
2. Respalda el binario anterior antes de pisarlo.
3. Confirma que el agente volvió a arrancar y, si no, REVIERTE al binario anterior.
# Verificar 7-Zip
Write-Host ""
Write-Host "2. Verificando 7-Zip..." -ForegroundColor Yellow
$sevenZipPaths = @(
"C:\Program Files\7-Zip\7z.exe",
"D:\Program Files\7-Zip\7z.exe",
"C:\Program Files (x86)\7-Zip\7z.exe"
.PARAMETER Service
Arranque 24/7 sin sesión: tarea programada ONSTART como SYSTEM (recomendado en servidor).
.PARAMETER Desktop
Arranque al iniciar sesión. La app registra su propia tarea ONLOGON al ejecutarse.
.PARAMETER UpdateInPlace
Actualiza una instalación EXISTENTE: reemplaza el binario y vuelve a levantar el agente por
el mismo mecanismo con el que estaba, sin volver a registrar la tarea ni correr el bootstrap.
Es el modo que usa el PANEL para actualizar, porque no toca nada de la configuración vigente.
.PARAMETER Prefix
Carpeta destino. Default C:\Aduanasoft\CloudRestoreAS.
.PARAMETER PanelEnvFile
Archivo KEY=valor con CLOUDRESTORE_PANEL_API_URL / _API_TOKEN / _INSTANCE_KEY que se
fusiona en config\.env tras el bootstrap y luego se borra. Lo usa el instalador remoto
del PANEL para dejar el servidor configurado sin intervención.
.EXAMPLE
.\install.ps1 -Service
.EXAMPLE
.\install.ps1 -UpdateInPlace -Prefix 'D:\CloudRestoreAS'
#>
[CmdletBinding()]
param(
[switch]$Service,
[switch]$Desktop,
[switch]$UpdateInPlace,
[string]$Prefix = 'C:\Aduanasoft\CloudRestoreAS',
[string]$PanelEnvFile = ''
)
$sevenZipFound = $false
foreach ($path in $sevenZipPaths) {
if (Test-Path $path) {
Write-Host "✅ 7-Zip encontrado en: $path" -ForegroundColor Green
$sevenZipFound = $true
break
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$BinName = 'CloudRestoreAS.exe'
$ProcName = 'CloudRestoreAS'
$TaskName = 'CloudRestoreAS'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Código de salida reservado para "hay una restauración en curso". Es 75 (EX_TEMPFAIL) y no 1
# a propósito, igual que en install.sh: le dice al PANEL "reintenta luego", no "falló la
# instalación", y así el operador no sale a buscar una avería que no existe.
$EXIT_RESTORE_IN_PROGRESS = 75
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Ok($msg) { Write-Host "OK: $msg" -ForegroundColor Green }
function Write-Warn($msg) { Write-Warning $msg }
$chosen = @()
if ($Service) { $chosen += '-Service' }
if ($Desktop) { $chosen += '-Desktop' }
if ($UpdateInPlace) { $chosen += '-UpdateInPlace' }
if ($chosen.Count -gt 1) {
throw "Elige un solo modo; se recibieron: $($chosen -join ', ')."
}
$Mode = if ($Service) { 'service' }
elseif ($Desktop) { 'desktop' }
elseif ($UpdateInPlace) { 'update-in-place' }
else { 'none' }
$dest = Join-Path $Prefix $BinName
# Respaldo del binario anterior. Se llena solo si hay algo que respaldar; el bloque final lo
# usa para revertir y lo borra cuando confirma que la versión nueva sí arrancó.
$backup = ''
# --- Utilidades sobre el estado del agente ------------------------------------------
function Get-AgentProcess {
Get-Process -Name $ProcName -ErrorAction SilentlyContinue
}
function Get-AgentTask {
Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
function Get-NormalizedPath([string]$Path) {
<#
Normaliza para comparar rutas: la tarea puede guardarlas entrecomilladas, con otra
capitalización o con separador final. Comparar las cadenas en crudo daba falsos negativos.
#>
if (-not $Path) { return '' }
$clean = $Path.Trim().Trim('"')
try { return [System.IO.Path]::GetFullPath($clean).TrimEnd('\') } catch { return $clean.TrimEnd('\') }
}
function Test-SamePath([string]$A, [string]$B) {
if (-not $A -or -not $B) { return $false }
# NTFS no distingue mayúsculas: comparar sensible a caso reportaría dos rutas distintas donde
# el SO ve una sola.
return (Get-NormalizedPath $A) -ieq (Get-NormalizedPath $B)
}
function Get-AgentTaskExecute {
<#
Ruta del ejecutable que la tarea programada tiene registrada en su acción.
Este es el dato que faltaba. Reemplazar el binario es una operación por RUTA, pero
arrancarlo con `Start-ScheduledTask` es una operación por NOMBRE. Si la tarea apunta a otra
carpeta se actualiza un binario y se arranca otro: el servidor se queda en la versión vieja
y la actualización parece haber funcionado.
Se lee la propiedad por reflexión porque no todas las acciones de una tarea son de tipo
Exec (una ComHandler no tiene `Execute`), y con Set-StrictMode tocar una propiedad que no
existe es un error.
#>
$task = Get-AgentTask
if (-not $task) { return '' }
foreach ($action in @($task.Actions)) {
$prop = $action.PSObject.Properties['Execute']
if ($prop -and $prop.Value) { return ([string]$prop.Value).Trim().Trim('"') }
}
return ''
}
function Repair-AgentTaskPath {
<#
Reapunta la acción de la tarea al binario recién instalado, conservando el resto de su
definición (disparador, principal, ajustes) y los argumentos que tuviera: volver a
registrarla desde cero perdería cualquier ajuste que el operador le haya hecho.
#>
param($Task)
$actions = @()
foreach ($action in @($Task.Actions)) {
$execProp = $action.PSObject.Properties['Execute']
if (-not $execProp) { $actions += $action; continue }
$argProp = $action.PSObject.Properties['Arguments']
$arguments = if ($argProp -and $argProp.Value) { [string]$argProp.Value } else { '' }
$actions += if ($arguments) {
New-ScheduledTaskAction -Execute $dest -Argument $arguments -WorkingDirectory $Prefix
} else {
New-ScheduledTaskAction -Execute $dest -WorkingDirectory $Prefix
}
}
try {
Set-ScheduledTask -TaskName $TaskName -Action $actions -ErrorAction Stop | Out-Null
Write-Ok "Tarea '$TaskName' reapuntada a $dest."
} catch {
throw ("La tarea '$TaskName' ejecuta un binario distinto del que se acaba de instalar y no " +
"se pudo corregir ($($_.Exception.Message)). Arrancarla levantaría la versión anterior " +
'y la actualización quedaría sin efecto aparentando haber funcionado, que es peor que ' +
'fallar aquí.')
}
}
if (-not $sevenZipFound) {
Write-Host "⚠️ 7-Zip no encontrado en ubicaciones estándar" -ForegroundColor Yellow
Write-Host " Descarga 7-Zip desde: https://www.7-zip.org/" -ForegroundColor Yellow
Write-Host " (Puedes configurar la ruta manualmente en la aplicación)" -ForegroundColor Cyan
function Get-AgentState {
<#
Estado del agente en una sola consulta: si hay algo vivo, si alguno corre el binario de
ESTA instalación, y desde dónde corren los que no.
`PathsReadable` separa "no corre desde aquí" de "no pude ver desde dónde corre": un proceso
de SYSTEM no expone `.Path` a una cuenta sin elevación, y tratar ese caso como "es de otra
instalación" revertiría actualizaciones correctas.
Vive en un solo sitio a propósito. Esta lógica repetida en dos funciones es exactamente
cómo se colaron las divergencias que estamos arreglando.
#>
$procs = @(Get-AgentProcess)
$paths = @()
foreach ($proc in $procs) {
try { if ($proc.Path) { $paths += $proc.Path } } catch { }
}
$fromPrefix = $false
foreach ($path in $paths) {
if (Test-SamePath $path $dest) { $fromPrefix = $true; break }
}
return [pscustomobject]@{
Running = ($procs.Count -gt 0)
PathsReadable = (($procs.Count -eq 0) -or ($paths.Count -gt 0))
FromPrefix = $fromPrefix
Paths = $paths
}
}
# Verificar ODBC Driver
Write-Host ""
Write-Host "3. Verificando ODBC Driver for SQL Server..." -ForegroundColor Yellow
$odbcDrivers = Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"}
if ($odbcDrivers) {
Write-Host "✅ ODBC Driver encontrado:" -ForegroundColor Green
$odbcDrivers | ForEach-Object { Write-Host " - $($_.Name)" -ForegroundColor Gray }
} else {
Write-Host "⚠️ ODBC Driver for SQL Server no encontrado" -ForegroundColor Yellow
Write-Host " Descarga desde: https://aka.ms/downloadmsodbcsql" -ForegroundColor Yellow
Write-Host " (Requerido para conectar con SQL Server)" -ForegroundColor Cyan
function Test-RestoreInProgress {
<#
Una restauración en curso no se interrumpe. El agente no atiende señales de terminación,
así que matarlo a media restauración deja el job atascado —ese ZIP queda vetado en cada
escaneo posterior— y puede dejar la base en SINGLE_USER. Cualquier subcarpeta de Temp\
es un job en vuelo.
#>
$temp = Join-Path $Prefix 'Temp'
if (-not (Test-Path -LiteralPath $temp)) { return $false }
$first = Get-ChildItem -LiteralPath $temp -Force -ErrorAction SilentlyContinue |
Select-Object -First 1
return [bool]$first
}
# Crear entorno virtual
Write-Host ""
Write-Host "4. Creando entorno virtual..." -ForegroundColor Yellow
if (Test-Path "venv") {
Write-Host " El entorno virtual ya existe, omitiendo..." -ForegroundColor Gray
function Stop-Agent {
<#
Detiene tarea y procesos, y espera a que el SO libere el .exe. Devuelve $true si había
algo corriendo.
La espera no es cosmética: mientras un proceso tenga el binario mapeado, Copy-Item falla
y la actualización aborta. Windows no tiene el truco que hace fácil esto en POSIX —donde
`install` desvincula el destino antes de crearlo, por lo que reemplazar un binario EN USO
funciona—, así que aquí no queda más que esperar de verdad. Los 5s de antes se quedaban
cortos con un antivirus escaneando un onefile de ~270 MB.
#>
param([int]$TimeoutSeconds = 30)
$running = [bool](Get-AgentProcess)
if (Get-AgentTask) {
Write-Step 'Deteniendo tarea programada existente'
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
Get-AgentProcess | Stop-Process -Force -ErrorAction SilentlyContinue
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-AgentProcess) -and (Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 500
}
if (Get-AgentProcess) {
throw ("Hay procesos $ProcName que siguen vivos tras ${TimeoutSeconds}s. No se reemplaza " +
'el binario: la copia fallaría y dejaría la instalación a medias.')
}
return $running
}
function Start-Agent {
<#
Vuelve a levantar el agente por el mismo mecanismo con el que estaba: la tarea si está
registrada, y si no, el proceso suelto. Devuelve la etiqueta del mecanismo usado.
No se fija QT_QPA_PLATFORM: `--headless` hace que el propio binario elija el plugin
'offscreen' en cualquier plataforma. Es lo que permite que el agente corra como SYSTEM
en la sesión 0, donde no hay escritorio interactivo al que asomar una ventana.
Que la acción de la tarea apunte a `$dest` lo garantiza `Sync-AgentTaskPath`, que corre
justo después de copiar el binario. Aquí ya se puede arrancar sin volver a comprobarlo.
#>
if (Get-AgentTask) {
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
return 'tarea programada'
}
Start-Process -FilePath $dest -ArgumentList '--start-engine', '--headless' `
-WorkingDirectory $Prefix -WindowStyle Hidden
return 'proceso suelto'
}
function Wait-AgentAlive {
<#
Espera a que el agente esté vivo, y que sea el binario de ESTA instalación.
La identidad importa: `Get-Process -Name` responde "hay un proceso con ese nombre", no
"corre el binario que instalé". Con la tarea apuntando a otra carpeta, el agente viejo
—que nunca se fue— satisfacía la comprobación por nombre y la actualización pasaba por
buena sin haber cambiado nada.
Si la ruta del proceso no es legible NO se concluye que sea de otra instalación: un proceso
de SYSTEM no expone `.Path` a una cuenta sin elevación. Se acepta por nombre y se avisa,
porque revertir una actualización correcta por falta de información es peor.
#>
param([int]$TimeoutSeconds = 60)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$foreign = @()
while ((Get-Date) -lt $deadline) {
$state = Get-AgentState
if ($state.Running) {
if (-not $state.PathsReadable) {
Write-Warn ("Hay $ProcName corriendo pero no se pudo leer su ruta; se acepta por " +
'nombre. La versión efectiva la confirma el panel con config\.version.')
return $true
}
if ($state.FromPrefix) { return $true }
$foreign = $state.Paths
}
Start-Sleep -Seconds 2
}
# Se avisa una sola vez y al final: dentro del bucle serían treinta líneas iguales.
if ($foreign.Count -gt 0) {
Write-Warn ("Hay $ProcName corriendo desde $($foreign -join ', '), pero no desde $dest.")
}
return $false
}
function Sync-AgentTaskPath {
<#
Alinea la acción de la tarea programada con el binario recién instalado.
Es la raíz del fallo silencioso: reemplazar el binario es una operación por RUTA, pero
`Start-ScheduledTask` es por NOMBRE y ejecuta la ruta que la tarea lleva registrada. En una
instalación fuera de la carpeta por omisión, eso significaba copiar el binario nuevo en un
sitio y arrancar el viejo desde otro: el run terminaba en verde y el servidor seguía igual.
#>
$registered = Get-AgentTaskExecute
if (-not $registered) { return }
if (Test-SamePath $registered $dest) { return }
Write-Warn "La tarea '$TaskName' ejecutaba $registered, no $dest."
Repair-AgentTaskPath -Task (Get-AgentTask)
}
function Copy-Binary {
<#
Copia con reintentos. Tras terminar un proceso, el antivirus y el propio SO pueden
mantener el .exe abierto unos segundos más; reintentar sale mucho más barato que abortar
una actualización que iba bien.
#>
param([string]$From, [string]$To, [int]$Attempts = 5)
for ($i = 1; $i -le $Attempts; $i++) {
try {
Copy-Item -LiteralPath $From -Destination $To -Force -ErrorAction Stop
return
} catch {
if ($i -eq $Attempts) { throw }
Start-Sleep -Seconds 3
}
}
}
# --- Localizar el binario (mismas rutas candidatas que install.sh) -------------------
$candidates = @(
(Join-Path $ScriptDir "dist\$BinName"),
(Join-Path $ScriptDir $BinName)
)
$src = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
if (-not $src) {
throw "No se encontró el binario ($BinName). Ejecuta .\build.ps1 primero."
}
# -Service crea una tarea como SYSTEM: requiere elevación.
#
# Se distingue "no es administrador" de "es administrador pero UAC le dio el token FILTRADO",
# que es lo que recibe una cuenta administradora que entra por OpenSSH cuando el destino no
# tiene LocalAccountTokenFilterPolicy. Los dos casos se veían idénticos —"no eres admin"— y el
# remedio es opuesto: en el primero hay que cambiar de cuenta; en el segundo la cuenta ya es la
# correcta y lo que falta es una política del servidor.
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$currentPrincipal = [Security.Principal.WindowsPrincipal]$identity
$isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
$adminSid = New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544'
$inAdminGroup = [bool]($identity.Groups | Where-Object { $_ -eq $adminSid })
if ($Mode -eq 'service' -and -not $isAdmin) {
if ($inAdminGroup) {
throw ("La cuenta '$($identity.Name)' SÍ pertenece a Administradores, pero esta sesión " +
'recibió el token filtrado por UAC, así que no puede registrar la tarea ONSTART como ' +
'SYSTEM. No hace falta cambiar de cuenta: hay que permitir la elevación remota en el ' +
'servidor (LocalAccountTokenFilterPolicy=1 en ' +
'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System), o usar -UpdateInPlace ' +
'si solo se está actualizando una instalación que ya existe.')
}
throw ("La cuenta '$($identity.Name)' no es Administrador y -Service registra una tarea " +
'ONSTART como SYSTEM, que exige elevación.')
}
Write-Host '===============================================' -ForegroundColor Cyan
Write-Host 'CloudRestoreAS - Instalación Windows'
Write-Host " Binario : $src"
Write-Host " Destino : $Prefix"
Write-Host " Modo : $Mode"
Write-Host '===============================================' -ForegroundColor Cyan
if ($Mode -eq 'update-in-place' -and -not (Test-Path -LiteralPath $dest)) {
throw ("Se pidió -UpdateInPlace pero en $Prefix no hay una instalación ($BinName no existe). " +
'Para una instalación nueva usa -Service, -Desktop o sin modo.')
}
# --- Guarda: no interrumpir una restauración en curso -------------------------------
# Va ANTES de detener nada y en TODOS los modos: el daño lo hace matar al agente, no el modo
# que se haya pedido. Antes esta comprobación no existía en Windows y una reinstalación a
# destiempo se llevaba por delante el respaldo que estuviera restaurando.
if (Test-RestoreInProgress) {
Write-Warn "Hay una restauración en curso ($Prefix\Temp no está vacío)."
Write-Warn 'No se instala para no dejarla a medias. Reintenta cuando termine.'
exit $EXIT_RESTORE_IN_PROGRESS
}
# --- Colocar el binario -------------------------------------------------------------
Write-Step 'Instalando binario'
New-Item -ItemType Directory -Path $Prefix -Force | Out-Null
$taskExisted = [bool](Get-AgentTask)
$wasRunning = Stop-Agent
# Respaldo para poder volver atrás. Se hace siempre que haya algo que pisar, no solo al
# actualizar en sitio: el PANEL actualiza con -Service, y esa vía también reemplaza el binario
# de un servidor en producción. Sin respaldo, un binario nuevo que no arranque deja el servidor
# sin agente y sin forma de recuperarlo salvo entrando a mano.
if (Test-Path -LiteralPath $dest) {
$backup = Join-Path $Prefix ".$BinName.prev"
try {
Copy-Item -LiteralPath $dest -Destination $backup -Force -ErrorAction Stop
Write-Ok "Respaldo del binario actual en $backup"
} catch {
throw ("No se pudo respaldar el binario actual en $backup ($($_.Exception.Message)). " +
'Se aborta: actualizar sin poder revertir no es aceptable.')
}
}
Copy-Binary -From $src -To $dest
Write-Ok "Binario instalado en $dest"
# El binario nuevo ya está en su sitio; falta que el arranque automático apunte AHÍ. Va aquí y no
# dentro de Start-Agent para que se corrija aunque en este momento no haya que arrancar nada: una
# tarea desalineada seguiría levantando la versión vieja en el próximo reinicio del servidor.
Sync-AgentTaskPath
# --- Bootstrap de config\ -----------------------------------------------------------
# El propio binario crea config\, .env y las carpetas de trabajo al arrancar. Se corre
# una vez acotado por timeout para que el operador ya pueda editar config\.env.
#
# Al actualizar en sitio se OMITE: el binario hace ensure_runtime_layout() en cada arranque, así
# que es redundante, y correr una segunda instancia junto a la viva es peligroso —al arrancar, el
# motor purga todas las subcarpetas de Temp, que son de la instancia en curso—.
if ($Mode -ne 'update-in-place') {
Write-Step 'Inicializando config\ (bootstrap)'
Push-Location $Prefix
try {
$env:QT_QPA_PLATFORM = 'offscreen'
$proc = Start-Process -FilePath $dest -ArgumentList '--headless' -PassThru -WindowStyle Hidden
if (-not $proc.WaitForExit(20000)) {
$proc | Stop-Process -Force -ErrorAction SilentlyContinue
}
} finally {
Remove-Item Env:\QT_QPA_PLATFORM -ErrorAction SilentlyContinue
Pop-Location
}
}
$envPath = Join-Path $Prefix 'config\.env'
if (Test-Path -LiteralPath $envPath) {
Write-Ok 'config\.env presente.'
} else {
python -m venv venv
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ Entorno virtual creado" -ForegroundColor Green
Write-Warn 'config\.env se creará en la primera ejecución.'
}
# --- Siembra de credenciales del PANEL ----------------------------------------------
# Fusión replace-or-append: respeta el resto de config\.env y es idempotente, así que
# reinstalar no duplica claves ni pierde ajustes locales.
function Merge-EnvFile {
param([string]$Target, [hashtable]$Values)
# El @() envuelve el `if` COMPLETO, no solo el Get-Content. Al asignar la salida de un `if`,
# PowerShell desenrolla un array de un solo elemento a escalar: con un config\.env de UNA línea
# $lines quedaba como String y `$lines.Count` reventaba con Set-StrictMode. Lo destapó la
# emulación de una actualización sobre una instalación con .env mínimo.
$lines = @(
if (Test-Path -LiteralPath $Target) { Get-Content -LiteralPath $Target -Encoding UTF8 }
)
foreach ($key in $Values.Keys) {
$line = "$key=$($Values[$key])"
$idx = -1
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match "^\s*$([regex]::Escape($key))\s*=") { $idx = $i; break }
}
if ($idx -ge 0) { $lines[$idx] = $line } else { $lines += $line }
}
# Se escribe SIN BOM a propósito. `Set-Content -Encoding UTF8` en PowerShell 5.1 agrega BOM
# (EF BB BF), y python-dotenv abre el archivo con encoding utf-8 (no utf-8-sig), así que el
# BOM se pega a la primera línea. Si esa primera línea es una clave —lo que pasa cuando el
# bootstrap no alcanzó a crear config\.env y este archivo se genera desde cero— la clave
# queda ilegible para el agente: se instalaría sin conectarse al panel, con toda la
# apariencia de un error de captura.
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllLines($Target, [string[]]$lines, $utf8NoBom)
}
if ($PanelEnvFile) {
if (-not (Test-Path -LiteralPath $PanelEnvFile)) {
throw "No existe el archivo indicado en -PanelEnvFile: $PanelEnvFile"
}
Write-Step 'Sembrando credenciales del PANEL en config\.env'
$allowed = @(
'CLOUDRESTORE_PANEL_API_URL',
'CLOUDRESTORE_PANEL_API_TOKEN',
'CLOUDRESTORE_PANEL_INSTANCE_KEY',
'CLOUDRESTORE_PANEL_VERIFY_SSL'
)
$values = @{}
foreach ($raw in Get-Content -LiteralPath $PanelEnvFile -Encoding UTF8) {
$line = $raw.Trim()
if (-not $line -or $line.StartsWith('#')) { continue }
$eq = $line.IndexOf('=')
if ($eq -lt 1) { continue }
$key = $line.Substring(0, $eq).Trim()
# Lista blanca: el archivo viene de la red, no debe poder inyectar otras claves.
if ($allowed -contains $key) {
$values[$key] = $line.Substring($eq + 1).Trim()
}
}
if ($values.Count -gt 0) {
# En update-in-place el bootstrap no corrió, así que config\.env puede no existir todavía
# si la instalación previa nunca llegó a arrancar. Merge-EnvFile lo crea.
New-Item -ItemType Directory -Path (Split-Path -Parent $envPath) -Force | Out-Null
Merge-EnvFile -Target $envPath -Values $values
Write-Ok "$($values.Count) clave(s) del PANEL escritas en config\.env"
} else {
Write-Host "❌ Error creando entorno virtual" -ForegroundColor Red
Read-Host "Presiona Enter para salir..."
exit 1
Write-Warn 'El archivo -PanelEnvFile no traía claves CLOUDRESTORE_PANEL_* válidas.'
}
# El archivo trae el token en claro: se borra en cuanto se consumió.
Remove-Item -LiteralPath $PanelEnvFile -Force -ErrorAction SilentlyContinue
}
# --- Arranque automático ------------------------------------------------------------
switch ($Mode) {
'service' {
Write-Step 'Registrando tarea programada ONSTART (SYSTEM)'
# Sin NSSM: una tarea ONSTART como SYSTEM cubre el 24/7 headless con lo que ya
# trae el SO. El binario elige el plugin Qt 'offscreen' por `--headless`, que es lo que
# le permite correr en la sesión 0, donde SYSTEM no tiene escritorio interactivo.
#
# Correr como SYSTEM es además lo que evita aquí el problema de propiedad que en Linux
# sí hay que resolver: allá el instalador crea el árbol como root pero el unit corre
# como un usuario común, que no podría leer su config/.env ni escribir su base local
# (de ahí el `chown -R` de install.sh). SYSTEM tiene control total sobre el sistema de
# archivos local y este script no restringe ninguna ACL del prefijo, así que hereda de
# su carpeta padre y puede leer y escribir todo lo que creó el Administrador. No hace
# falta un arreglo simétrico; si algún día se cambia el principal a una cuenta común,
# entonces sí habría que ajustar los permisos del árbol.
$action = New-ScheduledTaskAction -Execute $dest `
-Argument '--start-engine --headless' -WorkingDirectory $Prefix
$trigger = New-ScheduledTaskTrigger -AtStartup
$taskPrincipal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount `
-RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Principal $taskPrincipal -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName
Write-Ok "Tarea '$TaskName' registrada y arrancada."
}
'desktop' {
# La app registra su propia tarea ONLOGON la primera vez que corre EN la sesión del
# usuario. Desde aquí no se puede hacer por él: esta sesión no es la suya.
Write-Host 'Modo escritorio: el autostart ONLOGON queda registrado la primera vez que la'
Write-Host 'app se abra en la sesión del usuario.'
}
'update-in-place' {
# La tarea ya está registrada y no cambia: no se vuelve a definir ni se toca su
# configuración. El binario nuevo ya está en su sitio; el bloque de abajo se encarga de
# que el agente vuelva a levantarse con él.
Write-Host 'Actualización en sitio: la tarea programada existente se conserva tal cual.'
}
'none' {
Write-Host 'Instalación sin arranque automático.'
}
}
# Activar entorno virtual
Write-Host ""
Write-Host "5. Instalando dependencias..." -ForegroundColor Yellow
& ".\venv\Scripts\python.exe" -m pip install --upgrade pip
& ".\venv\Scripts\pip.exe" install -r requirements.txt
# --- Confirmar que el agente quedó corriendo, o revertir ----------------------------
# Esto es lo que faltaba: antes SOLO el modo -Service arrancaba algo, mientras que la detención
# de arriba corría en todos los modos. Actualizar con 'desktop' o 'none' mataba el agente y se
# iba, dejando el servidor sin restaurador y sin ninguna señal de que eso había pasado.
$shouldBeRunning = ($Mode -eq 'service') -or $wasRunning -or $taskExisted
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ Dependencias instaladas" -ForegroundColor Green
} else {
Write-Host "❌ Error instalando dependencias" -ForegroundColor Red
Read-Host "Presiona Enter para salir..."
exit 1
}
# Crear directorios necesarios
Write-Host ""
Write-Host "6. Creando directorios..." -ForegroundColor Yellow
$dirs = @("data", "logs")
foreach ($dir in $dirs) {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir | Out-Null
Write-Host "✅ Creado: $dir" -ForegroundColor Green
if ($shouldBeRunning) {
# La condición mira la RUTA, no solo el nombre. Mientras se copiaba, la tarea pudo relanzar el
# agente desde su ruta anterior (trae RestartCount): con una comprobación por nombre eso pasaba
# por "ya está corriendo", no se arrancaba el nuevo, y la actualización quedaba sin efecto.
$state = Get-AgentState
if (-not ($state.Running -and ($state.FromPrefix -or -not $state.PathsReadable))) {
if ($state.Running) {
Write-Warn ("Hay $ProcName corriendo desde $($state.Paths -join ', '); se termina para " +
'que no compita con el binario recién instalado.')
Stop-Agent -TimeoutSeconds 15 | Out-Null
}
$how = Start-Agent
Write-Step "Rearrancando el agente ($how)"
}
if (Wait-AgentAlive -TimeoutSeconds 60) {
Write-Ok 'El agente está corriendo con el binario nuevo.'
if ($backup -and (Test-Path -LiteralPath $backup)) {
# Solo tras confirmar que la versión nueva corre se descarta el respaldo.
Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue
}
} else {
Write-Host " $dir ya existe" -ForegroundColor Gray
Write-Warn 'El agente no volvió a arrancar tras la actualización.'
$reverted = $false
if ($backup -and (Test-Path -LiteralPath $backup)) {
Write-Warn 'Revirtiendo al binario anterior...'
try {
Copy-Binary -From $backup -To $dest
Start-Agent | Out-Null
$reverted = Wait-AgentAlive -TimeoutSeconds 60
} catch {
$reverted = $false
}
}
if ($reverted) {
throw ('La versión nueva no arrancó, así que se REVIRTIÓ: el binario anterior está ' +
"corriendo de nuevo y el servidor quedó como estaba. La versión nueva NO se " +
"aplicó. Revisa $Prefix\config\logs para ver por qué no levantó.")
}
$backupNote = if ($backup) { $backup } else { '(sin respaldo)' }
throw ('La instalación no dejó al agente corriendo y no se pudo revertir. El binario ' +
"nuevo está en $dest y el anterior en $backupNote. Revisa $Prefix\config\logs.")
}
} elseif ($backup -and (Test-Path -LiteralPath $backup)) {
# No había agente corriendo ni tarea que relevar, así que no hay arranque que confirmar y el
# respaldo ya no protege de nada. Se borra: son ~270 MB, y dejarlos ahí para siempre convierte
# cada reinstalación en un cobro de disco silencioso.
Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue
}
# Resumen
Write-Host ""
Write-Host "===============================================" -ForegroundColor Green
Write-Host "Instalación Completada" -ForegroundColor Green
Write-Host "===============================================" -ForegroundColor Green
Write-Host ""
Write-Host "Para ejecutar la aplicación:" -ForegroundColor Cyan
Write-Host " 1. Activa el entorno virtual:" -ForegroundColor White
Write-Host " .\venv\Scripts\Activate.ps1" -ForegroundColor Yellow
Write-Host " 2. Ejecuta la aplicación:" -ForegroundColor White
Write-Host " python runner.py" -ForegroundColor Yellow
Write-Host ""
Write-Host "O ejecuta directamente:" -ForegroundColor Cyan
Write-Host " .\venv\Scripts\python.exe runner.py" -ForegroundColor Yellow
Write-Host ""
Write-Host "Lee el README.md para configuración completa." -ForegroundColor Cyan
Write-Host ""
Write-Host ''
Write-Host "Configuración : $envPath"
Write-Host "Logs : $Prefix\config\logs"
Write-Host 'Listo.'
Read-Host "Presiona Enter para salir..."
# Salida explícita. El PANEL invoca este script como `& install.ps1 ...; exit $LASTEXITCODE`, y
# sin un `exit` propio esa variable queda sin fijar en el camino de éxito —aquí no corre ningún
# comando nativo que la establezca—, así que el código de salida dependería de lo que hubiera
# quedado en la sesión. Un 0 explícito no deja lugar a esa ambigüedad.
exit 0

497
install.sh Executable file
View File

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

View File

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

View File

@@ -1,6 +1,9 @@
CloudRestoreAS — despliegue portable
=====================================
El ejecutable es AUTOCONTENIDO: no requiere Python, ni 7-Zip, ni driver ODBC, ni
librerias Qt instaladas en el equipo destino. Todo va embebido dentro del binario.
Windows: copie CloudRestoreAS.exe a la carpeta deseada.
Linux: copie CloudRestoreAS y ejecute chmod +x CloudRestoreAS si hace falta.
@@ -15,4 +18,46 @@ 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.
-------------------------------------
Windows en SERVIDOR (24/7, sin sesion iniciada)
-------------------------------------
Lo normal es que el PANEL instale y actualice solo, desde Versiones CRAS. Esto es
el camino manual, y es lo mismo que el PANEL ejecuta por dentro.
Opcion A - servicio 24/7 (recomendado en servidor SQL):
.\install.ps1 -Service
(registra una tarea programada ONSTART que corre como SYSTEM, sin sesion
iniciada y sin ventana; ver estado: Get-ScheduledTask -TaskName CloudRestoreAS)
Requiere PowerShell como Administrador.
Opcion B - actualizar una instalacion que ya existe:
.\install.ps1 -UpdateInPlace
(reemplaza el binario conservando la tarea y la configuracion; no actua si hay
una restauracion en curso, y revierte solo si la version nueva no arranca)
Opcion C - escritorio Windows (con sesion):
.\install.ps1 -Desktop
No se usa NSSM ni ningun envoltorio de servicio: la tarea programada ya viene en el
SO, y en el servidor destino no se instala ni se descarga nada.
-------------------------------------
Linux en SERVIDOR SIN ESCRITORIO (headless)
-------------------------------------
En un servidor sin pantalla (sin DISPLAY), la app arranca automaticamente en modo
"offscreen": no muestra ventana pero el motor de restauracion trabaja igual. No hay
que instalar nada del sistema.
Opcion A - servicio 24/7 (recomendado en servidor SQL):
sudo ./install.sh --service
(instala una unit systemd que corre el binario con --start-engine --headless;
ver estado: systemctl status cloudrestoreas ; logs: journalctl -u cloudrestoreas -f)
Opcion B - manual:
QT_QPA_PLATFORM=offscreen ./CloudRestoreAS --start-engine --headless
Opcion C - escritorio Linux (con pantalla):
./install.sh --desktop (registra autostart .desktop; requiere sesion grafica)
El servidor SQL destino y su carpeta de datos (data_folder) se toman del PANEL;
con SQL Server sobre Linux use rutas POSIX (p. ej. /var/opt/mssql/data).

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

View File

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

34
packaging/scripts/build-all.sh Normal file → Executable file
View File

@@ -1,34 +1,6 @@
#!/usr/bin/env bash
# Genera ambos artefactos: Linux (local/Docker) y Windows (PowerShell + Python en Windows).
# Wrapper de compatibilidad: el orquestador canónico vive en la raíz del repo.
# Ejecuta ./build-all.sh (desde WSL) para generar Windows + Linux + paquetes.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
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"
exec bash "$ROOT/build-all.sh" "$@"

View File

@@ -9,10 +9,19 @@ docker run --rm \
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
# Herramientas de build + libs de sistema para que PyInstaller EMBEBA el cierre
# completo de dependencias (plugin Qt xcb y driver MS ODBC). Cinturón y tirantes
# junto con el bundle explícito de download-bundled-deps.sh.
apt-get install -y -qq \
python3 python3-venv python3-pip curl dpkg-dev binutils unixodbc \
libglib2.0-0 libdbus-1-3 libxkbcommon0 libfontconfig1 libxcb1 libx11-6 \
libxcb-xkb1 libegl1 libgl1 libxi6 libxrender1 libxext6 \
python3 python3-venv python3-pip curl dpkg-dev binutils apt-utils patchelf \
unixodbc libodbcinst2 libltdl7 libkrb5-3 libgssapi-krb5-2 libssl3 \
libglib2.0-0 libdbus-1-3 libfontconfig1 libfreetype6 \
libxkbcommon0 libxkbcommon-x11-0 libegl1 libgl1 libglvnd0 \
libx11-6 libx11-xcb1 libxext6 libxrender1 libxi6 libxfixes3 libxtst6 \
libxcomposite1 libxdamage1 libxrandr2 libxkbfile1 \
libxcb1 libxcb-cursor0 libxcb-icccm4 libxcb-util1 libxcb-image0 \
libxcb-keysyms1 libxcb-render-util0 libxcb-render0 libxcb-shape0 \
libxcb-xkb1 libxcb-glx0 libxcb-randr0 libxcb-shm0 libxcb-sync1 \
> /dev/null
chmod +x build.sh packaging/scripts/download-bundled-deps.sh
./build.sh

View File

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

View File

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

245
packaging/scripts/package-release.sh Normal file → Executable file
View File

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

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env bash
# Publica los artefactos de dist/release/ en el registro de paquetes GENÉRICOS de Gitea.
#
# Gitea calcula y expone el sha256 de cada archivo, así que se convierte en la fuente de
# verdad de los binarios: el PANEL descubre las versiones leyendo su API y verifica la
# integridad contra ese hash. Aquí, tras subir, se compara lo que reporta Gitea contra
# release.json para no dejar una publicación a medias pasando por buena.
#
# Uso:
# GITEA_TOKEN=... ./packaging/scripts/publish-release.sh
# ... --dry-run # lista qué subiría, sin subir nada
# ... --force # reemplaza archivos ya publicados de esta versión
# ... --notify-panel # avisa al PANEL para que sincronice de inmediato
#
# Variables:
# GITEA_TOKEN (requerida) PAT de Gitea con scope write:package
# GITEA_BASE_URL default https://git.aduanasoft.com
# GITEA_OWNER default ADUANASOFT
# CRAS_PACKAGE default cloudrestoreas
# PANEL_API_URL (solo --notify-panel) URL base del PANEL
# CLOUDRESTORE_API_TOKEN (solo --notify-panel) token de servicio del PANEL
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
RELEASE="$ROOT/dist/release"
MANIFEST="$RELEASE/release.json"
GITEA_BASE_URL="${GITEA_BASE_URL:-https://git.aduanasoft.com}"
GITEA_OWNER="${GITEA_OWNER:-ADUANASOFT}"
CRAS_PACKAGE="${CRAS_PACKAGE:-cloudrestoreas}"
DRY_RUN=0
FORCE=0
NOTIFY_PANEL=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
--force) FORCE=1 ;;
--notify-panel) NOTIFY_PANEL=1 ;;
-h|--help)
grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Opción desconocida: $arg" >&2; exit 2 ;;
esac
done
BLUE='\033[36m'; GREEN='\033[32m'; YELLOW='\033[33m'; RED='\033[31m'; NC='\033[0m'
say() { echo -e "${BLUE}==>${NC} $*"; }
ok() { echo -e "${GREEN}OK:${NC} $*"; }
warn() { echo -e "${YELLOW}AVISO:${NC} $*" >&2; }
err() { echo -e "${RED}ERROR:${NC} $*" >&2; }
if [[ ! -f "$MANIFEST" ]]; then
err "no existe $MANIFEST; corre packaging/scripts/package-release.sh primero"
exit 1
fi
VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST")"
# Los paquetes del manifiesto más los archivos de verificación, que se publican para
# poder auditar una versión sin depender del panel.
mapfile -t FILES < <(
python3 - "$MANIFEST" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
for artifact in data["artifacts"]:
print(artifact["file_name"])
print("SHA256SUMS")
print("release.json")
PY
)
if [[ "${#FILES[@]}" -le 2 ]]; then
err "release.json no lista artefactos; ¿falló el build de ambas plataformas?"
exit 1
fi
PKG_BASE="$GITEA_BASE_URL/api/packages/$GITEA_OWNER/generic/$CRAS_PACKAGE/$VERSION"
API_BASE="$GITEA_BASE_URL/api/v1/packages/$GITEA_OWNER/generic/$CRAS_PACKAGE/$VERSION"
say "Publicando CloudRestoreAS $VERSION"
echo " Destino : $PKG_BASE"
echo " Archivos: ${#FILES[@]}"
if [[ "$DRY_RUN" -eq 1 ]]; then
for f in "${FILES[@]}"; do
if [[ -f "$RELEASE/$f" ]]; then
printf ' [dry-run] PUT %s (%s)\n' "$f" "$(du -h "$RELEASE/$f" | cut -f1)"
else
warn "[dry-run] falta $f"
fi
done
ok "dry-run completado; no se subió nada"
exit 0
fi
if [[ -z "${GITEA_TOKEN:-}" ]]; then
err "falta GITEA_TOKEN (PAT de Gitea con scope write:package)"
echo " Ejemplo: GITEA_TOKEN=xxxx $0" >&2
exit 1
fi
# --- Subida ------------------------------------------------------------------------
for f in "${FILES[@]}"; do
path="$RELEASE/$f"
if [[ ! -f "$path" ]]; then
warn "no existe $f; se omite"
continue
fi
if [[ "$FORCE" -eq 1 ]]; then
# Los paquetes genéricos son inmutables: para reemplazar hay que borrar primero.
curl -sS -o /dev/null -X DELETE \
-H "Authorization: token $GITEA_TOKEN" \
"$PKG_BASE/$f" || true
fi
say "Subiendo $f ($(du -h "$path" | cut -f1))"
# -# manda la barra de progreso a stderr y -w el código HTTP a stdout, así se puede
# ver el avance de una subida de cientos de MB y a la vez capturar el resultado.
code="$(
curl -S -# -o /dev/null -w '%{http_code}' \
--retry 2 --retry-delay 3 --retry-connrefused \
-X PUT \
-H "Authorization: token $GITEA_TOKEN" \
--upload-file "$path" \
"$PKG_BASE/$f"
)"
case "$code" in
201|200) ok "$f publicado" ;;
409)
err "$f ya está publicado en la versión $VERSION"
echo " Los paquetes genéricos son inmutables. Sube una versión nueva (recomendado)" >&2
echo " o reemplaza esta a propósito con --force." >&2
exit 1 ;;
401|403)
err "Gitea rechazó el token ($code); revisa GITEA_TOKEN y su scope write:package"
exit 1 ;;
*)
err "fallo subiendo $f (HTTP $code)"
exit 1 ;;
esac
done
# --- Verificación contra la API que consume el PANEL ---------------------------------
say "Verificando en Gitea (misma API que lee el PANEL)"
files_json="$(
curl -sS -H "Authorization: token $GITEA_TOKEN" -H 'Accept: application/json' \
"$API_BASE/files"
)"
if ! MANIFEST="$MANIFEST" FILES_JSON="$files_json" python3 <<'PY'
import json, os, sys
manifest = json.load(open(os.environ["MANIFEST"], encoding="utf-8"))
try:
remote = json.loads(os.environ["FILES_JSON"])
except json.JSONDecodeError:
sys.exit("Gitea no devolvió JSON al listar los archivos del paquete")
if not isinstance(remote, list):
sys.exit(f"Respuesta inesperada de Gitea: {remote!r}")
by_name = {entry.get("name"): entry for entry in remote}
problems = []
for artifact in manifest["artifacts"]:
name = artifact["file_name"]
entry = by_name.get(name)
if entry is None:
problems.append(f"{name}: no aparece en Gitea")
continue
# Gitea calcula el sha256 del lado servidor: si coincide con el local, los bytes
# llegaron completos y el PANEL podrá verificar la descarga con ese mismo hash.
remote_sha = (entry.get("sha256") or "").lower()
if remote_sha != artifact["sha256"].lower():
problems.append(f"{name}: sha256 local {artifact['sha256'][:12]}… != remoto {remote_sha[:12] or '(vacío)'}…")
elif int(entry.get("size") or -1) != artifact["size"]:
problems.append(f"{name}: tamaño local {artifact['size']} != remoto {entry.get('size')}")
else:
print(f" {name}: sha256 y tamaño coinciden")
if problems:
print("\n".join(f" {p}" for p in problems), file=sys.stderr)
sys.exit(1)
PY
then
err "la verificación contra Gitea falló; la publicación quedó incompleta"
echo " Revisa el paquete y reintenta con --force." >&2
exit 1
fi
ok "verificación correcta"
# --- Aviso al PANEL ------------------------------------------------------------------
if [[ "$NOTIFY_PANEL" -eq 1 ]]; then
if [[ -z "${PANEL_API_URL:-}" || -z "${CLOUDRESTORE_API_TOKEN:-}" ]]; then
warn "--notify-panel requiere PANEL_API_URL y CLOUDRESTORE_API_TOKEN; se omite el aviso"
warn "El PANEL igual descubrirá la versión al sincronizar desde su UI."
else
say "Avisando al PANEL para que sincronice"
base="${PANEL_API_URL%/}"
code="$(
curl -sS -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: Bearer $CLOUDRESTORE_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{}' \
"$base/api/restore/agent-sync"
)"
if [[ "$code" == "200" ]]; then
ok "PANEL sincronizado"
else
# No es fatal: la publicación en Gitea ya está firme y el panel puede sincronizar
# después desde su UI. Solo se informa.
warn "el PANEL respondió $code al sincronizar; hazlo desde /versiones-cras"
fi
fi
fi
echo ""
ok "CloudRestoreAS $VERSION publicado en Gitea"
echo " Paquete: $GITEA_BASE_URL/-/packages/$GITEA_OWNER/generic/$CRAS_PACKAGE/$VERSION"
echo " Siguiente: en el PANEL, /versiones-cras → Sincronizar con Gitea → Activar"

View File

@@ -1,6 +1,7 @@
# CloudRestoreAS — configuración local (editar y reiniciar la app)
# Arranca el motor al abrir (la ventana siempre se muestra salvo START_MINIMIZED=true)
# Arranca el motor al abrir. La ventana SIEMPRE se muestra al iniciar; cerrar (X) la
# minimiza a la bandeja del sistema (START_MINIMIZED ya no oculta la ventana).
CLOUDRESTORE_AUTO_START=true
CLOUDRESTORE_START_MINIMIZED=false
CLOUDRESTORE_REGISTER_AUTOSTART=true

242
runner.py
View File

@@ -1,27 +1,15 @@
"""Punto de entrada principal de la aplicación."""
import argparse
import os
import sys
import traceback
from pathlib import Path
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
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="CloudRestoreAS")
@@ -35,26 +23,189 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
action="store_true",
help="Inicia el motor de restauración automáticamente",
)
parser.add_argument(
"--headless",
action="store_true",
help="Fuerza el modo sin interfaz (Qt offscreen) para servidores sin display",
)
parser.add_argument(
"--version",
action="store_true",
help="Imprime la versión, plataforma y arquitectura, y termina",
)
return parser.parse_args(argv)
def main():
"""Función principal."""
def _attach_windows_console() -> None:
"""
El .exe se compila con console=False (app de ventana), así que sin consola propia
stdout va al vacío. Cuando se invoca desde una terminal, se engancha a la consola
del proceso padre para que --version sea legible. En Linux no aplica.
"""
if sys.platform != "win32":
return
try:
import ctypes
attach_parent_process = -1
if not ctypes.windll.kernel32.AttachConsole(attach_parent_process):
return # sin consola del padre (doble clic): no hay dónde escribir
sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1)
sys.stderr = open("CONOUT$", "w", encoding="utf-8", buffering=1)
except (OSError, AttributeError):
# Sin consola disponible; el instalador remoto verifica por config/.version.
pass
def _print_version() -> int:
"""
Imprime la versión sin arrancar Qt ni el bootstrap: --version es una consulta.
El instalador remoto del PANEL prefiere leer config/.version (que el bootstrap
escribe), porque en Windows este stdout depende de haber consola del padre.
"""
from app import __version__
from app.constants import APP_ARCH, APP_PLATFORM
_attach_windows_console()
try:
sys.stdout.write(f"CloudRestoreAS {__version__} ({APP_PLATFORM}/{APP_ARCH})\n")
sys.stdout.flush()
except OSError:
pass
return 0
def _ensure_qt_platform(headless: bool = False) -> str:
"""
Selecciona el plugin de plataforma Qt.
`--headless` fuerza 'offscreen' en TODAS las plataformas. Antes se salía de inmediato en
Windows, así que la bandera no hacía nada ahí: la tarea programada ONSTART —que corre como
SYSTEM, en la sesión 0, sin escritorio interactivo— arrancaba con el plugin 'windows' e
intentaba crear una ventana real. Ese es el motivo de que el agente no levantara tras
instalarse en Windows mientras en Linux, donde el unit fija QT_QPA_PLATFORM=offscreen, sí.
El plugin va embebido en el binario de las dos plataformas (qoffscreen.dll / libqoffscreen.so).
La autodetección por DISPLAY/WAYLAND_DISPLAY sigue siendo solo de Linux: es donde su ausencia
significa "no hay servidor gráfico". En Windows y macOS no existen esas variables y tomarlas
como señal mandaría a offscreen a cualquiera que abra la app con doble clic.
No toca nada si el usuario ya fijó QT_QPA_PLATFORM: una elección explícita manda sobre todo
lo demás.
Devuelve la plataforma en uso ("offscreen") o "" si se deja el default.
"""
if os.environ.get("QT_QPA_PLATFORM"):
return os.environ["QT_QPA_PLATFORM"]
if headless:
os.environ["QT_QPA_PLATFORM"] = "offscreen"
return "offscreen"
if sys.platform in ("win32", "darwin"):
return ""
has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
if not has_display:
os.environ["QT_QPA_PLATFORM"] = "offscreen"
return "offscreen"
return ""
def _crash_log_targets() -> list[Path]:
"""Ubicaciones candidatas para el crash log, de más a menos accesible."""
targets: list[Path] = []
try:
targets.append(Path(sys.executable).resolve().parent / "CloudRestoreAS-crash.log")
except Exception:
pass
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or os.environ.get("HOME")
if base:
targets.append(Path(base) / "CloudRestoreAS" / "crash.log")
import tempfile
targets.append(Path(tempfile.gettempdir()) / "CloudRestoreAS-crash.log")
return targets
def _write_crash_log(text: str) -> Path | None:
"""Escribe el detalle del crash en la primera ubicación escribible."""
for path in _crash_log_targets():
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
except Exception:
continue
return None
def _show_fatal(exc: BaseException) -> None:
"""Hace VISIBLE un fallo de arranque: crash log + diálogo (o stderr)."""
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
detail = f"CloudRestoreAS no pudo iniciar.\n\n{type(exc).__name__}: {exc}\n\n{tb}"
crash_path = _write_crash_log(detail)
try:
sys.stderr.write(detail)
except Exception:
pass
try:
# Sin display, forzar offscreen para que crear la QApplication no falle
# también aquí (el diálogo no se verá, pero el crash log ya quedó escrito).
_ensure_qt_platform()
from PySide6.QtWidgets import QApplication, QMessageBox
app = QApplication.instance() or QApplication(sys.argv)
box = QMessageBox()
box.setIcon(QMessageBox.Icon.Critical)
box.setWindowTitle("CloudRestoreAS — Error al iniciar")
box.setText("La aplicación no pudo iniciar.")
info = str(exc) or type(exc).__name__
if crash_path:
info += f"\n\nDetalle guardado en:\n{crash_path}"
box.setInformativeText(info)
box.setDetailedText(tb)
box.exec()
except Exception:
# Sin GUI disponible: el crash log y stderr ya quedaron escritos.
pass
def _run() -> int:
"""Arranque real. Los imports están diferidos para que main() capture cualquier fallo."""
# Bootstrap y ODBC antes de importar módulos que usan pyodbc.
from app.config.bootstrap import ensure_runtime_layout
from app.config.odbc_setup import configure_odbc_environment
ensure_runtime_layout()
configure_odbc_environment()
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from app.config import get_launch_options, initialize_env, is_panel_configured
from app.ui.main_window import MainWindow
from app.ui.tray_assets import load_tray_icon
from app.utils.logger import app_logger
args = parse_args()
qt_platform = _ensure_qt_platform(headless=args.headless)
launch = get_launch_options()
initialize_env()
minimized = args.minimized or launch.minimized
panel_ok = is_panel_configured()
start_engine = args.start_engine or launch.start_engine
from app import __version__
app_logger.info("=" * 80)
app_logger.info("CloudRestoreAS - Iniciando aplicación")
# La versión va en la PRIMERA línea del arranque a propósito: es la forma de comprobar qué
# binario está corriendo de verdad sin depender del sello ni del reporte al panel, mirando
# solo config/logs. Sin esto, verificar una actualización obligaba a creerse lo que dijera
# otro sistema.
app_logger.info(f"CloudRestoreAS {__version__} - Iniciando aplicación")
app_logger.info(f"Directorio app: {ROOT_DIR}")
if minimized:
app_logger.info("Modo: ventana oculta (--minimized o START_MINIMIZED)")
if qt_platform == "offscreen":
app_logger.info("Modo: sin display → plataforma Qt 'offscreen' (motor headless)")
else:
app_logger.info("Modo: ventana visible; cerrar minimiza a bandeja")
app_logger.info("Modo: ventana siempre visible; cerrar minimiza a bandeja")
if start_engine:
app_logger.info("Modo: motor auto-inicio")
if not panel_ok:
@@ -69,25 +220,40 @@ def main():
app.setOrganizationName("Aduanasoft")
app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling)
app.setQuitOnLastWindowClosed(False)
app.setWindowIcon(load_tray_icon())
window = MainWindow(
start_engine=start_engine,
panel_configured=panel_ok,
)
# Con display, la ventana SIEMPRE se muestra al iniciar; cerrar (X) la manda a la bandeja.
# En offscreen no hay a quién mostrarla, y pedirlo igual solo da trabajo al plugin y ruido en
# el log. El motor no depende de esto: arranca por su propio temporizador en MainWindow.
if qt_platform == "offscreen":
app_logger.info("Sin interfaz: la ventana no se muestra (motor headless)")
else:
window.show()
window.activateWindow()
window.raise_()
app_logger.info("Ventana principal mostrada")
exit_code = app.exec()
app_logger.info(f"Aplicación finalizada con código: {exit_code}")
return exit_code
def main():
"""Punto de entrada: ejecuta _run() y hace visible cualquier fallo de arranque."""
# --version se atiende antes del bootstrap y de importar Qt/pyodbc: es una consulta
# barata que el instalador remoto usa para verificar el binario recién desplegado.
if "--version" in sys.argv[1:]:
sys.exit(_print_version())
try:
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(_run())
except SystemExit:
raise
except BaseException as exc: # capturamos TODO en el arranque: nunca morir en silencio
_show_fatal(exc)
sys.exit(1)

128
scripts/dev-setup.ps1 Normal file
View File

@@ -0,0 +1,128 @@
# Preparación del ENTORNO DE DESARROLLO en Windows: verifica Python/7-Zip/ODBC del
# sistema, crea el venv e instala requirements.txt para correr `python runner.py`.
#
# NO es el instalador de despliegue. Para instalar el binario compilado en un servidor
# usa install.ps1 (raíz del repo), que es autocontenido y no necesita nada de esto.
Write-Host "===============================================" -ForegroundColor Cyan
Write-Host "CloudRestoreAS - Entorno de desarrollo" -ForegroundColor Cyan
Write-Host "===============================================" -ForegroundColor Cyan
Write-Host ""
# Verificar Python
Write-Host "1. Verificando Python..." -ForegroundColor Yellow
$pythonVersion = python --version 2>$null
if (-not $pythonVersion) {
Write-Host "❌ Python no está instalado o no está en el PATH" -ForegroundColor Red
Write-Host " Descarga Python 3.11+ desde: https://www.python.org/downloads/" -ForegroundColor Yellow
Read-Host "Presiona Enter para salir..."
exit 1
}
Write-Host "$pythonVersion" -ForegroundColor Green
# Verificar versión de Python
$versionString = $pythonVersion -replace "Python ", ""
$version = [version]($versionString.Split()[0])
if ($version -lt [version]"3.11") {
Write-Host "❌ Python $version es demasiado antiguo. Se requiere 3.11+" -ForegroundColor Red
Read-Host "Presiona Enter para salir..."
exit 1
}
# Verificar 7-Zip
Write-Host ""
Write-Host "2. Verificando 7-Zip..." -ForegroundColor Yellow
$sevenZipPaths = @(
"C:\Program Files\7-Zip\7z.exe",
"D:\Program Files\7-Zip\7z.exe",
"C:\Program Files (x86)\7-Zip\7z.exe"
)
$sevenZipFound = $false
foreach ($path in $sevenZipPaths) {
if (Test-Path $path) {
Write-Host "✅ 7-Zip encontrado en: $path" -ForegroundColor Green
$sevenZipFound = $true
break
}
}
if (-not $sevenZipFound) {
Write-Host "⚠️ 7-Zip no encontrado en ubicaciones estándar" -ForegroundColor Yellow
Write-Host " Descarga 7-Zip desde: https://www.7-zip.org/" -ForegroundColor Yellow
Write-Host " (Puedes configurar la ruta manualmente en la aplicación)" -ForegroundColor Cyan
}
# Verificar ODBC Driver
Write-Host ""
Write-Host "3. Verificando ODBC Driver for SQL Server..." -ForegroundColor Yellow
$odbcDrivers = Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"}
if ($odbcDrivers) {
Write-Host "✅ ODBC Driver encontrado:" -ForegroundColor Green
$odbcDrivers | ForEach-Object { Write-Host " - $($_.Name)" -ForegroundColor Gray }
} else {
Write-Host "⚠️ ODBC Driver for SQL Server no encontrado" -ForegroundColor Yellow
Write-Host " Descarga desde: https://aka.ms/downloadmsodbcsql" -ForegroundColor Yellow
Write-Host " (Requerido para conectar con SQL Server)" -ForegroundColor Cyan
}
# Crear entorno virtual
Write-Host ""
Write-Host "4. Creando entorno virtual..." -ForegroundColor Yellow
if (Test-Path "venv") {
Write-Host " El entorno virtual ya existe, omitiendo..." -ForegroundColor Gray
} else {
python -m venv venv
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ Entorno virtual creado" -ForegroundColor Green
} else {
Write-Host "❌ Error creando entorno virtual" -ForegroundColor Red
Read-Host "Presiona Enter para salir..."
exit 1
}
}
# Activar entorno virtual
Write-Host ""
Write-Host "5. Instalando dependencias..." -ForegroundColor Yellow
& ".\venv\Scripts\python.exe" -m pip install --upgrade pip
& ".\venv\Scripts\pip.exe" install -r requirements.txt
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ Dependencias instaladas" -ForegroundColor Green
} else {
Write-Host "❌ Error instalando dependencias" -ForegroundColor Red
Read-Host "Presiona Enter para salir..."
exit 1
}
# Crear directorios necesarios
Write-Host ""
Write-Host "6. Creando directorios..." -ForegroundColor Yellow
$dirs = @("data", "logs")
foreach ($dir in $dirs) {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir | Out-Null
Write-Host "✅ Creado: $dir" -ForegroundColor Green
} else {
Write-Host " $dir ya existe" -ForegroundColor Gray
}
}
# Resumen
Write-Host ""
Write-Host "===============================================" -ForegroundColor Green
Write-Host "Instalación Completada" -ForegroundColor Green
Write-Host "===============================================" -ForegroundColor Green
Write-Host ""
Write-Host "Para ejecutar la aplicación:" -ForegroundColor Cyan
Write-Host " 1. Activa el entorno virtual:" -ForegroundColor White
Write-Host " .\venv\Scripts\Activate.ps1" -ForegroundColor Yellow
Write-Host " 2. Ejecuta la aplicación:" -ForegroundColor White
Write-Host " python runner.py" -ForegroundColor Yellow
Write-Host ""
Write-Host "O ejecuta directamente:" -ForegroundColor Cyan
Write-Host " .\venv\Scripts\python.exe runner.py" -ForegroundColor Yellow
Write-Host ""
Write-Host "Lee el README.md para configuración completa." -ForegroundColor Cyan
Write-Host ""
Read-Host "Presiona Enter para salir..."

View File

@@ -0,0 +1,227 @@
<#
Emulación end-to-end en Windows de la actualización que "completa y no hace nada".
Reproduce el caso del servidor real: el agente vive en la carpeta que el panel tiene declarada,
pero la tarea programada ejecuta el binario de OTRA carpeta. Reemplazar el binario de la primera
y arrancar la tarea levanta el de la segunda.
Qué es REAL aquí: el agente es un .exe compilado que se queda vivo, los archivos y carpetas, la
copia del binario, la identidad del proceso por ruta, el sello config\.version y la reversión.
El "viejo" NO escribe el sello (igual que 1.1.0) y el "nuevo" sí (igual que 1.1.3) — esa
diferencia es la que hacía que la verificación del panel tolerara el sello vacío.
Qué está simulado y por qué: los cmdlets *-ScheduledTask. Registrar una tarea exige elevación, y
la gracia de esta emulación es poder correrla sin ella. Los sustitutos respetan la semántica que
provoca el fallo: Start-ScheduledTask lanza LA RUTA REGISTRADA EN LA TAREA, no la que se acaba de
instalar. Como install.ps1 se invoca con &, sus llamadas resuelven a estas funciones del ámbito
padre. Si quieres la prueba con una tarea de verdad, corre esto en PowerShell elevado tras
quitar los sustitutos.
Uso:
.\emular-actualizacion-windows.ps1 -Installer ..\install.ps1
.\emular-actualizacion-windows.ps1 -Installer ..\install.ps1 -Escenario alineada
Encontró dos defectos que las pruebas de unidad no veían: el rearranque del binario equivocado,
y que Merge-EnvFile reventaba con un config\.env de una sola línea.
#>
param(
# install.ps1 a poner a prueba. Para comparar contra una versión anterior:
# git show <commit>:install.ps1 > install-antes.ps1
[Parameter(Mandatory = $true)][string]$Installer,
[string]$Etiqueta = 'instalador',
# 'desalineada' reproduce el servidor que falla; 'alineada' es el caso normal, para comprobar
# que el arreglo no lo rompe (una tarea que ya apunta bien no debe tocarse).
#
# 'sufijo' es el caso PELIGROSO y existe en producción: la instalación está en
# ...\CloudRestoreAS-win y la tarea apunta a ...\CloudRestoreAS, que es prefijo de cadena de la
# anterior. Cualquier comparación de rutas hecha con `startsWith` las daría por iguales, no
# corregiría la tarea, y la actualización volvería a no surtir efecto.
[ValidateSet('desalineada', 'alineada', 'sufijo')][string]$Escenario = 'desalineada',
[string]$Carpeta = 'C:\Users\Public\cras-e2e'
)
$ErrorActionPreference = 'Stop'
$base = $Carpeta
$pkg = Join-Path $base 'pkg'
# En el escenario 'sufijo' los nombres NO son arbitrarios: reproducen el par que se da en producción,
# donde el nombre de una carpeta es prefijo de cadena del de la otra. Con nombres sin relación entre
# sí (declarada / otra-carpeta) un `startsWith` mal puesto pasaría la prueba sin problema.
if ($Escenario -eq 'sufijo') {
$declarada = Join-Path $base 'CloudRestoreAS-win' # la instalación real
$otra = Join-Path $base 'CloudRestoreAS' # a donde apunta la tarea
} else {
$declarada = Join-Path $base 'declarada' # donde el panel cree que está (y está)
$otra = Join-Path $base 'otra-carpeta' # a donde apunta la tarea
}
function Nuevo-AgenteFalso {
param([string]$Destino, [string]$Version, [switch]$EscribeSello)
# Here-strings LITERALES: el C# lleva comillas dobles y en una cadena interpolada de PowerShell
# habría que escaparlas con backtick, no con barra invertida.
$plantilla = @'
using System; using System.IO; using System.Threading;
class P { static void Main() {
string d = AppDomain.CurrentDomain.BaseDirectory;
__CUERPO__
Thread.Sleep(Timeout.Infinite); } }
'@
$cuerpo = if ($EscribeSello) {
@'
Directory.CreateDirectory(Path.Combine(d, "config"));
File.WriteAllText(Path.Combine(d, "config", ".version"), "__VER__");
'@
} else {
' // 1.1.0 no escribia el sello de version'
}
$src = $plantilla.Replace('__CUERPO__', $cuerpo).Replace('__VER__', $Version)
New-Item -ItemType Directory -Path (Split-Path -Parent $Destino) -Force | Out-Null
Add-Type -TypeDefinition $src -OutputAssembly $Destino -OutputType ConsoleApplication
}
function Matar-Agentes {
Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 800
}
# ------------------------------------------------ tarea programada simulada (estado + semántica)
$global:tareaExiste = $true
$global:tareaExecute = if ($Escenario -eq 'alineada') {
Join-Path $declarada 'CloudRestoreAS.exe'
} else {
Join-Path $otra 'CloudRestoreAS.exe'
}
$global:tareaArgs = '--start-engine --headless'
$global:reapuntadaVeces = 0
function Get-ScheduledTask {
[CmdletBinding()] param([string]$TaskName)
if (-not $global:tareaExiste) { return $null }
[pscustomobject]@{
TaskName = 'CloudRestoreAS'
State = 'Running'
Actions = @([pscustomobject]@{
Execute = $global:tareaExecute
Arguments = $global:tareaArgs
})
}
}
function Set-ScheduledTask {
[CmdletBinding()] param([string]$TaskName, $Action)
$global:tareaExecute = $Action[0].Execute
$global:tareaArgs = $Action[0].Arguments
$global:reapuntadaVeces++
return $true
}
function New-ScheduledTaskAction {
[CmdletBinding()] param([string]$Execute, [string]$Argument, [string]$WorkingDirectory)
[pscustomobject]@{ Execute = $Execute; Arguments = $Argument }
}
function Start-ScheduledTask {
# AQUÍ está el corazón del fallo: se lanza la ruta REGISTRADA EN LA TAREA.
[CmdletBinding()] param([string]$TaskName)
Start-Process -FilePath $global:tareaExecute `
-WorkingDirectory (Split-Path -Parent $global:tareaExecute) -WindowStyle Hidden | Out-Null
}
function Stop-ScheduledTask {
[CmdletBinding()] param([string]$TaskName)
Matar-Agentes
}
# ---------------------------------------------------------------- montaje del escenario
Matar-Agentes
Remove-Item $base -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "=== Montando escenario: $Etiqueta ===" -ForegroundColor Cyan
Nuevo-AgenteFalso -Destino (Join-Path $declarada 'CloudRestoreAS.exe') -Version '1.1.0'
New-Item -ItemType Directory -Path (Join-Path $declarada 'config') -Force | Out-Null
# Multilínea, como el que escribe el bootstrap del agente. Con UNA sola línea se destapa un bug
# aparte de Merge-EnvFile (el array se desenrolla a escalar y $lines.Count revienta con StrictMode),
# que el instalador viejo todavía tiene y aquí taparía el fallo que se quiere medir.
Set-Content -Path (Join-Path $declarada 'config\.env') -Value @(
'# Configuracion de CloudRestoreAS',
'CLOUDRESTORE_PANEL_API_URL=http://ejemplo.local:3000',
'CLOUDRESTORE_AUTO_START=true'
)
Nuevo-AgenteFalso -Destino (Join-Path $otra 'CloudRestoreAS.exe') -Version '1.1.0'
New-Item -ItemType Directory -Path $pkg -Force | Out-Null
Copy-Item $Installer (Join-Path $pkg 'install.ps1') -Force
Nuevo-AgenteFalso -Destino (Join-Path $pkg 'dist\CloudRestoreAS.exe') -Version '1.1.3' -EscribeSello
$panelEnv = Join-Path $base 'panel.env'
Set-Content -Path $panelEnv -Value @(
'CLOUDRESTORE_PANEL_API_URL=http://ejemplo.local:3000',
'CLOUDRESTORE_PANEL_API_TOKEN=token-de-prueba-no-real',
'CLOUDRESTORE_PANEL_INSTANCE_KEY=servidor-de-prueba'
)
# El agente viejo corriendo, lanzado por la tarea desde su carpeta.
Start-ScheduledTask -TaskName 'CloudRestoreAS'
Start-Sleep -Seconds 2
Write-Host " instalacion declarada : $declarada"
Write-Host " tarea ejecuta : $global:tareaExecute"
$antes = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue |
ForEach-Object { $_.Path } | Select-Object -Unique)
Write-Host " proceso corriendo de : $($antes -join ', ')"
# ---------------------------------------------------------------- la actualización
Write-Host ''
Write-Host '=== Actualizacion, tal como la lanza el panel ===' -ForegroundColor Cyan
$codigo = 0
try {
& (Join-Path $pkg 'install.ps1') -UpdateInPlace -Prefix $declarada -PanelEnvFile $panelEnv
$codigo = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }
} catch {
$codigo = 1
Write-Host " [el instalador lanzo] $($_.Exception.Message)" -ForegroundColor Yellow
}
Write-Host "--- codigo de salida: $codigo"
# ---------------------------------------------------------------- veredicto
Start-Sleep -Seconds 3
Write-Host ''
Write-Host '=== RESULTADO ===' -ForegroundColor Cyan
$esperado = Join-Path $declarada 'CloudRestoreAS.exe'
$procAhora = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue |
ForEach-Object { try { $_.Path } catch { '(ilegible)' } } | Select-Object -Unique)
$selloPath = Join-Path $declarada 'config\.version'
$sello = if (Test-Path $selloPath) { (Get-Content $selloPath -Raw).Trim() } else { '(no existe)' }
Write-Host " tarea ejecuta : $global:tareaExecute (reapuntada $global:reapuntadaVeces vez/veces)"
Write-Host " proceso corriendo de : $($procAhora -join ', ')"
Write-Host " config\.version : $sello"
$fallos = @()
if ($global:tareaExecute -ne $esperado) { $fallos += "la tarea sigue apuntando a $global:tareaExecute" }
if ($procAhora -notcontains $esperado) { $fallos += 'el proceso vivo no es el de la instalacion declarada' }
if ($sello -ne '1.1.3') { $fallos += "el sello dice '$sello', se esperaba 1.1.3" }
# Una tarea que ya apuntaba bien no debe reescribirse: hacerlo sin necesidad arriesga perder
# ajustes que el operador le haya hecho.
if ($Escenario -eq 'alineada' -and $global:reapuntadaVeces -ne 0) {
$fallos += "se reescribio una tarea que ya estaba bien ($global:reapuntadaVeces vez/veces)"
}
Write-Host ''
if ($fallos.Count -eq 0 -and $codigo -eq 0) {
Write-Host 'VEREDICTO: la actualizacion SI surtio efecto' -ForegroundColor Green
} elseif ($fallos.Count -eq 0) {
Write-Host "VEREDICTO: surtio efecto pero el instalador salio con $codigo" -ForegroundColor Yellow
} else {
Write-Host 'VEREDICTO: la actualizacion NO surtio efecto' -ForegroundColor Red
$fallos | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
if ($codigo -eq 0) {
Write-Host ' *** y el instalador dijo que TODO BIEN (codigo 0) ***' -ForegroundColor Red
}
}
Matar-Agentes
Remove-Item $base -Recurse -Force -ErrorAction SilentlyContinue

View File

@@ -0,0 +1,111 @@
<#
Pruebas de las funciones de install.ps1, sin instalar nada.
Las funciones se extraen del script por AST y se ejercitan contra una tarea programada simulada.
Así se prueba el COMPORTAMIENTO —no solo que el archivo parsee— sin necesitar elevación ni
registrar una tarea 'CloudRestoreAS' de verdad en la máquina.
Complementa a emular-actualizacion-windows.ps1: ese cubre el flujo completo, este cubre los casos
límite de la comparación de rutas, que es donde se esconden los fallos silenciosos.
Uso: .\probar-funciones-install.ps1
#>
param([string]$Installer = (Join-Path (Split-Path -Parent $PSScriptRoot) 'install.ps1'))
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$tokens = $null; $errores = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path $Installer).Path, [ref]$tokens, [ref]$errores)
if ($errores -and $errores.Count -gt 0) {
Write-Host "install.ps1 no parsea: $($errores.Count) error(es)" -ForegroundColor Red
$errores | ForEach-Object { Write-Host (" L" + $_.Extent.StartLineNumber + ": " + $_.Message) }
exit 1
}
# Variables de ámbito de script que usan las funciones.
$Prefix = 'C:\Aduanasoft\CloudRestoreAS-win'
$dest = Join-Path $Prefix 'CloudRestoreAS.exe'
$TaskName = 'CloudRestoreAS'
$ProcName = 'CloudRestoreAS'
$queremos = @('Write-Step', 'Write-Ok', 'Write-Warn', 'Get-NormalizedPath', 'Test-SamePath',
'Get-AgentTaskExecute', 'Repair-AgentTaskPath', 'Sync-AgentTaskPath')
foreach ($f in $ast.FindAll({ param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
if ($queremos -contains $f.Name) { . ([scriptblock]::Create($f.Extent.Text)) }
}
$fallos = 0
function Assert($cond, $msg) {
if ($cond) { Write-Host " OK $msg" } else { Write-Host " FALLA $msg" -ForegroundColor Red; $script:fallos++ }
}
Write-Host '=== Test-SamePath: rutas donde una es prefijo de cadena de la otra ==='
# El caso que existe en produccion. Un `-like "$a*"` o un StartsWith aqui daria por iguales dos
# instalaciones distintas, no se corregiria la tarea, y la actualizacion no surtiria efecto.
$porOmision = 'C:\Aduanasoft\CloudRestoreAS'
$personalizada = 'C:\Aduanasoft\CloudRestoreAS-win'
Assert (-not (Test-SamePath $porOmision $personalizada)) 'no confunde la ruta por omision con la -win'
Assert (-not (Test-SamePath $personalizada $porOmision)) 'ni al reves'
Assert (-not (Test-SamePath "$porOmision\CloudRestoreAS.exe" "$personalizada\CloudRestoreAS.exe")) `
'tampoco con el ejecutable completo'
Assert (Test-SamePath $personalizada $personalizada) 'y la personalizada sigue siendo igual a si misma'
Write-Host '=== Test-SamePath: normalizacion ==='
Assert (Test-SamePath "`"$personalizada`"" $personalizada) 'tolera comillas (la tarea las guarda asi)'
Assert (Test-SamePath $personalizada.ToUpper() $personalizada) 'NTFS no distingue mayusculas'
Assert (Test-SamePath "$personalizada\" $personalizada) 'ignora la barra final'
Assert (Test-SamePath "$Prefix\..\CloudRestoreAS-win\x.exe" "$Prefix\x.exe") 'normaliza .. en la ruta'
Assert (-not (Test-SamePath '' $personalizada)) 'una ruta vacia no es igual a nada'
Write-Host '=== Get-AgentTaskExecute ==='
function Get-AgentTask { [pscustomobject]@{ Actions = @(
[pscustomobject]@{ Execute = "`"$porOmision\CloudRestoreAS.exe`""; Arguments = '--start-engine --headless' }) } }
Assert ((Get-AgentTaskExecute) -eq "$porOmision\CloudRestoreAS.exe") 'devuelve la ruta sin comillas'
# Una accion ComHandler no tiene .Execute: con StrictMode, tocarla a ciegas seria un error.
function Get-AgentTask { [pscustomobject]@{ Actions = @(
[pscustomobject]@{ ClassId = '{guid}' },
[pscustomobject]@{ Execute = "$porOmision\CloudRestoreAS.exe"; Arguments = '' }) } }
Assert ((Get-AgentTaskExecute) -eq "$porOmision\CloudRestoreAS.exe") 'se salta acciones sin Execute'
function Get-AgentTask { $null }
Assert ((Get-AgentTaskExecute) -eq '') 'sin tarea devuelve cadena vacia'
Write-Host '=== Sync-AgentTaskPath: el fallo silencioso ==='
$script:reapuntadoA = $null
$script:argsPreservados = $null
function Set-ScheduledTask { param($TaskName, $Action)
$ejec = @($Action | Where-Object { $_.PSObject.Properties['Execute'] -and $_.Execute })
$script:reapuntadoA = $ejec[0].Execute
$script:argsPreservados = $ejec[0].Arguments
return $true }
function New-ScheduledTaskAction { param($Execute, $Argument, $WorkingDirectory)
[pscustomobject]@{ Execute = $Execute; Arguments = $Argument } }
function Get-AgentTask { [pscustomobject]@{ Actions = @(
[pscustomobject]@{ Execute = "$porOmision\CloudRestoreAS.exe"; Arguments = '--start-engine --headless' }) } }
Sync-AgentTaskPath
Assert ($script:reapuntadoA -eq $dest) "reapunta de la carpeta por omision a $dest"
Assert ($script:argsPreservados -eq '--start-engine --headless') 'conserva los argumentos originales'
$script:reapuntadoA = $null
function Get-AgentTask { [pscustomobject]@{ Actions = @(
[pscustomobject]@{ Execute = $dest; Arguments = '--start-engine --headless' }) } }
Sync-AgentTaskPath
Assert ($null -eq $script:reapuntadoA) 'una tarea ya alineada no se toca'
# Si no se puede corregir tiene que FALLAR, no arrancar el binario viejo en silencio.
function Set-ScheduledTask { param($TaskName, $Action) throw 'Acceso denegado' }
function Get-AgentTask { [pscustomobject]@{ Actions = @(
[pscustomobject]@{ Execute = "$porOmision\CloudRestoreAS.exe"; Arguments = '' }) } }
$lanzo = $false; $msg = ''
try { Sync-AgentTaskPath } catch { $lanzo = $true; $msg = $_.Exception.Message }
Assert $lanzo 'sin permiso para corregir -> lanza en vez de seguir'
Assert ($lanzo -and $msg -match 'sin efecto') 'el mensaje explica que la actualizacion no surtiria efecto'
Write-Host ''
if ($fallos -gt 0) { Write-Host "FALLOS: $fallos" -ForegroundColor Red; exit 1 }
Write-Host 'TODO OK' -ForegroundColor Green

View File

@@ -5,6 +5,7 @@ from pathlib import Path
import pytest
from app.config import bootstrap
from app.config.bootstrap import ensure_runtime_layout
from app.config.env_loader import (
apply_env_overrides,
@@ -77,3 +78,146 @@ def test_apply_env_overrides_paths(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CLOUDRESTORE_INPUT_FOLDER", "/tmp/in")
cfg = apply_env_overrides(DEFAULT_CONFIG.copy())
assert cfg["paths"]["input_folder"] == "/tmp/in"
def _redirect_bootstrap(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Reapunta los globales de bootstrap a un árbol temporal. Devuelve config/."""
for name, rel in (
("APP_DIR", ""),
("CONFIG_DIR", "config"),
("DATA_DIR", "config/data"),
("LOGS_DIR", "config/logs"),
("ODBC_DIR", "config/odbc"),
("SEVEN_ZIP_DIR", "config/7zip"),
("ENV_PATH", "config/.env"),
("DIR_ENTRADA", "Entrada"),
("DIR_PROCESADOS", "Procesados"),
("DIR_FALLADOS", "Fallados"),
("DIR_TEMP", "Temp"),
):
target = tmp_path / rel if rel else tmp_path
monkeypatch.setattr(f"app.config.bootstrap.{name}", target)
monkeypatch.setattr("app.constants.DB_PATH", tmp_path / "config" / "data" / "app.db")
return tmp_path / "config"
def test_bootstrap_escribe_sello_de_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""
El instalador remoto del PANEL lee config/.version por SFTP para verificar el
despliegue: en Windows el .exe se compila con console=False y no hay stdout confiable.
"""
from app import __version__
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
ensure_runtime_layout()
assert (config_dir / ".version").read_text(encoding="utf-8").strip() == __version__
def test_sello_de_version_se_escribe_antes_de_copiar_las_deps(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""
El sello tiene que existir ANTES de re-desplegar 7-Zip y ODBC.
Iba al final, y eso lo volvía inservible justo cuando más importa: al cambiar de versión las
deps embebidas se re-copian enteras, así que el sello quedaba por detrás de esa copia y del
desempaquetado del onefile de ~254 MB con el antivirus escaneando. El PANEL se rendía
esperándolo y reportaba como fallida una actualización que en realidad iba bien.
"""
from app import __version__
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
# Se observa el estado del sello EN EL MOMENTO en que empieza la copia de deps.
visto: dict[str, str | None] = {}
real = bootstrap._copy_bundled_tree
def espia(*args, **kwargs):
sello = config_dir / ".version"
visto.setdefault(
"al_copiar",
sello.read_text(encoding="utf-8").strip() if sello.exists() else None,
)
return real(*args, **kwargs)
monkeypatch.setattr("app.config.bootstrap._copy_bundled_tree", espia)
ensure_runtime_layout()
assert visto["al_copiar"] == __version__, (
"el sello de versión debe existir antes de empezar a copiar las deps embebidas"
)
def test_bootstrap_redespliega_deps_cuando_cambia_el_manifiesto(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""
config/7zip y config/odbc se re-copian cuando el build trae otras versiones embebidas.
Antes solo se copiaban si la carpeta estaba vacía, así que una actualización con driver
ODBC nuevo conservaba el viejo indefinidamente.
"""
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
src_7zip = tmp_path / "bundle" / "7zip"
src_odbc = tmp_path / "bundle" / "odbc"
src_7zip.mkdir(parents=True)
src_odbc.mkdir(parents=True)
(src_7zip / "7zz").write_text("7zz v26", encoding="utf-8")
(src_odbc / "odbcinst.ini").write_text("odbc 18.5", encoding="utf-8")
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_7ZIP", src_7zip)
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_ODBC", src_odbc)
# bundled-versions.json se busca bajo BUNDLE_DIR/packaging (va embebido en el onefile).
bundle_root = tmp_path / "bundle_root"
(bundle_root / "packaging").mkdir(parents=True)
manifest = bundle_root / "packaging" / "bundled-versions.json"
manifest.write_text('{"seven_zip": "26.01"}', encoding="utf-8")
monkeypatch.setattr("app.config.bootstrap.BUNDLE_DIR", bundle_root)
ensure_runtime_layout()
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "7zz v26"
stamp_before = (config_dir / ".bundled_deps").read_text(encoding="utf-8").strip()
assert stamp_before.startswith("sha256:")
# Mismo manifiesto: no debe re-copiar (no pisa ajustes locales en cada arranque).
(config_dir / "7zip" / "7zz").write_text("editado a mano", encoding="utf-8")
ensure_runtime_layout()
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "editado a mano"
# Manifiesto distinto (build con deps nuevas): debe re-copiar.
(src_7zip / "7zz").write_text("7zz v27", encoding="utf-8")
(src_odbc / "odbcinst.ini").write_text("odbc 19.0", encoding="utf-8")
manifest.write_text('{"seven_zip": "27.00"}', encoding="utf-8")
ensure_runtime_layout()
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "7zz v27"
assert (config_dir / "odbc" / "odbcinst.ini").read_text(encoding="utf-8") == "odbc 19.0"
assert (config_dir / ".bundled_deps").read_text(encoding="utf-8").strip() != stamp_before
# El re-despliegue SOBRESCRIBE, así que lo que el operador hubiera editado a mano debe
# quedar respaldado: no es reconstruible.
assert (config_dir / "7zip.bak" / "7zz").read_text(encoding="utf-8") == "editado a mano"
assert (config_dir / "odbc.bak" / "odbcinst.ini").read_text(encoding="utf-8") == "odbc 18.5"
def test_bootstrap_no_respalda_cuando_no_hay_refresco(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""Sin cambio de manifiesto no se re-copia, así que tampoco debe crearse el .bak."""
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
src_7zip = tmp_path / "bundle" / "7zip"
src_7zip.mkdir(parents=True)
(src_7zip / "7zz").write_text("7zz v26", encoding="utf-8")
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_7ZIP", src_7zip)
bundle_root = tmp_path / "bundle_root"
(bundle_root / "packaging").mkdir(parents=True)
(bundle_root / "packaging" / "bundled-versions.json").write_text(
'{"seven_zip": "26.01"}', encoding="utf-8"
)
monkeypatch.setattr("app.config.bootstrap.BUNDLE_DIR", bundle_root)
ensure_runtime_layout()
ensure_runtime_layout() # segunda vez: mismo manifiesto, no debe refrescar
assert not (config_dir / "7zip.bak").exists()

View File

@@ -0,0 +1,170 @@
"""
Regresión del "falso fallo" en el reenvío por SFTP.
Escenario reportado: el SFTP SÍ entrega el archivo, pero un error POSTERIOR a la entrega
(p.ej. al mover el ZIP a Procesados) degradaba el job a FAILED y lo reportaba como 'failed'
al panel, aunque el respaldo ya había llegado al destino. La corrección hace que la entrega
exitosa sea el punto de no retorno: el job queda COMPLETED/forwarded y el error posterior
solo se registra, sin caer a Fallados ni reportar 'failed'.
"""
import pytest
from app.constants import JobStatus
from app.engine.restore_worker import RestoreWorker
FORWARD_ROUTE = {
"action": "forward",
"db_name": "DB1",
"node_key": "NODO",
"target": {
"id": 7,
"name": "Omega",
"ssh_host": "h",
"ssh_username": "u",
"ssh_password": "p",
"input_folder": "D:\\In",
},
}
@pytest.fixture
def base_config(tmp_path):
return {
"paths": {
"input_folder": str(tmp_path / "in"),
"processed_folder": str(tmp_path / "processed"),
"failed_folder": str(tmp_path / "failed"),
"extract_folder": str(tmp_path / "extract"),
"data_sql_folder": str(tmp_path / "data"),
"seven_zip_exe": "C:\\Program Files\\7-Zip\\7z.exe",
},
"sql": {"server": "localhost", "use_windows_auth": True},
"timeouts": {"extract_minutes": 30, "restore_minutes": 60},
"panel": {
"api_url": "http://panel:3000",
"api_token": "tok",
"instance_key": "Alfa",
},
}
class FakeJob:
source_name = "NODO.ZIP"
source_path = "C:\\in\\NODO.ZIP"
db_name = "DB1"
node_name = "NODO"
def _wire_common(monkeypatch, statuses, reported):
"""Mockea las dependencias del worker comunes a los dos escenarios."""
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.get", lambda job_id: FakeJob()
)
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.update_node_and_db",
lambda *a, **k: None,
)
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.update_timing", lambda *a, **k: None
)
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.update_status",
lambda job_id, status, **k: statuses.append(status),
)
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.delete", lambda *a, **k: None
)
monkeypatch.setattr(
"app.engine.restore_worker.JobStepRepository.create", lambda *a, **k: 1
)
monkeypatch.setattr(
"app.engine.restore_worker.JobStepRepository.complete", lambda *a, **k: None
)
monkeypatch.setattr(
"app.engine.restore_worker.EventRepository.create", lambda *a, **k: None
)
monkeypatch.setattr(
"app.engine.restore_worker.panel_client.resolve_route",
lambda *a, **k: FORWARD_ROUTE,
)
monkeypatch.setattr(
"app.engine.restore_worker.panel_client.report_job_result",
lambda **k: reported.append(k.get("status")),
)
def test_error_post_entrega_no_degrada_a_fallido(monkeypatch, base_config):
"""Entrega OK + error al mover a Procesados => job forwarded, nunca failed."""
statuses: list[str] = []
reported: list[str] = []
_wire_common(monkeypatch, statuses, reported)
# El SFTP entrega con éxito.
monkeypatch.setattr(
"app.engine.restore_worker.sftp_copy.upload_zip_parts",
lambda *a, **k: ["D:/In/NODO.ZIP"],
)
moved_to_failed = {"called": False}
monkeypatch.setattr(
RestoreWorker,
"_move_zip_to_failed",
lambda self, job: moved_to_failed.__setitem__("called", True),
)
# Error POSTERIOR a la entrega: mover a Procesados falla.
def boom(self, job):
raise OSError("disco lleno al mover a Procesados")
monkeypatch.setattr(RestoreWorker, "_move_zip_to_processed", boom)
worker = RestoreWorker("job-1", base_config)
worker.run()
assert JobStatus.COMPLETED in statuses
assert JobStatus.FAILED not in statuses
assert reported == ["forwarded"]
assert moved_to_failed["called"] is False
def test_fallo_real_de_sftp_va_a_fallados_y_limpia_parcial(monkeypatch, base_config):
"""Fallo genuino de entrega => FAILED, ZIP a Fallados, y limpieza de partes subidas."""
from app.transfer.sftp_copy import SFTPCopyError
statuses: list[str] = []
reported: list[str] = []
_wire_common(monkeypatch, statuses, reported)
# El SFTP falla en la parte 2, adjuntando lo ya subido (envío parcial).
err = SFTPCopyError("timeout en la parte 2")
err.uploaded = ["D:/In/NODO.ZIP.001"]
def failing_upload(*a, **k):
raise err
monkeypatch.setattr(
"app.engine.restore_worker.sftp_copy.upload_zip_parts", failing_upload
)
cleaned: list[str] = []
monkeypatch.setattr(
"app.engine.restore_worker.sftp_copy.cleanup_remote",
lambda target, path: cleaned.append(path),
)
moved_to_failed = {"called": False}
monkeypatch.setattr(
RestoreWorker,
"_move_zip_to_failed",
lambda self, job: moved_to_failed.__setitem__("called", True),
)
worker = RestoreWorker("job-1", base_config)
worker.run()
assert JobStatus.FAILED in statuses
assert JobStatus.COMPLETED not in statuses
assert reported == ["failed"]
assert moved_to_failed["called"] is True
assert cleaned == ["D:/In/NODO.ZIP.001"]

View File

@@ -0,0 +1,89 @@
"""
Pruebas del programador de mantenimiento diario: gating "máximo 1/día", catch-up tras
reinicios y respeto de run_at_hour. Se mockea ConfigRepository con un dict en memoria.
"""
from datetime import datetime
import pytest
from app.engine import maintenance_scheduler as msched
from app.engine.maintenance_scheduler import DailyMaintenanceScheduler
@pytest.fixture
def state_store(monkeypatch):
store: dict = {}
monkeypatch.setattr(
msched.ConfigRepository, "get", staticmethod(lambda key, default=None: store.get(key, default))
)
monkeypatch.setattr(
msched.ConfigRepository, "set", staticmethod(lambda key, value: store.__setitem__(key, value))
)
monkeypatch.setattr(msched.EventRepository, "create", staticmethod(lambda *a, **k: None))
return store
def _scheduler(runs, clock, run_at_hour=3):
return DailyMaintenanceScheduler(
task=lambda: runs.append(1),
run_at_hour=run_at_hour,
clock=clock,
)
def test_corre_una_vez_por_dia(state_store):
runs: list[int] = []
now = {"dt": datetime(2026, 7, 24, 5, 0, 0)}
sched = _scheduler(runs, lambda: now["dt"])
sched._run_if_due() # primer día: corre
sched._run_if_due() # mismo día: NO corre
assert len(runs) == 1
def test_catch_up_al_cambiar_de_dia(state_store):
runs: list[int] = []
now = {"dt": datetime(2026, 7, 24, 5, 0, 0)}
sched = _scheduler(runs, lambda: now["dt"])
sched._run_if_due()
assert len(runs) == 1
now["dt"] = datetime(2026, 7, 25, 5, 0, 0) # día nuevo
sched._run_if_due()
assert len(runs) == 2
def test_respeta_run_at_hour(state_store):
runs: list[int] = []
now = {"dt": datetime(2026, 7, 24, 1, 0, 0)} # antes de las 3
sched = _scheduler(runs, lambda: now["dt"], run_at_hour=3)
sched._run_if_due() # aún no es la hora
assert len(runs) == 0
now["dt"] = datetime(2026, 7, 24, 3, 30, 0) # ya pasó la hora
sched._run_if_due()
assert len(runs) == 1
def test_run_at_hour_none_corre_al_primer_wake(state_store):
runs: list[int] = []
now = {"dt": datetime(2026, 7, 24, 0, 5, 0)}
sched = _scheduler(runs, lambda: now["dt"], run_at_hour=None)
sched._run_if_due()
assert len(runs) == 1
def test_claim_al_inicio_persiste_fecha_aunque_falle(state_store):
"""Si la tarea falla, el turno del día igual se consume (claim al inicio)."""
now = {"dt": datetime(2026, 7, 24, 5, 0, 0)}
def boom():
raise RuntimeError("fallo de limpieza")
sched = DailyMaintenanceScheduler(task=boom, run_at_hour=3, clock=lambda: now["dt"])
sched._run_if_due() # no debe propagar la excepción
assert state_store["retention_last_run"]["last_run_date"] == "2026-07-24"
assert state_store["retention_last_run"]["last_status"].startswith("error")

View File

@@ -0,0 +1,116 @@
"""
Recolección de partes de un ZIP multipart, sin importar la caja de la extensión.
En este dominio los respaldos llegan con extensión en MAYÚSCULAS con frecuencia (los propios
tests del panel usan `GENERICA-TEST.ZIP`). El bug que esto fija: `_collect_zip_paths` hacía
`path.stem.split(".zip")[0]`, que con `EMPRESA.ZIP.001` dejaba `base_name="EMPRESA.ZIP"` y
armaba el glob `EMPRESA.ZIP.zip.*`, que no encuentra nada. Devolvía lista vacía, y como
`_move_zip_to_processed` y `_move_zip_to_failed` iteran sobre ese resultado, **las partes nunca
salían de Entrada**: se acumulaban ahí mezcladas con los pendientes.
"""
from pathlib import Path
import pytest
class _FakeExtractor:
"""Espeja is_multipart de SevenZipExtractor: reconoce .zip.NNN sin importar la caja."""
@staticmethod
def is_multipart(zip_path: str) -> bool:
path = Path(zip_path)
# .zip.001 -> suffix ".001", stem "algo.zip"
return path.stem.lower().endswith(".zip") and len(path.suffix) == 4
def _collect(source_path: str) -> list[str]:
"""
Copia de la lógica de RestoreWorker._collect_zip_paths, aislada para poder probarla sin
arrastrar PySide6 ni pyodbc. Si la implementación cambia, este test debe cambiar con ella.
"""
path = Path(source_path)
if not _FakeExtractor.is_multipart(str(path)):
return [str(path)]
stem = path.stem
base_name = stem[:-4] if stem.lower().endswith(".zip") else stem
prefix = f"{base_name}.zip.".lower()
parts = sorted(
(item for item in path.parent.iterdir() if item.name.lower().startswith(prefix)),
key=lambda item: item.name.lower(),
)
if parts:
return [str(item) for item in parts]
return [str(path)]
@pytest.mark.parametrize("ext", ["zip", "ZIP", "Zip"])
def test_recolecta_todas_las_partes_sin_importar_la_caja(tmp_path: Path, ext: str):
"""Las tres partes se recolectan igual con la extensión en minúsculas, MAYÚSCULAS o mixta."""
names = [f"EMPRESA.{ext}.001", f"EMPRESA.{ext}.002", f"EMPRESA.{ext}.003"]
for name in names:
(tmp_path / name).write_bytes(b"x")
collected = _collect(str(tmp_path / names[0]))
assert [Path(p).name for p in collected] == names, (
f"con extensión .{ext} se recolectaron {len(collected)} de {len(names)} partes"
)
def test_orden_estable_entre_partes(tmp_path: Path):
"""El orden importa: 7-Zip necesita la .001 primero para reensamblar."""
for i in (3, 1, 10, 2):
(tmp_path / f"BASE.ZIP.{i:03d}").write_bytes(b"x")
collected = [Path(p).name for p in _collect(str(tmp_path / "BASE.ZIP.001"))]
assert collected == ["BASE.ZIP.001", "BASE.ZIP.002", "BASE.ZIP.003", "BASE.ZIP.010"]
def test_no_mezcla_partes_de_otro_respaldo(tmp_path: Path):
"""Dos multipart en la misma carpeta no deben contaminarse entre sí."""
for name in ["ALFA.ZIP.001", "ALFA.ZIP.002", "OMEGA.ZIP.001", "OMEGA.zip.002"]:
(tmp_path / name).write_bytes(b"x")
alfa = [Path(p).name for p in _collect(str(tmp_path / "ALFA.ZIP.001"))]
assert alfa == ["ALFA.ZIP.001", "ALFA.ZIP.002"]
# OMEGA tiene sus dos partes con distinta caja: aun así deben salir las dos.
omega = [Path(p).name for p in _collect(str(tmp_path / "OMEGA.ZIP.001"))]
assert sorted(omega) == ["OMEGA.ZIP.001", "OMEGA.zip.002"]
def test_zip_simple_devuelve_solo_ese_archivo(tmp_path: Path):
simple = tmp_path / "UNICO.ZIP"
simple.write_bytes(b"x")
assert _collect(str(simple)) == [str(simple)]
def test_multipart_sin_partes_localizadas_devuelve_el_original(tmp_path: Path):
"""
Si no se localizan las partes, se devuelve el archivo original en lugar de lista vacía:
mover una sola parte es mejor que dejarla atorada en Entrada indefinidamente.
"""
huerfana = tmp_path / "SOLA.ZIP.007"
huerfana.write_bytes(b"x")
# Es multipart por el nombre, y su propia parte sí se encuentra.
assert _collect(str(huerfana)) == [str(huerfana)]
def test_la_implementacion_real_no_usa_glob_en_minusculas():
"""
Tripwire sobre el código real: `glob` distingue mayúsculas en Linux, así que un patrón
en minúsculas nunca encontraría `.ZIP.001`. La implementación debe filtrar iterdir()
comparando en minúsculas.
"""
source = (
Path(__file__).resolve().parent.parent / "app" / "engine" / "restore_worker.py"
).read_text(encoding="utf-8")
start = source.index("def _collect_zip_paths")
body = source[start : start + 2000]
assert 'glob(f"{base_name}.zip.*")' not in body, "glob en minúsculas: no halla .ZIP en Linux"
assert "iterdir()" in body
assert ".lower()" in body

View File

@@ -57,7 +57,9 @@ def test_target_db_vacio_devuelve_none():
def test_target_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None):
# **kwargs porque panel_client también pasa verify=; una firma rígida rompe la prueba
# cada vez que se agrega un kwarg al cliente.
def fake_get(url, headers=None, timeout=None, **kwargs):
captured["url"] = url
return FakeResponse(200, VALID_TARGET)
@@ -116,7 +118,7 @@ def test_target_json_invalido_devuelve_none(monkeypatch):
def test_report_job_result_201_true(monkeypatch):
captured = {}
def fake_post(url, json=None, headers=None, timeout=None):
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
captured["json"] = json
return FakeResponse(201)
@@ -166,7 +168,7 @@ def test_test_connection_url_invalida():
def test_report_instance_config_200_true(monkeypatch):
captured = {}
def fake_post(url, json=None, headers=None, timeout=None):
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
captured["url"] = url
captured["json"] = json
return FakeResponse(200)
@@ -256,7 +258,7 @@ CATALOG_RESPONSE = {
def test_list_restore_target_names_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None):
def fake_get(url, headers=None, timeout=None, **kwargs):
captured["url"] = url
return FakeResponse(200, CATALOG_RESPONSE)
@@ -346,7 +348,7 @@ ROUTE_RESTORE_LOCAL = {
def test_resolve_route_forward_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None):
def fake_get(url, headers=None, timeout=None, **kwargs):
captured["url"] = url
return FakeResponse(200, ROUTE_FORWARD)
@@ -379,3 +381,71 @@ def test_resolve_route_forward_sin_input_folder_invalido(monkeypatch):
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, bad)
)
assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None
# ============================================================================
# platform / arch: el PANEL los usa para elegir qué artefacto le toca a este
# servidor al instalar o actualizar (a24c.cras_releases se llavea por
# version + platform + arch).
# ============================================================================
def _capture_post(monkeypatch) -> dict:
captured = {}
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
captured["url"] = url
captured["json"] = json
captured["headers"] = headers
return FakeResponse(200)
monkeypatch.setattr(panel_client.requests, "post", fake_post)
return captured
def test_report_instance_config_envia_platform_y_arch(monkeypatch):
captured = _capture_post(monkeypatch)
ok = panel_client.report_instance_config(
URL,
TOKEN,
r"D:\Backups\Entrada",
processed_folder=r"D:\Backups\Procesados",
host_name="WIN-01",
app_version="1.1.0",
instance_key="Alfa",
platform_name="windows",
arch="x86_64",
)
assert ok is True
assert captured["json"]["platform"] == "windows"
assert captured["json"]["arch"] == "x86_64"
assert captured["json"]["processed_folder"] == r"D:\Backups\Procesados"
def test_report_instance_config_sin_platform_manda_none(monkeypatch):
# Compatibilidad hacia atrás: un agente viejo no manda estas claves y el PANEL
# debe poder caer al texto libre de restore_targets.os.
captured = _capture_post(monkeypatch)
panel_client.report_instance_config(URL, TOKEN, r"D:\In")
assert captured["json"]["platform"] is None
assert captured["json"]["arch"] is None
@pytest.mark.parametrize("blank", ["", " ", None])
def test_report_instance_config_platform_en_blanco_es_none(monkeypatch, blank):
captured = _capture_post(monkeypatch)
panel_client.report_instance_config(
URL, TOKEN, r"D:\In", platform_name=blank, arch=blank
)
assert captured["json"]["platform"] is None
assert captured["json"]["arch"] is None
def test_constantes_platform_arch_son_del_vocabulario_del_panel():
# El PANEL valida platform contra ('windows','linux'); si esto cambia hay que
# actualizar el CHECK de a24c.cras_releases y la validación del endpoint.
from app.constants import APP_ARCH, APP_PLATFORM
assert APP_PLATFORM in ("windows", "linux")
assert APP_ARCH and APP_ARCH == APP_ARCH.strip()
assert " " not in APP_ARCH

View File

@@ -0,0 +1,385 @@
"""
Pruebas del contrato de release: versión, artefactos y manifiesto.
Lo que se protege aquí es la cadena que hace posible la distribución automatizada:
app/__init__.py es la fuente única de la versión, package-release.sh la usa para nombrar
los artefactos y armar release.json, y el PANEL compara versiones como tuplas de enteros
para saber si hay una más nueva. Un formato de versión distinto rompe esa comparación en
silencio, así que se valida el formato, no solo que exista.
"""
import json
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
# Mismo patrón que valida package-release.sh y que el PANEL puede ordenar.
VERSION_RE = re.compile(r"^\d+(\.\d+){1,3}$")
def read_version_from_source() -> str:
"""Lee __version__ del archivo, sin importar el paquete (igual que el spec)."""
text = (ROOT / "app" / "__init__.py").read_text(encoding="utf-8")
match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
assert match, "no se encontró __version__ en app/__init__.py"
return match.group(1)
def test_version_tiene_formato_comparable():
assert VERSION_RE.match(read_version_from_source())
def test_version_del_paquete_coincide_con_el_archivo():
# El spec de PyInstaller y package-release.sh leen el archivo con regex; el resto de
# la app importa app.__version__. Ambos caminos deben dar lo mismo.
from app import __version__
assert __version__ == read_version_from_source()
def test_ui_no_hardcodea_la_version():
# El diálogo "Acerca de" traía la versión literal y quedó desfasado del paquete.
text = (ROOT / "app" / "ui" / "main_window.py").read_text(encoding="utf-8")
assert "__version__" in text
assert not re.search(r"CloudRestoreAS v\d+\.\d+\.\d+", text)
def test_bundled_versions_es_json_valido():
# El bootstrap calcula el sha256 de este archivo para decidir si re-despliega
# config/7zip y config/odbc; si no es JSON válido el build queda inconsistente.
manifest = ROOT / "packaging" / "bundled-versions.json"
data = json.loads(manifest.read_text(encoding="utf-8"))
assert data.get("seven_zip"), "falta la versión de 7-Zip"
def test_spec_inyecta_metadatos_de_version_en_windows():
spec = (ROOT / "packaging" / "CloudRestoreAS.spec").read_text(encoding="utf-8")
assert "version=_version_info" in spec, "el EXE debe recibir el recurso de versión"
assert "bundled-versions.json" in spec, "bundled-versions.json debe ir embebido"
def test_instalador_de_despliegue_y_script_de_desarrollo_estan_separados():
"""
scripts/dev-setup.ps1 prepara un venv de desarrollo y no tiene nada que hacer en el
zip del ejecutable autocontenido; install.ps1 es el instalador de despliegue. Antes
eran el mismo archivo y se empaquetaba el de desarrollo.
"""
script = (ROOT / "packaging" / "scripts" / "package-release.sh").read_text(encoding="utf-8")
assert '(root / "install.ps1", "CloudRestoreAS/install.ps1")' in script
assert "CloudRestoreAS/dev-setup.ps1" not in script
dev_setup = ROOT / "scripts" / "dev-setup.ps1"
installer = ROOT / "install.ps1"
assert dev_setup.is_file() and installer.is_file()
# El de desarrollo crea venv; el de despliegue registra el arranque automático.
assert "venv" in dev_setup.read_text(encoding="utf-8")
assert "Register-ScheduledTask" in installer.read_text(encoding="utf-8")
def test_instaladores_aceptan_panel_env_file():
"""
El instalador remoto del PANEL siembra las credenciales por archivo, no por argv,
para que el token no quede visible en `ps` ni en el historial del destino.
"""
sh = (ROOT / "install.sh").read_text(encoding="utf-8")
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "--panel-env-file" in sh
assert "PanelEnvFile" in ps1
# Lista blanca de claves en ambos: el archivo llega por la red.
for text in (sh, ps1):
assert "CLOUDRESTORE_PANEL_API_TOKEN" in text
assert "CLOUDRESTORE_PANEL_INSTANCE_KEY" in text
def test_ambos_instaladores_reservan_el_75_para_restauracion_en_curso():
"""
75 (EX_TEMPFAIL) es el contrato con el PANEL: significa "reintenta luego", no "falló la
instalación", y el panel lo traduce a un 409 amable. Con el código genérico, el operador
salía a investigar una avería inexistente mientras el respaldo que se estaba restaurando
quedaba vetado y la base en SINGLE_USER.
Windows no tenía esta guarda: reinstalar se llevaba por delante la restauración en curso.
"""
sh = (ROOT / "install.sh").read_text(encoding="utf-8")
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "exit 75" in sh
assert "EXIT_RESTORE_IN_PROGRESS = 75" in ps1
assert "exit $EXIT_RESTORE_IN_PROGRESS" in ps1
# La señal de "hay un job en vuelo" es la misma en ambos: Temp/ no vacío.
for text in (sh, ps1):
assert "Temp" in text
def test_install_ps1_acepta_update_in_place():
"""El PANEL actualiza con -UpdateInPlace; sin el parámetro, la actualización aborta."""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "[switch]$UpdateInPlace" in ps1
# Y debe saltarse el bootstrap: una segunda instancia purga Temp/ de la que está viva.
assert "$Mode -ne 'update-in-place'" in ps1
def test_install_ps1_respalda_y_revierte():
"""
Reemplazar el binario de un servidor en producción sin red de seguridad significa que un
binario que no arranca deja el servidor sin restaurador y sin forma de recuperarlo salvo
entrando a mano. install.sh ya respaldaba y revertía; install.ps1 no.
"""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert ".$BinName.prev" in ps1
assert "Wait-AgentAlive" in ps1
# El respaldo solo se descarta tras confirmar que la versión nueva corre.
assert "Remove-Item -LiteralPath $backup" in ps1
def test_install_ps1_alinea_la_tarea_con_el_binario_instalado():
"""
El fallo silencioso: reemplazar el binario es una operación por RUTA, pero
`Start-ScheduledTask` es por NOMBRE y ejecuta la ruta que la tarea lleva registrada. Con una
instalación fuera de la carpeta por omisión, eso copiaba el binario nuevo en un sitio y
arrancaba el viejo desde otro: el run terminaba en verde y el servidor seguía igual.
Se comprueba con `scripts/emular-actualizacion-windows.ps1`, que reproduce el escenario
completo; esto solo protege de que las piezas desaparezcan en un refactor.
"""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "Sync-AgentTaskPath" in ps1
assert "Get-AgentTaskExecute" in ps1
# La comparación tiene que normalizar: la tarea guarda la ruta entrecomillada y NTFS no
# distingue mayúsculas, así que comparar las cadenas en crudo da falsos negativos.
assert "Test-SamePath" in ps1
# Y la confirmación de arranque tiene que mirar la RUTA del proceso, no solo su nombre.
assert "FromPrefix" in ps1
def test_merge_env_file_no_se_desenrolla_con_una_sola_linea():
"""
`$lines = if (...) { @(Get-Content ...) } else { @() }` desenrolla un array de UN elemento a
escalar al asignarlo, así que con un config\\.env de una sola línea `$lines.Count` reventaba
bajo Set-StrictMode y la siembra de credenciales abortaba la instalación. El `@()` tiene que
envolver el `if` COMPLETO.
"""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "$lines = @(\n" in ps1, "el @() debe envolver el if completo, no solo el Get-Content"
assert "$lines = if (" not in ps1
def test_el_arranque_automatico_de_windows_pide_headless():
"""
La tarea ONSTART corre como SYSTEM, en la sesión 0, donde no hay escritorio interactivo. Es
`--headless` lo que hace que el binario elija el plugin Qt 'offscreen'; sin esa bandera Qt
intenta el plugin 'windows' y el agente no levanta.
Se prueba el contrato COMPLETO —quien lanza y quien recibe— porque el defecto original fue
justamente que las dos mitades no coincidían: install.ps1 documentaba en un comentario que
fijaba QT_QPA_PLATFORM y no lo hacía, y runner.py ignoraba --headless en Windows.
"""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
runner_py = (ROOT / "runner.py").read_text(encoding="utf-8")
assert "-Argument '--start-engine --headless'" in ps1
# El binario tiene que honrar la bandera ANTES de mirar la plataforma; si el early-return de
# win32/darwin vuelve a quedar primero, --headless deja de hacer nada en Windows.
headless_at = runner_py.index('if headless:\n os.environ["QT_QPA_PLATFORM"]')
win32_at = runner_py.index('if sys.platform in ("win32", "darwin"):')
assert headless_at < win32_at
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash y sha256sum")
def test_package_release_genera_manifiesto_consistente(tmp_path: Path):
"""
Corre package-release.sh contra un árbol mínimo con binarios simulados y verifica
que release.json y SHA256SUMS concuerden entre sí y con los archivos en disco.
"""
for rel in (
"app/__init__.py",
"packaging/scripts/package-release.sh",
"packaging/LEEME.txt",
"packaging/bundled-versions.json",
"packaging/linux/cloudrestoreas.service",
"install.sh",
"install.ps1",
):
dest = tmp_path / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes((ROOT / rel).read_bytes())
dist = tmp_path / "dist"
dist.mkdir()
(dist / "CloudRestoreAS").write_text("ELF simulado\n", encoding="utf-8")
(dist / "CloudRestoreAS.exe").write_text("PE simulado\n", encoding="utf-8")
result = subprocess.run(
["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")],
capture_output=True,
text=True,
# Los binarios simulados son de unos bytes; se desactiva el piso de tamaño, que se
# prueba aparte en test_package_release_rechaza_binario_truncado.
env={**os.environ, "CLOUDRESTORE_MIN_BINARY_MB": "0"},
)
assert result.returncode == 0, f"package-release.sh falló:\n{result.stderr}"
release_dir = dist / "release"
manifest = json.loads((release_dir / "release.json").read_text(encoding="utf-8"))
version = read_version_from_source()
assert manifest["version"] == version
assert manifest["product"] == "CloudRestoreAS"
assert manifest["bundled"]["seven_zip"], "el manifiesto debe registrar las deps embebidas"
platforms = {a["platform"] for a in manifest["artifacts"]}
assert platforms == {"linux", "windows"}
# Cada artefacto: existe, el nombre lleva versión y plataforma, y el sha256/tamaño
# del manifiesto coinciden con el archivo real.
import hashlib
sums = dict(
reversed(line.split(maxsplit=1))
for line in (release_dir / "SHA256SUMS").read_text(encoding="utf-8").splitlines()
if line.strip()
)
for artifact in manifest["artifacts"]:
path = release_dir / artifact["file_name"]
assert path.is_file(), f"falta el artefacto {artifact['file_name']}"
assert version in artifact["file_name"]
assert artifact["arch"] in artifact["file_name"]
assert path.stat().st_size == artifact["size"]
assert hashlib.sha256(path.read_bytes()).hexdigest() == artifact["sha256"]
# SHA256SUMS y release.json no deben poder divergir.
assert sums[path.name.strip()].strip() == artifact["sha256"]
# Contenido de los paquetes: el instalador de despliegue va dentro, el script de
# desarrollo NO (antes se empaquetaba dev-setup.ps1 junto al .exe autocontenido).
import tarfile
import zipfile
win_pkg = next(a for a in manifest["artifacts"] if a["platform"] == "windows")
with zipfile.ZipFile(release_dir / win_pkg["file_name"]) as zf:
names = set(zf.namelist())
assert "CloudRestoreAS/CloudRestoreAS.exe" in names
assert "CloudRestoreAS/install.ps1" in names
assert not any("dev-setup" in n for n in names)
linux_pkg = next(a for a in manifest["artifacts"] if a["platform"] == "linux")
with tarfile.open(release_dir / linux_pkg["file_name"]) as tf:
members = set(tf.getnames())
# El instalador remoto extrae y corre CloudRestoreAS/install.sh, que a su vez busca
# el binario y la unit systemd relativos a su ubicación.
assert "CloudRestoreAS/CloudRestoreAS-linux" in members
assert "CloudRestoreAS/install.sh" in members
assert "CloudRestoreAS/packaging/linux/cloudrestoreas.service" in members
def _stage_package_tree(tmp_path: Path) -> Path:
"""Árbol mínimo para correr package-release.sh. Devuelve dist/."""
for rel in (
"app/__init__.py",
"packaging/scripts/package-release.sh",
"packaging/LEEME.txt",
"packaging/bundled-versions.json",
"packaging/linux/cloudrestoreas.service",
"install.sh",
"install.ps1",
):
dest = tmp_path / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes((ROOT / rel).read_bytes())
dist = tmp_path / "dist"
(dist / "release").mkdir(parents=True, exist_ok=True)
return dist
def _run_package(tmp_path: Path, min_mb: str = "1") -> subprocess.CompletedProcess:
return subprocess.run(
["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")],
capture_output=True,
text=True,
env={**os.environ, "CLOUDRESTORE_MIN_BINARY_MB": min_mb},
)
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
def test_package_release_ignora_binario_rancio_en_release(tmp_path: Path):
"""
Un binario que quedó en dist/release/ de una corrida anterior NO debe empaquetarse.
Así se generó una vez un zip etiquetado 1.1.0 con un .exe parcial de 4.9 MB: el build de
Windows había fallado, pero el empaquetado tomaba la copia vieja de dist/release/ en lugar
del binario recién compilado en dist/. El sha256 y el release.json quedaban consistentes
con los bytes equivocados, así que la verificación de integridad no lo detectaba.
"""
dist = _stage_package_tree(tmp_path)
(dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024)) # Linux sí compiló
# Sobrante de una corrida previa; dist/CloudRestoreAS.exe NO existe.
(dist / "release" / "CloudRestoreAS.exe").write_bytes(b"parcial" * 1000)
result = _run_package(tmp_path)
assert result.returncode == 0, result.stderr
manifest = json.loads((dist / "release" / "release.json").read_text(encoding="utf-8"))
assert [a["platform"] for a in manifest["artifacts"]] == ["linux"]
assert not list((dist / "release").glob("*win*.zip")), "no debió empaquetar Windows"
# La copia rancia se elimina para que no reaparezca en la siguiente corrida.
assert not (dist / "release" / "CloudRestoreAS.exe").exists()
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
def test_package_release_rechaza_binario_truncado(tmp_path: Path):
"""Un build a medias debe abortar el empaquetado completo, no publicarse."""
dist = _stage_package_tree(tmp_path)
(dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024))
(dist / "CloudRestoreAS.exe").write_bytes(b"x" * 1024) # muy por debajo del piso
result = _run_package(tmp_path, min_mb="1")
assert result.returncode != 0
salida = result.stdout + result.stderr
assert "truncado" in salida
assert "abortado" in salida
# Nada debe quedar publicable si alguna plataforma es inválida.
assert not (dist / "release" / "release.json").exists()
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
def test_package_release_rechaza_binario_anterior_al_bump(tmp_path: Path):
"""
Un binario más viejo que app/__init__.py pertenece a otra versión. Empaquetarlo con el
nombre de la versión actual publicaría una mentira que el sha256 no puede delatar.
"""
dist = _stage_package_tree(tmp_path)
(dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024))
exe = dist / "CloudRestoreAS.exe"
exe.write_bytes(b"x" * (2 * 1024 * 1024))
os.utime(exe, (0, 0)) # 1970: anterior a cualquier cambio de versión
result = _run_package(tmp_path)
assert result.returncode != 0
salida = result.stdout + result.stderr
assert "MÁS VIEJO" in salida
assert not (dist / "release" / "release.json").exists()
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
def test_package_release_rechaza_version_invalida(tmp_path: Path):
"""Una versión no comparable debe abortar el empaquetado, no publicarse."""
for rel in ("packaging/scripts/package-release.sh", "packaging/LEEME.txt"):
dest = tmp_path / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes((ROOT / rel).read_bytes())
(tmp_path / "app").mkdir()
(tmp_path / "app" / "__init__.py").write_text('__version__ = "1.0.0-rc1"\n', encoding="utf-8")
(tmp_path / "dist").mkdir()
result = subprocess.run(
["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")],
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "inválida" in (result.stdout + result.stderr)

157
tests/test_retention.py Normal file
View File

@@ -0,0 +1,157 @@
"""
Pruebas de la retención por nodo (Procesados/) y por antigüedad (Fallados/).
Se mockea el acceso a la BD (JobRepository/EventRepository) y se opera sobre carpetas reales
en tmp_path. Las fechas-carpeta de Procesados/ se derivan con el mismo helper que usa el
limpiador para que la prueba sea independiente de la zona horaria del runner.
"""
from datetime import datetime
import pytest
from app.engine.retention import RetentionCleaner
class FakeJob:
def __init__(self, job_id, node_name, source_name, finished_at):
self.job_id = job_id
self.node_name = node_name
self.source_name = source_name
self.finished_at = finished_at
def _config(tmp_path, *, days=2, failed_days=7, dry_run=False):
return {
"paths": {
"processed_folder": str(tmp_path / "processed"),
"failed_folder": str(tmp_path / "failed"),
},
"retention": {"days": days, "failed_days": failed_days, "dry_run": dry_run},
}
def _date_folder(base, iso):
"""Crea (si falta) la carpeta-fecha local correspondiente a un finished_at ISO UTC."""
local_date = RetentionCleaner._local_date_from_iso(iso)
folder = base / local_date.isoformat()
folder.mkdir(parents=True, exist_ok=True)
return folder
def test_procesados_borra_obsoletos_conserva_reciente_y_nodo_unico(monkeypatch, tmp_path):
processed = tmp_path / "processed"
ref_iso = "2026-07-24T10:00:00"
obsolete_iso = "2026-07-20T10:00:00"
node_b_iso = "2026-07-22T10:00:00"
# NODO_A: copia obsoleta (a borrar) + copia más reciente (a conservar).
obsolete_file = _date_folder(processed, obsolete_iso) / "NODO_A.zip"
obsolete_file.write_bytes(b"viejo")
recent_file = _date_folder(processed, ref_iso) / "NODO_A.zip"
recent_file.write_bytes(b"nuevo")
# NODO_B: una sola restauración (nunca se toca).
node_b_file = _date_folder(processed, node_b_iso) / "NODO_B.zip"
node_b_file.write_bytes(b"unico")
obsolete_job = FakeJob("job-a-old", "NODO_A", "NODO_A.zip", obsolete_iso)
purged: list[str] = []
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_obsolete_completed_by_node",
lambda days: [obsolete_job],
)
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_latest_completed_per_node",
lambda: {"NODO_A": ref_iso, "NODO_B": node_b_iso},
)
monkeypatch.setattr(
"app.engine.retention.JobRepository.mark_purged", lambda job_id: purged.append(job_id)
)
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
result = RetentionCleaner(_config(tmp_path)).run()
assert not obsolete_file.exists() # obsoleto borrado
assert recent_file.exists() # más reciente intacto
assert node_b_file.exists() # nodo de una sola copia intacto
assert purged == ["job-a-old"]
assert result.deleted_files == 1
def test_procesados_dry_run_no_borra(monkeypatch, tmp_path):
processed = tmp_path / "processed"
obsolete_iso = "2026-07-20T10:00:00"
obsolete_file = _date_folder(processed, obsolete_iso) / "NODO_A.zip"
obsolete_file.write_bytes(b"viejo")
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_obsolete_completed_by_node",
lambda days: [FakeJob("job-a-old", "NODO_A", "NODO_A.zip", obsolete_iso)],
)
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_latest_completed_per_node",
lambda: {"NODO_A": "2026-07-24T10:00:00"},
)
purged: list[str] = []
monkeypatch.setattr(
"app.engine.retention.JobRepository.mark_purged", lambda job_id: purged.append(job_id)
)
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
result = RetentionCleaner(_config(tmp_path, dry_run=True)).run()
assert obsolete_file.exists() # dry-run no borra
assert purged == [] # ni marca purgado
assert result.dry_run is True
assert result.deleted_files == 1 # sí lo contabiliza como "se borraría"
def test_fallados_borra_por_antiguedad_absoluta(monkeypatch, tmp_path):
failed = tmp_path / "failed"
old_folder = failed / "2026-07-10" # < (hoy - 7)
recent_folder = failed / "2026-07-20" # >= (hoy - 7)
old_folder.mkdir(parents=True)
recent_folder.mkdir(parents=True)
old_file = old_folder / "VIEJO.zip"
old_file.write_bytes(b"x")
recent_file = recent_folder / "RECIENTE.zip"
recent_file.write_bytes(b"y")
# Sin obsoletos en Procesados; solo probamos Fallados.
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_obsolete_completed_by_node", lambda days: []
)
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_latest_completed_per_node", lambda: {}
)
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
cleaner = RetentionCleaner(
_config(tmp_path, failed_days=7), clock=lambda: datetime(2026, 7, 24, 12, 0, 0)
)
cleaner.run()
assert not old_file.exists() # carpeta-fecha vieja borrada
assert recent_file.exists() # dentro de la ventana, se conserva
def test_fallados_ignora_carpetas_no_fecha(monkeypatch, tmp_path):
failed = tmp_path / "failed"
weird = failed / "no-es-fecha"
weird.mkdir(parents=True)
keep = weird / "algo.zip"
keep.write_bytes(b"z")
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_obsolete_completed_by_node", lambda days: []
)
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_latest_completed_per_node", lambda: {}
)
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
RetentionCleaner(
_config(tmp_path), clock=lambda: datetime(2026, 7, 24, 12, 0, 0)
).run()
assert keep.exists() # nombre que no es fecha: no se toca

View File

@@ -0,0 +1,69 @@
"""
Selección del plugin de plataforma Qt.
Esto decide si el agente arranca o no en un servidor. El caso que motivó las pruebas: en
Windows, `--headless` no hacía nada —la función salía de inmediato en `win32`—, así que la
tarea programada ONSTART, que corre como SYSTEM en la sesión 0 y sin escritorio interactivo,
arrancaba con el plugin 'windows' e intentaba crear una ventana real. En Linux el mismo modo
funcionaba porque el unit de systemd fija QT_QPA_PLATFORM=offscreen por fuera, y esa asimetría
escondió el defecto: la bandera parecía cubierta en las dos plataformas.
"""
import runner
def test_headless_fuerza_offscreen_en_windows(monkeypatch):
# La regresión: la tarea ONSTART pasa --headless y necesita que sirva de algo.
monkeypatch.setattr(runner.sys, "platform", "win32")
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
assert runner._ensure_qt_platform(headless=True) == "offscreen"
assert runner.os.environ["QT_QPA_PLATFORM"] == "offscreen"
def test_headless_fuerza_offscreen_en_linux(monkeypatch):
monkeypatch.setattr(runner.sys, "platform", "linux")
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
monkeypatch.setenv("DISPLAY", ":0")
# Con --headless da igual que haya display: lo pidió el llamador.
assert runner._ensure_qt_platform(headless=True) == "offscreen"
def test_windows_sin_headless_conserva_el_plugin_nativo(monkeypatch):
# Quien abre la app con doble clic quiere su ventana. En Windows no existen DISPLAY ni
# WAYLAND_DISPLAY, así que tomar su ausencia como señal mandaría a offscreen a todos.
monkeypatch.setattr(runner.sys, "platform", "win32")
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
assert runner._ensure_qt_platform(headless=False) == ""
assert "QT_QPA_PLATFORM" not in runner.os.environ
def test_linux_sin_display_cae_a_offscreen(monkeypatch):
monkeypatch.setattr(runner.sys, "platform", "linux")
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
assert runner._ensure_qt_platform(headless=False) == "offscreen"
def test_una_eleccion_explicita_manda_sobre_headless(monkeypatch):
# El instalador fija QT_QPA_PLATFORM para el bootstrap; un override del operador tiene que
# sobrevivir, o depurar un arranque en un servidor ajeno se vuelve imposible.
monkeypatch.setattr(runner.sys, "platform", "win32")
monkeypatch.setenv("QT_QPA_PLATFORM", "minimal")
assert runner._ensure_qt_platform(headless=True) == "minimal"
assert runner.os.environ["QT_QPA_PLATFORM"] == "minimal"
def test_la_bandera_headless_existe_en_el_parser():
# La tarea programada y el unit de systemd la pasan; si desaparece, argparse aborta el
# arranque con código 2 y el agente no levanta en ningún servidor.
args = runner.parse_args(["--start-engine", "--headless"])
assert args.headless is True
assert args.start_engine is True

View File

@@ -2,6 +2,8 @@
Pruebas de la transferencia SFTP al servidor remoto. Se mockea paramiko para no
requerir un servidor SSH real; se valida la conversión de rutas y el flujo de subida.
"""
import os
import pytest
from app.transfer import sftp_copy
@@ -32,12 +34,26 @@ def test_upload_origen_inexistente(tmp_path):
sftp_copy.upload_to_remote(str(tmp_path / "noexiste.bak"), CFG)
class FakeStat:
def __init__(self, st_size):
self.st_size = st_size
class FakeSFTP:
def __init__(self, store):
self.store = store
def put(self, local, remote):
def put(self, local, remote, confirm=True):
self.store["put"] = (local, remote)
self.store["confirm"] = confirm
# Registra el tamaño para que stat() (verificación de subida) lo confirme.
self.store.setdefault("sizes", {})[remote] = os.path.getsize(local)
def stat(self, remote):
sizes = self.store.get("sizes", {})
if remote not in sizes:
raise FileNotFoundError(remote)
return FakeStat(sizes[remote])
def remove(self, remote):
self.store["removed"] = remote
@@ -133,3 +149,65 @@ def test_upload_file_to_folder_vacio_falla(tmp_path):
f.write_bytes(b"x")
with pytest.raises(SFTPCopyError, match="carpeta remota"):
sftp_copy.upload_file_to_folder(str(f), CFG, " ")
def test_upload_usa_confirm_false(tmp_path, monkeypatch):
"""La subida no debe delegar la verificación al confirm inmediato de paramiko."""
zf = tmp_path / "backup.zip"
zf.write_bytes(b"zipdata")
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
sftp_copy.upload_file_to_folder(str(zf), CFG, "D:\\In")
assert store["confirm"] is False
def test_verify_remote_size_reintenta_stat_flaky(monkeypatch):
"""Un stat transitoriamente fallido se reintenta y NO produce falso fallo."""
monkeypatch.setattr(sftp_copy, "VERIFY_DELAY_SECONDS", 0)
calls = {"n": 0}
class Flaky:
def stat(self, remote):
calls["n"] += 1
if calls["n"] < 2:
raise OSError("stat flaky")
return FakeStat(100)
sftp_copy._verify_remote_size(Flaky(), "C:/In/x.zip", 100) # no debe lanzar
assert calls["n"] == 2
def test_verify_remote_size_tamano_incorrecto_falla(monkeypatch):
"""Si el tamaño remoto nunca coincide, es un fallo genuino de entrega."""
monkeypatch.setattr(sftp_copy, "VERIFY_DELAY_SECONDS", 0)
class Wrong:
def stat(self, remote):
return FakeStat(50)
with pytest.raises(SFTPCopyError, match="verificar"):
sftp_copy._verify_remote_size(Wrong(), "C:/In/x.zip", 100)
def test_upload_zip_parts_adjunta_uploaded_en_fallo(tmp_path, monkeypatch):
"""Ante un fallo parcial, la excepción lleva las partes ya subidas para limpieza."""
p1 = tmp_path / "big.zip.001"
p2 = tmp_path / "big.zip.002"
p1.write_bytes(b"a")
p2.write_bytes(b"b")
calls = {"n": 0}
def fake_upload(local, cfg, remote_folder):
calls["n"] += 1
if calls["n"] == 1:
return "D:/In/big.zip.001"
raise SFTPCopyError("boom en la parte 2")
monkeypatch.setattr(sftp_copy, "upload_file_to_folder", fake_upload)
with pytest.raises(SFTPCopyError) as exc_info:
sftp_copy.upload_zip_parts([str(p1), str(p2)], CFG, "D:\\In")
assert getattr(exc_info.value, "uploaded", None) == ["D:/In/big.zip.001"]