13 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Con dos cuidados que no son opcionales:

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

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

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

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

Robustez, que es donde estaba el trabajo real:

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

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

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

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

Detalles que costaron una prueba cada uno:

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

Reinstalar es idempotente: no duplica entradas de cron.
2026-07-30 13:24:50 -06:00
fd077dad42 Merge branch 'development' into feature/generador-instaladores-linux-windows 2026-07-30 07:34:58 -06:00
ce38956e3e Merge pull request 'feature/generador-instaladores-linux-windows' (#2) from feature/generador-instaladores-linux-windows into development
Reviewed-on: #2
2026-07-01 16:37:35 +00:00
15 changed files with 1563 additions and 94 deletions

View File

@@ -153,15 +153,33 @@ credenciales. Lo de abajo es el camino manual y lo que el PANEL ejecuta por dent
```bash
tar xzf CloudRestoreAS-<version>-linux-x86_64.tar.gz && cd CloudRestoreAS
sudo ./install.sh --service # servicio systemd 24/7 headless (recomendado en servidor)
./install.sh --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 (un ELF en ejecución da `ETXTBSY`) y lo
vuelve a levantar si estaba activo.
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
@@ -176,9 +194,10 @@ sudo systemctl restart cloudrestoreas # tras editar config/.env
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 # solo instala + bootstrap
.\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`.
@@ -187,6 +206,65 @@ Parámetros: `-Prefix` (default `C:\Aduanasoft\CloudRestoreAS`), `-PanelEnvFile`
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.

View File

@@ -1,5 +1,101 @@
# 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
@@ -31,8 +127,9 @@
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 (un ELF en ejecución da
`ETXTBSY`) y lo vuelve a levantar si estaba activo.
- `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

View File

@@ -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

View File

@@ -4,5 +4,5 @@
# 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.0"
__version__ = "1.1.5"
__author__ = "Aduanasoft"

View File

@@ -175,16 +175,30 @@ def ensure_runtime_layout() -> None:
):
directory.mkdir(parents=True, exist_ok=True)
_write_env_if_missing()
# 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
# Las deps embebidas se re-despliegan cuando el build trae otras versiones. El 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.
# 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()
# 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)
@@ -193,10 +207,6 @@ def ensure_runtime_layout() -> None:
if refresh_deps:
_write_stamp(deps_stamp_path, deps_stamp)
# 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).
_write_stamp(version_stamp_path, __version__)
from ..constants import DB_PATH
if not DB_PATH.exists():

View File

@@ -11,12 +11,23 @@
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.
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.
.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.
@@ -28,12 +39,13 @@
.EXAMPLE
.\install.ps1 -Service
.EXAMPLE
.\install.ps1 -Desktop -Prefix 'D:\CloudRestoreAS'
.\install.ps1 -UpdateInPlace -Prefix 'D:\CloudRestoreAS'
#>
[CmdletBinding()]
param(
[switch]$Service,
[switch]$Desktop,
[switch]$UpdateInPlace,
[string]$Prefix = 'C:\Aduanasoft\CloudRestoreAS',
[string]$PanelEnvFile = ''
)
@@ -42,17 +54,285 @@ $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 }
if ($Service -and $Desktop) {
throw 'Elige -Service o -Desktop, no ambos.'
$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í.')
}
}
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
}
}
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
}
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
}
}
}
$Mode = if ($Service) { 'service' } elseif ($Desktop) { 'desktop' } else { 'none' }
# --- Localizar el binario (mismas rutas candidatas que install.sh) -------------------
$candidates = @(
@@ -65,11 +345,29 @@ if (-not $src) {
}
# -Service crea una tarea como SYSTEM: requiere elevación.
$isAdmin = ([Security.Principal.WindowsPrincipal] `
[Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
#
# 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) {
throw 'Se requiere PowerShell como Administrador para -Service (tarea ONSTART como SYSTEM).'
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
@@ -79,48 +377,76 @@ 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
$dest = Join-Path $Prefix $BinName
# Si hay una instancia corriendo, el .exe queda bloqueado y Copy-Item falla. Se detiene
# la tarea y se esperan los procesos antes de reemplazar (caso actualización).
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
Write-Step 'Deteniendo tarea programada existente'
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | ForEach-Object {
$_ | Stop-Process -Force -ErrorAction SilentlyContinue
}
# Espera acotada a que el SO libere el archivo; sin esto el Copy-Item puede fallar.
for ($i = 0; $i -lt 10; $i++) {
if (-not (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue)) { break }
Start-Sleep -Milliseconds 500
$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-Item -LiteralPath $src -Destination $dest -Force
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.
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
#
# 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
}
} 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 creado.'
Write-Ok 'config\.env presente.'
} else {
Write-Warn 'config\.env se creará en la primera ejecución.'
}
@@ -131,9 +457,13 @@ if (Test-Path -LiteralPath $envPath) {
function Merge-EnvFile {
param([string]$Target, [hashtable]$Values)
$lines = if (Test-Path -LiteralPath $Target) {
@(Get-Content -LiteralPath $Target -Encoding UTF8)
} else { @() }
# 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])"
@@ -178,6 +508,9 @@ if ($PanelEnvFile) {
}
}
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 {
@@ -192,36 +525,110 @@ 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. QT_QPA_PLATFORM=offscreen porque SYSTEM no tiene sesión gráfica.
# 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
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount `
$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 $principal -Settings $settings -Force | Out-Null
-Principal $taskPrincipal -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName
Write-Ok "Tarea '$TaskName' registrada y arrancada."
Write-Host " Estado : Get-ScheduledTask -TaskName $TaskName"
Write-Host " Logs : $Prefix\config\logs"
}
'desktop' {
Write-Host 'Modo escritorio: la app registra su autostart ONLOGON al iniciarse.'
Write-Host "Ejecuta '$dest' en tu sesión."
# 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.'
Write-Host "Ejecuta: `"$dest`" --start-engine --headless"
}
}
Write-Host ''
Write-Host "Siguiente paso: revisa $envPath (CLOUDRESTORE_PANEL_*) y reinicia."
if ($Mode -eq 'service') {
Write-Host " Tras editar: Stop-ScheduledTask -TaskName $TaskName; Start-ScheduledTask -TaskName $TaskName"
# --- 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 ($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-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
}
Write-Host ''
Write-Host "Configuración : $envPath"
Write-Host "Logs : $Prefix\config\logs"
Write-Host 'Listo.'
# 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

