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:
@@ -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):
|
||||
"""
|
||||
|
||||
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