Files
CloudRecoveryAS/tests/test_release_metadata.py
hreyes c2afa52d6f 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>
2026-07-31 08:46:12 -06:00

354 lines
15 KiB
Python

"""
Pruebas del contrato de release: versión, artefactos y manifiesto.
Lo que se protege aquí es la cadena que hace posible la distribución automatizada:
app/__init__.py es la fuente única de la versión, package-release.sh la usa para nombrar
los artefactos y armar release.json, y el PANEL compara versiones como tuplas de enteros
para saber si hay una más nueva. Un formato de versión distinto rompe esa comparación en
silencio, así que se valida el formato, no solo que exista.
"""
import json
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
# Mismo patrón que valida package-release.sh y que el PANEL puede ordenar.
VERSION_RE = re.compile(r"^\d+(\.\d+){1,3}$")
def read_version_from_source() -> str:
"""Lee __version__ del archivo, sin importar el paquete (igual que el spec)."""
text = (ROOT / "app" / "__init__.py").read_text(encoding="utf-8")
match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
assert match, "no se encontró __version__ en app/__init__.py"
return match.group(1)
def test_version_tiene_formato_comparable():
assert VERSION_RE.match(read_version_from_source())
def test_version_del_paquete_coincide_con_el_archivo():
# El spec de PyInstaller y package-release.sh leen el archivo con regex; el resto de
# la app importa app.__version__. Ambos caminos deben dar lo mismo.
from app import __version__
assert __version__ == read_version_from_source()
def test_ui_no_hardcodea_la_version():
# El diálogo "Acerca de" traía la versión literal y quedó desfasado del paquete.
text = (ROOT / "app" / "ui" / "main_window.py").read_text(encoding="utf-8")
assert "__version__" in text
assert not re.search(r"CloudRestoreAS v\d+\.\d+\.\d+", text)
def test_bundled_versions_es_json_valido():
# El bootstrap calcula el sha256 de este archivo para decidir si re-despliega
# config/7zip y config/odbc; si no es JSON válido el build queda inconsistente.
manifest = ROOT / "packaging" / "bundled-versions.json"
data = json.loads(manifest.read_text(encoding="utf-8"))
assert data.get("seven_zip"), "falta la versión de 7-Zip"
def test_spec_inyecta_metadatos_de_version_en_windows():
spec = (ROOT / "packaging" / "CloudRestoreAS.spec").read_text(encoding="utf-8")
assert "version=_version_info" in spec, "el EXE debe recibir el recurso de versión"
assert "bundled-versions.json" in spec, "bundled-versions.json debe ir embebido"
def test_instalador_de_despliegue_y_script_de_desarrollo_estan_separados():
"""
scripts/dev-setup.ps1 prepara un venv de desarrollo y no tiene nada que hacer en el
zip del ejecutable autocontenido; install.ps1 es el instalador de despliegue. Antes
eran el mismo archivo y se empaquetaba el de desarrollo.
"""
script = (ROOT / "packaging" / "scripts" / "package-release.sh").read_text(encoding="utf-8")
assert '(root / "install.ps1", "CloudRestoreAS/install.ps1")' in script
assert "CloudRestoreAS/dev-setup.ps1" not in script
dev_setup = ROOT / "scripts" / "dev-setup.ps1"
installer = ROOT / "install.ps1"
assert dev_setup.is_file() and installer.is_file()
# El de desarrollo crea venv; el de despliegue registra el arranque automático.
assert "venv" in dev_setup.read_text(encoding="utf-8")
assert "Register-ScheduledTask" in installer.read_text(encoding="utf-8")
def test_instaladores_aceptan_panel_env_file():
"""
El instalador remoto del PANEL siembra las credenciales por archivo, no por argv,
para que el token no quede visible en `ps` ni en el historial del destino.
"""
sh = (ROOT / "install.sh").read_text(encoding="utf-8")
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "--panel-env-file" in sh
assert "PanelEnvFile" in ps1
# Lista blanca de claves en ambos: el archivo llega por la red.
for text in (sh, ps1):
assert "CLOUDRESTORE_PANEL_API_TOKEN" in text
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):
"""
Corre package-release.sh contra un árbol mínimo con binarios simulados y verifica
que release.json y SHA256SUMS concuerden entre sí y con los archivos en disco.
"""
for rel in (
"app/__init__.py",
"packaging/scripts/package-release.sh",
"packaging/LEEME.txt",
"packaging/bundled-versions.json",
"packaging/linux/cloudrestoreas.service",
"install.sh",
"install.ps1",
):
dest = tmp_path / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes((ROOT / rel).read_bytes())
dist = tmp_path / "dist"
dist.mkdir()
(dist / "CloudRestoreAS").write_text("ELF simulado\n", encoding="utf-8")
(dist / "CloudRestoreAS.exe").write_text("PE simulado\n", encoding="utf-8")
result = subprocess.run(
["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")],
capture_output=True,
text=True,
# Los binarios simulados son de unos bytes; se desactiva el piso de tamaño, que se
# prueba aparte en test_package_release_rechaza_binario_truncado.
env={**os.environ, "CLOUDRESTORE_MIN_BINARY_MB": "0"},
)
assert result.returncode == 0, f"package-release.sh falló:\n{result.stderr}"
release_dir = dist / "release"
manifest = json.loads((release_dir / "release.json").read_text(encoding="utf-8"))
version = read_version_from_source()
assert manifest["version"] == version
assert manifest["product"] == "CloudRestoreAS"
assert manifest["bundled"]["seven_zip"], "el manifiesto debe registrar las deps embebidas"
platforms = {a["platform"] for a in manifest["artifacts"]}
assert platforms == {"linux", "windows"}
# Cada artefacto: existe, el nombre lleva versión y plataforma, y el sha256/tamaño
# del manifiesto coinciden con el archivo real.
import hashlib
sums = dict(
reversed(line.split(maxsplit=1))
for line in (release_dir / "SHA256SUMS").read_text(encoding="utf-8").splitlines()
if line.strip()
)
for artifact in manifest["artifacts"]:
path = release_dir / artifact["file_name"]
assert path.is_file(), f"falta el artefacto {artifact['file_name']}"
assert version in artifact["file_name"]
assert artifact["arch"] in artifact["file_name"]
assert path.stat().st_size == artifact["size"]
assert hashlib.sha256(path.read_bytes()).hexdigest() == artifact["sha256"]
# SHA256SUMS y release.json no deben poder divergir.
assert sums[path.name.strip()].strip() == artifact["sha256"]
# Contenido de los paquetes: el instalador de despliegue va dentro, el script de
# desarrollo NO (antes se empaquetaba dev-setup.ps1 junto al .exe autocontenido).
import tarfile
import zipfile
win_pkg = next(a for a in manifest["artifacts"] if a["platform"] == "windows")
with zipfile.ZipFile(release_dir / win_pkg["file_name"]) as zf:
names = set(zf.namelist())
assert "CloudRestoreAS/CloudRestoreAS.exe" in names
assert "CloudRestoreAS/install.ps1" in names
assert not any("dev-setup" in n for n in names)
linux_pkg = next(a for a in manifest["artifacts"] if a["platform"] == "linux")
with tarfile.open(release_dir / linux_pkg["file_name"]) as tf:
members = set(tf.getnames())
# El instalador remoto extrae y corre CloudRestoreAS/install.sh, que a su vez busca
# el binario y la unit systemd relativos a su ubicación.
assert "CloudRestoreAS/CloudRestoreAS-linux" in members
assert "CloudRestoreAS/install.sh" in members
assert "CloudRestoreAS/packaging/linux/cloudrestoreas.service" in members
def _stage_package_tree(tmp_path: Path) -> Path:
"""Árbol mínimo para correr package-release.sh. Devuelve dist/."""
for rel in (
"app/__init__.py",
"packaging/scripts/package-release.sh",
"packaging/LEEME.txt",
"packaging/bundled-versions.json",
"packaging/linux/cloudrestoreas.service",
"install.sh",
"install.ps1",
):
dest = tmp_path / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes((ROOT / rel).read_bytes())
dist = tmp_path / "dist"
(dist / "release").mkdir(parents=True, exist_ok=True)
return dist
def _run_package(tmp_path: Path, min_mb: str = "1") -> subprocess.CompletedProcess:
return subprocess.run(
["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")],
capture_output=True,
text=True,
env={**os.environ, "CLOUDRESTORE_MIN_BINARY_MB": min_mb},
)
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
def test_package_release_ignora_binario_rancio_en_release(tmp_path: Path):
"""
Un binario que quedó en dist/release/ de una corrida anterior NO debe empaquetarse.
Así se generó una vez un zip etiquetado 1.1.0 con un .exe parcial de 4.9 MB: el build de
Windows había fallado, pero el empaquetado tomaba la copia vieja de dist/release/ en lugar
del binario recién compilado en dist/. El sha256 y el release.json quedaban consistentes
con los bytes equivocados, así que la verificación de integridad no lo detectaba.
"""
dist = _stage_package_tree(tmp_path)
(dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024)) # Linux sí compiló
# Sobrante de una corrida previa; dist/CloudRestoreAS.exe NO existe.
(dist / "release" / "CloudRestoreAS.exe").write_bytes(b"parcial" * 1000)
result = _run_package(tmp_path)
assert result.returncode == 0, result.stderr
manifest = json.loads((dist / "release" / "release.json").read_text(encoding="utf-8"))
assert [a["platform"] for a in manifest["artifacts"]] == ["linux"]
assert not list((dist / "release").glob("*win*.zip")), "no debió empaquetar Windows"
# La copia rancia se elimina para que no reaparezca en la siguiente corrida.
assert not (dist / "release" / "CloudRestoreAS.exe").exists()
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
def test_package_release_rechaza_binario_truncado(tmp_path: Path):
"""Un build a medias debe abortar el empaquetado completo, no publicarse."""
dist = _stage_package_tree(tmp_path)
(dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024))
(dist / "CloudRestoreAS.exe").write_bytes(b"x" * 1024) # muy por debajo del piso
result = _run_package(tmp_path, min_mb="1")
assert result.returncode != 0
salida = result.stdout + result.stderr
assert "truncado" in salida
assert "abortado" in salida
# Nada debe quedar publicable si alguna plataforma es inválida.
assert not (dist / "release" / "release.json").exists()
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
def test_package_release_rechaza_binario_anterior_al_bump(tmp_path: Path):
"""
Un binario más viejo que app/__init__.py pertenece a otra versión. Empaquetarlo con el
nombre de la versión actual publicaría una mentira que el sha256 no puede delatar.
"""
dist = _stage_package_tree(tmp_path)
(dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024))
exe = dist / "CloudRestoreAS.exe"
exe.write_bytes(b"x" * (2 * 1024 * 1024))
os.utime(exe, (0, 0)) # 1970: anterior a cualquier cambio de versión
result = _run_package(tmp_path)
assert result.returncode != 0
salida = result.stdout + result.stderr
assert "MÁS VIEJO" in salida
assert not (dist / "release" / "release.json").exists()
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash")
def test_package_release_rechaza_version_invalida(tmp_path: Path):
"""Una versión no comparable debe abortar el empaquetado, no publicarse."""
for rel in ("packaging/scripts/package-release.sh", "packaging/LEEME.txt"):
dest = tmp_path / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes((ROOT / rel).read_bytes())
(tmp_path / "app").mkdir()
(tmp_path / "app" / "__init__.py").write_text('__version__ = "1.0.0-rc1"\n', encoding="utf-8")
(tmp_path / "dist").mkdir()
result = subprocess.run(
["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")],
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "inválida" in (result.stdout + result.stderr)