Compare commits
7 Commits
feature/ge
...
fix/instal
| Author | SHA1 | Date | |
|---|---|---|---|
| 9214a1feab | |||
| 896528734b | |||
| 9255c6277e | |||
| 399b0483d5 | |||
| 0ac899f531 | |||
| 0a62b7d0aa | |||
| c2afa52d6f |
66
BUILD.md
66
BUILD.md
@@ -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.
|
||||
|
||||
|
||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
496
install.ps1
496
install.ps1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
-------------------------------------
|
||||
|
||||
53
runner.py
53
runner.py
@@ -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}")
|
||||
|
||||
227
scripts/emular-actualizacion-windows.ps1
Normal file
227
scripts/emular-actualizacion-windows.ps1
Normal file
@@ -0,0 +1,227 @@
|
||||
<#
|
||||
Emulación end-to-end en Windows de la actualización que "completa y no hace nada".
|
||||
|
||||
Reproduce el caso del servidor real: el agente vive en la carpeta que el panel tiene declarada,
|
||||
pero la tarea programada ejecuta el binario de OTRA carpeta. Reemplazar el binario de la primera
|
||||
y arrancar la tarea levanta el de la segunda.
|
||||
|
||||
Qué es REAL aquí: el agente es un .exe compilado que se queda vivo, los archivos y carpetas, la
|
||||
copia del binario, la identidad del proceso por ruta, el sello config\.version y la reversión.
|
||||
El "viejo" NO escribe el sello (igual que 1.1.0) y el "nuevo" sí (igual que 1.1.3) — esa
|
||||
diferencia es la que hacía que la verificación del panel tolerara el sello vacío.
|
||||
|
||||
Qué está simulado y por qué: los cmdlets *-ScheduledTask. Registrar una tarea exige elevación, y
|
||||
la gracia de esta emulación es poder correrla sin ella. Los sustitutos respetan la semántica que
|
||||
provoca el fallo: Start-ScheduledTask lanza LA RUTA REGISTRADA EN LA TAREA, no la que se acaba de
|
||||
instalar. Como install.ps1 se invoca con &, sus llamadas resuelven a estas funciones del ámbito
|
||||
padre. Si quieres la prueba con una tarea de verdad, corre esto en PowerShell elevado tras
|
||||
quitar los sustitutos.
|
||||
|
||||
Uso:
|
||||
.\emular-actualizacion-windows.ps1 -Installer ..\install.ps1
|
||||
.\emular-actualizacion-windows.ps1 -Installer ..\install.ps1 -Escenario alineada
|
||||
|
||||
Encontró dos defectos que las pruebas de unidad no veían: el rearranque del binario equivocado,
|
||||
y que Merge-EnvFile reventaba con un config\.env de una sola línea.
|
||||
#>
|
||||
param(
|
||||
# install.ps1 a poner a prueba. Para comparar contra una versión anterior:
|
||||
# git show <commit>:install.ps1 > install-antes.ps1
|
||||
[Parameter(Mandatory = $true)][string]$Installer,
|
||||
[string]$Etiqueta = 'instalador',
|
||||
# 'desalineada' reproduce el servidor que falla; 'alineada' es el caso normal, para comprobar
|
||||
# que el arreglo no lo rompe (una tarea que ya apunta bien no debe tocarse).
|
||||
#
|
||||
# 'sufijo' es el caso PELIGROSO y existe en producción: la instalación está en
|
||||
# ...\CloudRestoreAS-win y la tarea apunta a ...\CloudRestoreAS, que es prefijo de cadena de la
|
||||
# anterior. Cualquier comparación de rutas hecha con `startsWith` las daría por iguales, no
|
||||
# corregiría la tarea, y la actualización volvería a no surtir efecto.
|
||||
[ValidateSet('desalineada', 'alineada', 'sufijo')][string]$Escenario = 'desalineada',
|
||||
[string]$Carpeta = 'C:\Users\Public\cras-e2e'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$base = $Carpeta
|
||||
$pkg = Join-Path $base 'pkg'
|
||||
|
||||
# En el escenario 'sufijo' los nombres NO son arbitrarios: reproducen el par que se da en producción,
|
||||
# donde el nombre de una carpeta es prefijo de cadena del de la otra. Con nombres sin relación entre
|
||||
# sí (declarada / otra-carpeta) un `startsWith` mal puesto pasaría la prueba sin problema.
|
||||
if ($Escenario -eq 'sufijo') {
|
||||
$declarada = Join-Path $base 'CloudRestoreAS-win' # la instalación real
|
||||
$otra = Join-Path $base 'CloudRestoreAS' # a donde apunta la tarea
|
||||
} else {
|
||||
$declarada = Join-Path $base 'declarada' # donde el panel cree que está (y está)
|
||||
$otra = Join-Path $base 'otra-carpeta' # a donde apunta la tarea
|
||||
}
|
||||
|
||||
function Nuevo-AgenteFalso {
|
||||
param([string]$Destino, [string]$Version, [switch]$EscribeSello)
|
||||
|
||||
# Here-strings LITERALES: el C# lleva comillas dobles y en una cadena interpolada de PowerShell
|
||||
# habría que escaparlas con backtick, no con barra invertida.
|
||||
$plantilla = @'
|
||||
using System; using System.IO; using System.Threading;
|
||||
class P { static void Main() {
|
||||
string d = AppDomain.CurrentDomain.BaseDirectory;
|
||||
__CUERPO__
|
||||
Thread.Sleep(Timeout.Infinite); } }
|
||||
'@
|
||||
$cuerpo = if ($EscribeSello) {
|
||||
@'
|
||||
Directory.CreateDirectory(Path.Combine(d, "config"));
|
||||
File.WriteAllText(Path.Combine(d, "config", ".version"), "__VER__");
|
||||
'@
|
||||
} else {
|
||||
' // 1.1.0 no escribia el sello de version'
|
||||
}
|
||||
$src = $plantilla.Replace('__CUERPO__', $cuerpo).Replace('__VER__', $Version)
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $Destino) -Force | Out-Null
|
||||
Add-Type -TypeDefinition $src -OutputAssembly $Destino -OutputType ConsoleApplication
|
||||
}
|
||||
|
||||
function Matar-Agentes {
|
||||
Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue |
|
||||
Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Milliseconds 800
|
||||
}
|
||||
|
||||
# ------------------------------------------------ tarea programada simulada (estado + semántica)
|
||||
$global:tareaExiste = $true
|
||||
$global:tareaExecute = if ($Escenario -eq 'alineada') {
|
||||
Join-Path $declarada 'CloudRestoreAS.exe'
|
||||
} else {
|
||||
Join-Path $otra 'CloudRestoreAS.exe'
|
||||
}
|
||||
$global:tareaArgs = '--start-engine --headless'
|
||||
$global:reapuntadaVeces = 0
|
||||
|
||||
function Get-ScheduledTask {
|
||||
[CmdletBinding()] param([string]$TaskName)
|
||||
if (-not $global:tareaExiste) { return $null }
|
||||
[pscustomobject]@{
|
||||
TaskName = 'CloudRestoreAS'
|
||||
State = 'Running'
|
||||
Actions = @([pscustomobject]@{
|
||||
Execute = $global:tareaExecute
|
||||
Arguments = $global:tareaArgs
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function Set-ScheduledTask {
|
||||
[CmdletBinding()] param([string]$TaskName, $Action)
|
||||
$global:tareaExecute = $Action[0].Execute
|
||||
$global:tareaArgs = $Action[0].Arguments
|
||||
$global:reapuntadaVeces++
|
||||
return $true
|
||||
}
|
||||
|
||||
function New-ScheduledTaskAction {
|
||||
[CmdletBinding()] param([string]$Execute, [string]$Argument, [string]$WorkingDirectory)
|
||||
[pscustomobject]@{ Execute = $Execute; Arguments = $Argument }
|
||||
}
|
||||
|
||||
function Start-ScheduledTask {
|
||||
# AQUÍ está el corazón del fallo: se lanza la ruta REGISTRADA EN LA TAREA.
|
||||
[CmdletBinding()] param([string]$TaskName)
|
||||
Start-Process -FilePath $global:tareaExecute `
|
||||
-WorkingDirectory (Split-Path -Parent $global:tareaExecute) -WindowStyle Hidden | Out-Null
|
||||
}
|
||||
|
||||
function Stop-ScheduledTask {
|
||||
[CmdletBinding()] param([string]$TaskName)
|
||||
Matar-Agentes
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- montaje del escenario
|
||||
Matar-Agentes
|
||||
Remove-Item $base -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "=== Montando escenario: $Etiqueta ===" -ForegroundColor Cyan
|
||||
|
||||
Nuevo-AgenteFalso -Destino (Join-Path $declarada 'CloudRestoreAS.exe') -Version '1.1.0'
|
||||
New-Item -ItemType Directory -Path (Join-Path $declarada 'config') -Force | Out-Null
|
||||
# Multilínea, como el que escribe el bootstrap del agente. Con UNA sola línea se destapa un bug
|
||||
# aparte de Merge-EnvFile (el array se desenrolla a escalar y $lines.Count revienta con StrictMode),
|
||||
# que el instalador viejo todavía tiene y aquí taparía el fallo que se quiere medir.
|
||||
Set-Content -Path (Join-Path $declarada 'config\.env') -Value @(
|
||||
'# Configuracion de CloudRestoreAS',
|
||||
'CLOUDRESTORE_PANEL_API_URL=http://ejemplo.local:3000',
|
||||
'CLOUDRESTORE_AUTO_START=true'
|
||||
)
|
||||
|
||||
Nuevo-AgenteFalso -Destino (Join-Path $otra 'CloudRestoreAS.exe') -Version '1.1.0'
|
||||
|
||||
New-Item -ItemType Directory -Path $pkg -Force | Out-Null
|
||||
Copy-Item $Installer (Join-Path $pkg 'install.ps1') -Force
|
||||
Nuevo-AgenteFalso -Destino (Join-Path $pkg 'dist\CloudRestoreAS.exe') -Version '1.1.3' -EscribeSello
|
||||
|
||||
$panelEnv = Join-Path $base 'panel.env'
|
||||
Set-Content -Path $panelEnv -Value @(
|
||||
'CLOUDRESTORE_PANEL_API_URL=http://ejemplo.local:3000',
|
||||
'CLOUDRESTORE_PANEL_API_TOKEN=token-de-prueba-no-real',
|
||||
'CLOUDRESTORE_PANEL_INSTANCE_KEY=servidor-de-prueba'
|
||||
)
|
||||
|
||||
# El agente viejo corriendo, lanzado por la tarea desde su carpeta.
|
||||
Start-ScheduledTask -TaskName 'CloudRestoreAS'
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
Write-Host " instalacion declarada : $declarada"
|
||||
Write-Host " tarea ejecuta : $global:tareaExecute"
|
||||
$antes = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { $_.Path } | Select-Object -Unique)
|
||||
Write-Host " proceso corriendo de : $($antes -join ', ')"
|
||||
|
||||
# ---------------------------------------------------------------- la actualización
|
||||
Write-Host ''
|
||||
Write-Host '=== Actualizacion, tal como la lanza el panel ===' -ForegroundColor Cyan
|
||||
$codigo = 0
|
||||
try {
|
||||
& (Join-Path $pkg 'install.ps1') -UpdateInPlace -Prefix $declarada -PanelEnvFile $panelEnv
|
||||
$codigo = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }
|
||||
} catch {
|
||||
$codigo = 1
|
||||
Write-Host " [el instalador lanzo] $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host "--- codigo de salida: $codigo"
|
||||
|
||||
# ---------------------------------------------------------------- veredicto
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host ''
|
||||
Write-Host '=== RESULTADO ===' -ForegroundColor Cyan
|
||||
$esperado = Join-Path $declarada 'CloudRestoreAS.exe'
|
||||
$procAhora = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { try { $_.Path } catch { '(ilegible)' } } | Select-Object -Unique)
|
||||
$selloPath = Join-Path $declarada 'config\.version'
|
||||
$sello = if (Test-Path $selloPath) { (Get-Content $selloPath -Raw).Trim() } else { '(no existe)' }
|
||||
|
||||
Write-Host " tarea ejecuta : $global:tareaExecute (reapuntada $global:reapuntadaVeces vez/veces)"
|
||||
Write-Host " proceso corriendo de : $($procAhora -join ', ')"
|
||||
Write-Host " config\.version : $sello"
|
||||
|
||||
$fallos = @()
|
||||
if ($global:tareaExecute -ne $esperado) { $fallos += "la tarea sigue apuntando a $global:tareaExecute" }
|
||||
if ($procAhora -notcontains $esperado) { $fallos += 'el proceso vivo no es el de la instalacion declarada' }
|
||||
if ($sello -ne '1.1.3') { $fallos += "el sello dice '$sello', se esperaba 1.1.3" }
|
||||
# Una tarea que ya apuntaba bien no debe reescribirse: hacerlo sin necesidad arriesga perder
|
||||
# ajustes que el operador le haya hecho.
|
||||
if ($Escenario -eq 'alineada' -and $global:reapuntadaVeces -ne 0) {
|
||||
$fallos += "se reescribio una tarea que ya estaba bien ($global:reapuntadaVeces vez/veces)"
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
if ($fallos.Count -eq 0 -and $codigo -eq 0) {
|
||||
Write-Host 'VEREDICTO: la actualizacion SI surtio efecto' -ForegroundColor Green
|
||||
} elseif ($fallos.Count -eq 0) {
|
||||
Write-Host "VEREDICTO: surtio efecto pero el instalador salio con $codigo" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host 'VEREDICTO: la actualizacion NO surtio efecto' -ForegroundColor Red
|
||||
$fallos | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
||||
if ($codigo -eq 0) {
|
||||
Write-Host ' *** y el instalador dijo que TODO BIEN (codigo 0) ***' -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
Matar-Agentes
|
||||
Remove-Item $base -Recurse -Force -ErrorAction SilentlyContinue
|
||||
111
scripts/probar-funciones-install.ps1
Normal file
111
scripts/probar-funciones-install.ps1
Normal file
@@ -0,0 +1,111 @@
|
||||
<#
|
||||
Pruebas de las funciones de install.ps1, sin instalar nada.
|
||||
|
||||
Las funciones se extraen del script por AST y se ejercitan contra una tarea programada simulada.
|
||||
Así se prueba el COMPORTAMIENTO —no solo que el archivo parsee— sin necesitar elevación ni
|
||||
registrar una tarea 'CloudRestoreAS' de verdad en la máquina.
|
||||
|
||||
Complementa a emular-actualizacion-windows.ps1: ese cubre el flujo completo, este cubre los casos
|
||||
límite de la comparación de rutas, que es donde se esconden los fallos silenciosos.
|
||||
|
||||
Uso: .\probar-funciones-install.ps1
|
||||
#>
|
||||
param([string]$Installer = (Join-Path (Split-Path -Parent $PSScriptRoot) 'install.ps1'))
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$tokens = $null; $errores = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Resolve-Path $Installer).Path, [ref]$tokens, [ref]$errores)
|
||||
if ($errores -and $errores.Count -gt 0) {
|
||||
Write-Host "install.ps1 no parsea: $($errores.Count) error(es)" -ForegroundColor Red
|
||||
$errores | ForEach-Object { Write-Host (" L" + $_.Extent.StartLineNumber + ": " + $_.Message) }
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Variables de ámbito de script que usan las funciones.
|
||||
$Prefix = 'C:\Aduanasoft\CloudRestoreAS-win'
|
||||
$dest = Join-Path $Prefix 'CloudRestoreAS.exe'
|
||||
$TaskName = 'CloudRestoreAS'
|
||||
$ProcName = 'CloudRestoreAS'
|
||||
|
||||
$queremos = @('Write-Step', 'Write-Ok', 'Write-Warn', 'Get-NormalizedPath', 'Test-SamePath',
|
||||
'Get-AgentTaskExecute', 'Repair-AgentTaskPath', 'Sync-AgentTaskPath')
|
||||
foreach ($f in $ast.FindAll({ param($n)
|
||||
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
|
||||
if ($queremos -contains $f.Name) { . ([scriptblock]::Create($f.Extent.Text)) }
|
||||
}
|
||||
|
||||
$fallos = 0
|
||||
function Assert($cond, $msg) {
|
||||
if ($cond) { Write-Host " OK $msg" } else { Write-Host " FALLA $msg" -ForegroundColor Red; $script:fallos++ }
|
||||
}
|
||||
|
||||
Write-Host '=== Test-SamePath: rutas donde una es prefijo de cadena de la otra ==='
|
||||
# El caso que existe en produccion. Un `-like "$a*"` o un StartsWith aqui daria por iguales dos
|
||||
# instalaciones distintas, no se corregiria la tarea, y la actualizacion no surtiria efecto.
|
||||
$porOmision = 'C:\Aduanasoft\CloudRestoreAS'
|
||||
$personalizada = 'C:\Aduanasoft\CloudRestoreAS-win'
|
||||
Assert (-not (Test-SamePath $porOmision $personalizada)) 'no confunde la ruta por omision con la -win'
|
||||
Assert (-not (Test-SamePath $personalizada $porOmision)) 'ni al reves'
|
||||
Assert (-not (Test-SamePath "$porOmision\CloudRestoreAS.exe" "$personalizada\CloudRestoreAS.exe")) `
|
||||
'tampoco con el ejecutable completo'
|
||||
Assert (Test-SamePath $personalizada $personalizada) 'y la personalizada sigue siendo igual a si misma'
|
||||
|
||||
Write-Host '=== Test-SamePath: normalizacion ==='
|
||||
Assert (Test-SamePath "`"$personalizada`"" $personalizada) 'tolera comillas (la tarea las guarda asi)'
|
||||
Assert (Test-SamePath $personalizada.ToUpper() $personalizada) 'NTFS no distingue mayusculas'
|
||||
Assert (Test-SamePath "$personalizada\" $personalizada) 'ignora la barra final'
|
||||
Assert (Test-SamePath "$Prefix\..\CloudRestoreAS-win\x.exe" "$Prefix\x.exe") 'normaliza .. en la ruta'
|
||||
Assert (-not (Test-SamePath '' $personalizada)) 'una ruta vacia no es igual a nada'
|
||||
|
||||
Write-Host '=== Get-AgentTaskExecute ==='
|
||||
function Get-AgentTask { [pscustomobject]@{ Actions = @(
|
||||
[pscustomobject]@{ Execute = "`"$porOmision\CloudRestoreAS.exe`""; Arguments = '--start-engine --headless' }) } }
|
||||
Assert ((Get-AgentTaskExecute) -eq "$porOmision\CloudRestoreAS.exe") 'devuelve la ruta sin comillas'
|
||||
|
||||
# Una accion ComHandler no tiene .Execute: con StrictMode, tocarla a ciegas seria un error.
|
||||
function Get-AgentTask { [pscustomobject]@{ Actions = @(
|
||||
[pscustomobject]@{ ClassId = '{guid}' },
|
||||
[pscustomobject]@{ Execute = "$porOmision\CloudRestoreAS.exe"; Arguments = '' }) } }
|
||||
Assert ((Get-AgentTaskExecute) -eq "$porOmision\CloudRestoreAS.exe") 'se salta acciones sin Execute'
|
||||
|
||||
function Get-AgentTask { $null }
|
||||
Assert ((Get-AgentTaskExecute) -eq '') 'sin tarea devuelve cadena vacia'
|
||||
|
||||
Write-Host '=== Sync-AgentTaskPath: el fallo silencioso ==='
|
||||
$script:reapuntadoA = $null
|
||||
$script:argsPreservados = $null
|
||||
function Set-ScheduledTask { param($TaskName, $Action)
|
||||
$ejec = @($Action | Where-Object { $_.PSObject.Properties['Execute'] -and $_.Execute })
|
||||
$script:reapuntadoA = $ejec[0].Execute
|
||||
$script:argsPreservados = $ejec[0].Arguments
|
||||
return $true }
|
||||
function New-ScheduledTaskAction { param($Execute, $Argument, $WorkingDirectory)
|
||||
[pscustomobject]@{ Execute = $Execute; Arguments = $Argument } }
|
||||
|
||||
function Get-AgentTask { [pscustomobject]@{ Actions = @(
|
||||
[pscustomobject]@{ Execute = "$porOmision\CloudRestoreAS.exe"; Arguments = '--start-engine --headless' }) } }
|
||||
Sync-AgentTaskPath
|
||||
Assert ($script:reapuntadoA -eq $dest) "reapunta de la carpeta por omision a $dest"
|
||||
Assert ($script:argsPreservados -eq '--start-engine --headless') 'conserva los argumentos originales'
|
||||
|
||||
$script:reapuntadoA = $null
|
||||
function Get-AgentTask { [pscustomobject]@{ Actions = @(
|
||||
[pscustomobject]@{ Execute = $dest; Arguments = '--start-engine --headless' }) } }
|
||||
Sync-AgentTaskPath
|
||||
Assert ($null -eq $script:reapuntadoA) 'una tarea ya alineada no se toca'
|
||||
|
||||
# Si no se puede corregir tiene que FALLAR, no arrancar el binario viejo en silencio.
|
||||
function Set-ScheduledTask { param($TaskName, $Action) throw 'Acceso denegado' }
|
||||
function Get-AgentTask { [pscustomobject]@{ Actions = @(
|
||||
[pscustomobject]@{ Execute = "$porOmision\CloudRestoreAS.exe"; Arguments = '' }) } }
|
||||
$lanzo = $false; $msg = ''
|
||||
try { Sync-AgentTaskPath } catch { $lanzo = $true; $msg = $_.Exception.Message }
|
||||
Assert $lanzo 'sin permiso para corregir -> lanza en vez de seguir'
|
||||
Assert ($lanzo -and $msg -match 'sin efecto') 'el mensaje explica que la actualizacion no surtiria efecto'
|
||||
|
||||
Write-Host ''
|
||||
if ($fallos -gt 0) { Write-Host "FALLOS: $fallos" -ForegroundColor Red; exit 1 }
|
||||
Write-Host 'TODO OK' -ForegroundColor Green
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import bootstrap
|
||||
from app.config.bootstrap import ensure_runtime_layout
|
||||
from app.config.env_loader import (
|
||||
apply_env_overrides,
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
69
tests/test_runner_qt_platform.py
Normal file
69
tests/test_runner_qt_platform.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Selección del plugin de plataforma Qt.
|
||||
|
||||
Esto decide si el agente arranca o no en un servidor. El caso que motivó las pruebas: en
|
||||
Windows, `--headless` no hacía nada —la función salía de inmediato en `win32`—, así que la
|
||||
tarea programada ONSTART, que corre como SYSTEM en la sesión 0 y sin escritorio interactivo,
|
||||
arrancaba con el plugin 'windows' e intentaba crear una ventana real. En Linux el mismo modo
|
||||
funcionaba porque el unit de systemd fija QT_QPA_PLATFORM=offscreen por fuera, y esa asimetría
|
||||
escondió el defecto: la bandera parecía cubierta en las dos plataformas.
|
||||
"""
|
||||
|
||||
import runner
|
||||
|
||||
|
||||
def test_headless_fuerza_offscreen_en_windows(monkeypatch):
|
||||
# La regresión: la tarea ONSTART pasa --headless y necesita que sirva de algo.
|
||||
monkeypatch.setattr(runner.sys, "platform", "win32")
|
||||
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
|
||||
|
||||
assert runner._ensure_qt_platform(headless=True) == "offscreen"
|
||||
assert runner.os.environ["QT_QPA_PLATFORM"] == "offscreen"
|
||||
|
||||
|
||||
def test_headless_fuerza_offscreen_en_linux(monkeypatch):
|
||||
monkeypatch.setattr(runner.sys, "platform", "linux")
|
||||
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
|
||||
# Con --headless da igual que haya display: lo pidió el llamador.
|
||||
assert runner._ensure_qt_platform(headless=True) == "offscreen"
|
||||
|
||||
|
||||
def test_windows_sin_headless_conserva_el_plugin_nativo(monkeypatch):
|
||||
# Quien abre la app con doble clic quiere su ventana. En Windows no existen DISPLAY ni
|
||||
# WAYLAND_DISPLAY, así que tomar su ausencia como señal mandaría a offscreen a todos.
|
||||
monkeypatch.setattr(runner.sys, "platform", "win32")
|
||||
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
|
||||
monkeypatch.delenv("DISPLAY", raising=False)
|
||||
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
||||
|
||||
assert runner._ensure_qt_platform(headless=False) == ""
|
||||
assert "QT_QPA_PLATFORM" not in runner.os.environ
|
||||
|
||||
|
||||
def test_linux_sin_display_cae_a_offscreen(monkeypatch):
|
||||
monkeypatch.setattr(runner.sys, "platform", "linux")
|
||||
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
|
||||
monkeypatch.delenv("DISPLAY", raising=False)
|
||||
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
||||
|
||||
assert runner._ensure_qt_platform(headless=False) == "offscreen"
|
||||
|
||||
|
||||
def test_una_eleccion_explicita_manda_sobre_headless(monkeypatch):
|
||||
# El instalador fija QT_QPA_PLATFORM para el bootstrap; un override del operador tiene que
|
||||
# sobrevivir, o depurar un arranque en un servidor ajeno se vuelve imposible.
|
||||
monkeypatch.setattr(runner.sys, "platform", "win32")
|
||||
monkeypatch.setenv("QT_QPA_PLATFORM", "minimal")
|
||||
|
||||
assert runner._ensure_qt_platform(headless=True) == "minimal"
|
||||
assert runner.os.environ["QT_QPA_PLATFORM"] == "minimal"
|
||||
|
||||
|
||||
def test_la_bandera_headless_existe_en_el_parser():
|
||||
# La tarea programada y el unit de systemd la pasan; si desaparece, argparse aborta el
|
||||
# arranque con código 2 y el agente no levanta en ningún servidor.
|
||||
args = runner.parse_args(["--start-engine", "--headless"])
|
||||
assert args.headless is True
|
||||
assert args.start_engine is True
|
||||
Reference in New Issue
Block a user