Compare commits
14 Commits
cafe3f1b87
...
fix/instal
| Author | SHA1 | Date | |
|---|---|---|---|
| 9214a1feab | |||
| 896528734b | |||
| 9255c6277e | |||
| 399b0483d5 | |||
| 0ac899f531 | |||
| 0a62b7d0aa | |||
| c2afa52d6f | |||
| c8133a5108 | |||
| 874fe0c46f | |||
| f02cd1f4c3 | |||
| 17c0dea4bf | |||
| fd077dad42 | |||
| c3f1d70e23 | |||
| ce38956e3e |
222
BUILD.md
222
BUILD.md
@@ -13,11 +13,12 @@ Qt instaladas en el equipo destino. Todo se embebe dentro del binario en tiempo
|
||||
Orquestador único. **Se ejecuta desde WSL** (bash). Genera Windows + Linux + los paquetes.
|
||||
|
||||
```bash
|
||||
./build-all.sh # Windows + Linux + dist/release/*.tar.gz y *.zip
|
||||
./build-all.sh # 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
|
||||
```
|
||||
|
||||
@@ -72,29 +73,114 @@ Los binarios quedan en `packaging/bundled/{linux,windows}/` (git-ignored, se gen
|
||||
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-linux.tar.gz` | `CloudRestoreAS/` → binario + `install.sh` + `packaging/linux/cloudrestoreas.service` + `LEEME.txt` |
|
||||
| `CloudRestoreAS-win.zip` | `CloudRestoreAS/` → `CloudRestoreAS.exe` + `install.ps1` + `LEEME.txt` |
|
||||
| `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. Instalación / despliegue
|
||||
## 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-linux.tar.gz && cd CloudRestoreAS
|
||||
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 --desktop # autostart .desktop (requiere sesión gráfica)
|
||||
./install.sh # solo instala + bootstrap; lo corres a mano
|
||||
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
|
||||
@@ -102,14 +188,105 @@ journalctl -u cloudrestoreas -f
|
||||
sudo systemctl restart cloudrestoreas # tras editar config/.env
|
||||
```
|
||||
|
||||
### Windows
|
||||
### Windows — `install.ps1` (autocontenido, sin NSSM ni descargas)
|
||||
|
||||
Copiar `CloudRestoreAS.exe` a una carpeta y ejecutarlo (o usar `install.ps1`). Al iniciar
|
||||
crea `config/` y un icono en la bandeja. Ver [packaging/LEEME.txt](packaging/LEEME.txt).
|
||||
```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.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ejecución manual y flags del binario
|
||||
## 7. Ejecución manual y flags del binario
|
||||
|
||||
```bash
|
||||
# Linux servidor sin pantalla (headless): motor de restauración sin GUI
|
||||
@@ -124,13 +301,14 @@ QT_QPA_PLATFORM=offscreen ./CloudRestoreAS --start-engine --headless
|
||||
| `--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`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuración (primera ejecución)
|
||||
## 8. Configuración (primera ejecución)
|
||||
|
||||
El binario crea automáticamente: `config/`, `config/.env`, `Entrada/`, `Procesados/`,
|
||||
`Fallados/`, `Temp/`. Editar `config/.env`:
|
||||
@@ -147,7 +325,7 @@ automáticamente según el formato del `data_folder`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verificación de autocontención (contenedor pelado)
|
||||
## 9. Verificación de autocontención (contenedor pelado)
|
||||
|
||||
Confirma que el binario Linux corre sin instalar NADA del sistema:
|
||||
|
||||
@@ -166,19 +344,23 @@ Esperado: arranca en `offscreen` sin errores Qt, `0` deps ODBC faltantes, `7zz`
|
||||
|
||||
---
|
||||
|
||||
## 9. Artefactos y ubicaciones
|
||||
## 10. Artefactos y ubicaciones
|
||||
|
||||
| Ruta | Qué es |
|
||||
|---|---|
|
||||
| `dist/CloudRestoreAS` | Binario Linux onefile |
|
||||
| `dist/CloudRestoreAS.exe` | Ejecutable Windows onefile |
|
||||
| `dist/release/*.tar.gz` / `*.zip` | Paquetes de despliegue (binario + instalador + docs) |
|
||||
| `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) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Scripts de referencia rápida
|
||||
## 11. Scripts de referencia rápida
|
||||
|
||||
| Script | Propósito |
|
||||
|---|---|
|
||||
@@ -186,6 +368,8 @@ Esperado: arranca en `offscreen` sin errores Qt, `0` deps ODBC faltantes, `7zz`
|
||||
| `build.sh` / `build.ps1` | Build individual Linux / Windows |
|
||||
| `packaging/scripts/docker-build-linux.sh` | Build Linux en contenedor controlado |
|
||||
| `packaging/scripts/download-bundled-deps.sh` / `.ps1` | Descarga+embebe deps |
|
||||
| `packaging/scripts/package-release.sh` | Genera `.tar.gz` / `.zip` |
|
||||
| `install.sh` / `install.ps1` | Instalador Linux / Windows |
|
||||
| `packaging/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) |
|
||||
|
||||
144
CHANGELOG.md
144
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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**).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -472,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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
@@ -20,19 +24,100 @@ from ..constants import (
|
||||
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:
|
||||
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
|
||||
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
|
||||
# dest puede existir pero VACÍO: ensure_runtime_layout crea ODBC_DIR/SEVEN_ZIP_DIR
|
||||
# antes de llamar aquí. Solo saltar si ya tiene contenido (evita re-copiar en cada
|
||||
# arranque). Antes se saltaba por dest.exists(), dejando 7-Zip/ODBC sin desplegar.
|
||||
if dest.exists() and any(dest.iterdir()):
|
||||
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)
|
||||
|
||||
@@ -90,11 +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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -136,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
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
125
app/engine/maintenance_scheduler.py
Normal file
125
app/engine/maintenance_scheduler.py
Normal 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,
|
||||
},
|
||||
)
|
||||
@@ -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
253
app/engine/retention.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -128,6 +130,10 @@ 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)
|
||||
@@ -282,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()
|
||||
@@ -308,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"
|
||||
|
||||
41
build-all.sh
41
build-all.sh
@@ -4,7 +4,9 @@
|
||||
#
|
||||
# Linux : build en Docker (ubuntu:22.04) -> dist/CloudRestoreAS (autocontenido)
|
||||
# Windows: build vía powershell.exe -> build.ps1 -> dist/CloudRestoreAS.exe
|
||||
# Paquete: dist/release/CloudRestoreAS-linux.tar.gz y CloudRestoreAS-win.zip
|
||||
# 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
|
||||
@@ -12,6 +14,9 @@
|
||||
# ./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
|
||||
|
||||
@@ -22,6 +27,8 @@ DO_LINUX=1
|
||||
DO_WINDOWS=1
|
||||
DO_PACKAGE=1
|
||||
DO_CLEAN=0
|
||||
DO_PUBLISH=0
|
||||
PUBLISH_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
@@ -29,6 +36,9 @@ for arg in "$@"; do
|
||||
--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 ;;
|
||||
@@ -128,4 +138,33 @@ fi
|
||||
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
|
||||
|
||||
714
install.ps1
714
install.ps1
@@ -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
|
||||
|
||||
424
install.sh
424
install.sh
@@ -1,35 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Instalador Linux de CloudRestoreAS.
|
||||
#
|
||||
# NO instala ni descarga NADA del sistema: el binario es 100% autocontenido
|
||||
# (Qt/xcb, driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo
|
||||
# coloca el binario, marca permisos, hace el bootstrap de config/, y opcionalmente
|
||||
# registra el arranque automático (servicio systemd headless o autostart de escritorio).
|
||||
# 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"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--service) MODE="service" ;;
|
||||
--desktop) MODE="desktop" ;;
|
||||
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: $arg" >&2; exit 2 ;;
|
||||
*) 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=""
|
||||
@@ -52,27 +92,234 @@ 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"
|
||||
install -m 0755 "$SRC" "$PREFIX/$BIN_NAME"
|
||||
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)..."
|
||||
( cd "$PREFIX" && QT_QPA_PLATFORM=offscreen timeout 20 "$PREFIX/$BIN_NAME" --headless >/dev/null 2>&1 || true )
|
||||
# `</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)
|
||||
SERVICE_USER="${SERVICE_USER:-${SUDO_USER:-$(id -un)}}"
|
||||
UNIT_SRC="$SCRIPT_DIR/packaging/linux/cloudrestoreas.service"
|
||||
UNIT_DST="/etc/systemd/system/cloudrestoreas.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
|
||||
@@ -82,11 +329,148 @@ case "$MODE" in
|
||||
-e "s|__EXEC__|$PREFIX/$BIN_NAME|g" \
|
||||
"$UNIT_SRC" > "$UNIT_DST"
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cloudrestoreas.service
|
||||
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."
|
||||
@@ -97,7 +481,17 @@ case "$MODE" in
|
||||
;;
|
||||
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."
|
||||
|
||||
@@ -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():
|
||||
@@ -89,6 +108,82 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
_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,
|
||||
@@ -98,6 +193,7 @@ exe = EXE(
|
||||
[],
|
||||
name="CloudRestoreAS",
|
||||
icon=_icon,
|
||||
version=_version_info,
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
|
||||
@@ -18,6 +18,29 @@ 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.
|
||||
|
||||
-------------------------------------
|
||||
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)
|
||||
-------------------------------------
|
||||
|
||||
22
packaging/linux/cloudrestoreas-user.service
Normal file
22
packaging/linux/cloudrestoreas-user.service
Normal 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
|
||||
@@ -1,47 +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"
|
||||
RED='\033[31m'; NC='\033[0m'
|
||||
err() { echo -e "${RED}ERROR:${NC} $*" >&2; }
|
||||
|
||||
# --- 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
|
||||
)"
|
||||
|
||||
# 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
|
||||
|
||||
# --- 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: carpeta raíz CloudRestoreAS/ con .exe + install.ps1 + LEEME
|
||||
# (paridad con el .tar.gz de Linux).
|
||||
if [[ -f "$RELEASE/CloudRestoreAS.exe" ]]; then
|
||||
ROOT="$ROOT" RELEASE="$RELEASE" python3 <<'PY'
|
||||
# --- 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"])
|
||||
items = [(release / "CloudRestoreAS.exe", "CloudRestoreAS/CloudRestoreAS.exe"),
|
||||
(root / "packaging" / "LEEME.txt", "CloudRestoreAS/LEEME.txt")]
|
||||
if (root / "install.ps1").exists():
|
||||
items.append((root / "install.ps1", "CloudRestoreAS/install.ps1"))
|
||||
with zipfile.ZipFile(release / "CloudRestoreAS-win.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
||||
|
||||
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(" zip Windows:", release / "CloudRestoreAS-win.zip")
|
||||
print(f" zip Windows: {out.name}")
|
||||
PY
|
||||
fi
|
||||
|
||||
if [[ -f "$RELEASE/CloudRestoreAS-linux" ]]; then
|
||||
# Paquete de despliegue completo: binario + instalador + unit systemd + LEEME,
|
||||
# bajo una carpeta raíz para que extraiga limpio. install.sh encuentra el binario
|
||||
# --- 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.
|
||||
STAGE="$RELEASE/CloudRestoreAS-linux"
|
||||
PKG="$RELEASE/pkg-linux/CloudRestoreAS"
|
||||
rm -rf "$RELEASE/pkg-linux"
|
||||
mkdir -p "$PKG/packaging/linux"
|
||||
cp -f "$STAGE" "$PKG/CloudRestoreAS-linux"; chmod +x "$PKG/CloudRestoreAS-linux"
|
||||
cp -f "$ROOT/install.sh" "$PKG/install.sh"; chmod +x "$PKG/install.sh"
|
||||
cp -f "$ROOT/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/CloudRestoreAS-linux.tar.gz" -C "$RELEASE/pkg-linux" CloudRestoreAS
|
||||
tar czf "$RELEASE/$LINUX_PKG" -C "$RELEASE/pkg-linux" CloudRestoreAS
|
||||
rm -rf "$RELEASE/pkg-linux"
|
||||
echo " tar.gz Linux: $LINUX_PKG"
|
||||
fi
|
||||
|
||||
echo "Release en: $RELEASE"
|
||||
# --- 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"
|
||||
|
||||
221
packaging/scripts/publish-release.sh
Executable file
221
packaging/scripts/publish-release.sh
Executable 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"
|
||||
101
runner.py
101
runner.py
@@ -28,24 +28,82 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
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 _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 en Linux. En un servidor headless (sin
|
||||
DISPLAY/WAYLAND_DISPLAY) o con --headless, usa 'offscreen' para que la app
|
||||
arranque y el motor trabaje sin X; con display usa el default ('xcb'). No toca
|
||||
nada si el usuario ya fijó QT_QPA_PLATFORM, ni en Windows/macOS.
|
||||
Selecciona el plugin de plataforma Qt.
|
||||
|
||||
Devuelve la plataforma forzada ("offscreen") o "" si se deja el default.
|
||||
`--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 sys.platform in ("win32", "darwin"):
|
||||
return ""
|
||||
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 headless or not has_display:
|
||||
if not has_display:
|
||||
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||
return "offscreen"
|
||||
return ""
|
||||
@@ -135,8 +193,14 @@ def _run() -> int:
|
||||
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 qt_platform == "offscreen":
|
||||
app_logger.info("Modo: sin display → plataforma Qt 'offscreen' (motor headless)")
|
||||
@@ -162,11 +226,16 @@ def _run() -> int:
|
||||
start_engine=start_engine,
|
||||
panel_configured=panel_ok,
|
||||
)
|
||||
# La ventana SIEMPRE se muestra al iniciar; cerrar (X) la manda a la bandeja.
|
||||
window.show()
|
||||
window.activateWindow()
|
||||
window.raise_()
|
||||
app_logger.info("Ventana principal mostrada")
|
||||
# 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}")
|
||||
@@ -175,6 +244,10 @@ def _run() -> int:
|
||||
|
||||
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:
|
||||
sys.exit(_run())
|
||||
except SystemExit:
|
||||
|
||||
128
scripts/dev-setup.ps1
Normal file
128
scripts/dev-setup.ps1
Normal 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..."
|
||||
227
scripts/emular-actualizacion-windows.ps1
Normal file
227
scripts/emular-actualizacion-windows.ps1
Normal 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
|
||||
111
scripts/probar-funciones-install.ps1
Normal file
111
scripts/probar-funciones-install.ps1
Normal 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
|
||||
@@ -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()
|
||||
|
||||
170
tests/test_forward_false_failure.py
Normal file
170
tests/test_forward_false_failure.py
Normal 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"]
|
||||
89
tests/test_maintenance_scheduler.py
Normal file
89
tests/test_maintenance_scheduler.py
Normal 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")
|
||||
116
tests/test_multipart_case.py
Normal file
116
tests/test_multipart_case.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
385
tests/test_release_metadata.py
Normal file
385
tests/test_release_metadata.py
Normal 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
157
tests/test_retention.py
Normal 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
|
||||
69
tests/test_runner_qt_platform.py
Normal file
69
tests/test_runner_qt_platform.py
Normal 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
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user