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>
This commit is contained in:
2026-07-31 08:46:12 -06:00
parent c8133a5108
commit c2afa52d6f
4 changed files with 441 additions and 59 deletions

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,137 @@ $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 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.
#>
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 {
param([int]$TimeoutSeconds = 60)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Get-AgentProcess) { return $true }
Start-Sleep -Seconds 2
}
return $false
}
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 +197,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,32 +229,54 @@ 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
$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.')
}
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
}
Copy-Item -LiteralPath $src -Destination $dest -Force
Copy-Binary -From $src -To $dest
Write-Ok "Binario instalado en $dest"
# --- Bootstrap de config\ -----------------------------------------------------------
# El propio binario crea config\, .env y las carpetas de trabajo al arrancar. Se corre
# una vez acotado por timeout para que el operador ya pueda editar config\.env.
#
# Al actualizar en sitio se OMITE: el binario hace ensure_runtime_layout() en cada arranque, así
# que es redundante, y correr una segunda instancia junto a la viva es peligroso —al arrancar, el
# motor purga todas las subcarpetas de Temp, que son de la instancia en curso—.
if ($Mode -ne 'update-in-place') {
Write-Step 'Inicializando config\ (bootstrap)'
Push-Location $Prefix
try {
@@ -117,10 +289,11 @@ try {
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.'
}
@@ -178,6 +351,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 +368,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 +382,87 @@ 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) {
if (-not (Get-AgentProcess)) {
$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

@@ -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 ""
@@ -206,7 +220,12 @@ 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.
# 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_()

View File

@@ -97,6 +97,68 @@ 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_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