View File

@@ -9,9 +9,25 @@
#
# 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.
@@ -29,6 +45,8 @@ 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; }
@@ -44,6 +62,14 @@ 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=""
@@ -67,12 +93,38 @@ echo " Modo : $MODE"
echo "==============================================="
# --- Detener el servicio si está corriendo ------------------------------------
# Un ELF en ejecución no se puede sobrescribir (ETXTBSY), así que una actualización
# tiene que detenerlo antes de reemplazar el binario. Se recuerda si estaba activo
# para volver a levantarlo al final incluso en los modos que no tocan systemd.
# 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
if command -v systemctl >/dev/null 2>&1; then
if systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then
# 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 || \
@@ -80,11 +132,40 @@ if command -v systemctl >/dev/null 2>&1; then
fi
fi
# Una restauración en curso no se interrumpe. El agente no atiende SIGTERM, así que matarlo a
# media restauración deja el job atascado —ese ZIP queda vetado en cada escaneo posterior— y puede
# dejar la base en SINGLE_USER. Cualquier subcarpeta de Temp/ es un job en vuelo.
# Código 75 (EX_TEMPFAIL) y no 1: le dice al panel "reintenta luego", no "falló la instalación".
if [[ "$MODE" == "update-in-place" ]] && [[ -d "$PREFIX/Temp" ]]; then
if [[ -n "$(ls -A "$PREFIX/Temp" 2>/dev/null)" ]]; then
echo "ERROR: hay una restauración en curso ($PREFIX/Temp no está vacío)." >&2
echo " No se actualiza para no dejarla a medias. Reintenta cuando termine." >&2
exit 75
fi
fi
# Respaldo para poder volver atrás. Una actualización en sitio reemplaza el binario de un
# servidor en producción sin red de seguridad: si el nuevo no arranca, sin esto el servidor queda
# sin agente y sin forma de recuperarlo salvo entrando a mano.
BACKUP=""
if [[ "$MODE" == "update-in-place" && -f "$PREFIX/$BIN_NAME" ]]; then
BACKUP="$PREFIX/.$BIN_NAME.prev"
if ! cp -p "$PREFIX/$BIN_NAME" "$BACKUP"; then
echo "ERROR: no se pudo respaldar el binario actual en $BACKUP" >&2
echo " Se aborta: actualizar sin poder revertir no es aceptable." >&2
exit 1
fi
echo "Respaldo del binario actual en $BACKUP"
fi
mkdir -p "$PREFIX"
if ! install -m 0755 "$SRC" "$PREFIX/$BIN_NAME"; then
echo "ERROR: no se pudo instalar el binario en $PREFIX/$BIN_NAME" >&2
echo " Si el archivo está en uso, detén el proceso y reintenta:" >&2
echo " sudo systemctl stop $UNIT_NAME # o mata el proceso de escritorio" >&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"
@@ -93,8 +174,16 @@ echo "Binario instalado en $PREFIX/$BIN_NAME"
# 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
@@ -184,9 +273,51 @@ if [[ -n "$PANEL_ENV_FILE" ]]; then
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/$UNIT_NAME"
UNIT_DST="/etc/systemd/system/$UNIT_NAME"
if [[ ! -w "$(dirname "$UNIT_DST")" ]]; then
@@ -203,6 +334,143 @@ case "$MODE" in
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."
@@ -215,7 +483,8 @@ 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" ]]; then
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
@@ -224,4 +493,5 @@ fi
echo ""
echo "Siguiente paso: edita $PREFIX/config/.env (CLOUDRESTORE_PANEL_*) y reinicia."
[[ "$MODE" == "service" ]] && echo " Tras editar: sudo systemctl restart cloudrestoreas"
[[ "$MODE" == "user-service" ]] && echo " Tras editar: systemctl --user restart cloudrestoreas"
echo "Listo."

View File

@@ -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)
-------------------------------------

View File

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

View File

@@ -77,19 +77,33 @@ def _print_version() -> int:
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 ""
@@ -179,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)")
@@ -206,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}")

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ from pathlib import Path
import pytest
from app.config import bootstrap
from app.config.bootstrap import ensure_runtime_layout
from app.config.env_loader import (
apply_env_overrides,
@@ -112,6 +113,41 @@ def test_bootstrap_escribe_sello_de_version(tmp_path: Path, monkeypatch: pytest.
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
):

View File

@@ -97,6 +97,100 @@ def test_instaladores_aceptan_panel_env_file():
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):
"""

View File

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