7 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
13 changed files with 1226 additions and 77 deletions

View File

@@ -194,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`.
@@ -205,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

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.2"
__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,7 +525,8 @@ 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
@@ -205,32 +539,96 @@ switch ($Mode) {
$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

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

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