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

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