From c2afa52d6f144494b43d81aa327dbcc73bb980b9 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 31 Jul 2026 08:46:12 -0600 Subject: [PATCH] =?UTF-8?q?fix(install):=20actualizaci=C3=B3n=20desatendid?= =?UTF-8?q?a=20en=20Windows,=20con=20reversi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- install.ps1 | 324 ++++++++++++++++++++++++++----- runner.py | 45 +++-- tests/test_release_metadata.py | 62 ++++++ tests/test_runner_qt_platform.py | 69 +++++++ 4 files changed, 441 insertions(+), 59 deletions(-) create mode 100644 tests/test_runner_qt_platform.py diff --git a/install.ps1 b/install.ps1 index f21ee0e..771d5ed 100644 --- a/install.ps1 +++ b/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,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,48 +229,71 @@ 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" # --- 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.' } @@ -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 diff --git a/runner.py b/runner.py index 0bf2926..7b54d81 100644 --- a/runner.py +++ b/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 "" @@ -206,11 +220,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}") diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index 2028c5b..15f87f5 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -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): """ diff --git a/tests/test_runner_qt_platform.py b/tests/test_runner_qt_platform.py new file mode 100644 index 0000000..590eec8 --- /dev/null +++ b/tests/test_runner_qt_platform.py @@ -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