erplibre/test/test_qemu_deploy_monitor.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

284 lines
11 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Suivre un déploiement qui n'installe PAS ERPLibre.
Symptôme rapporté : en décochant l'installation d'ERPLibre, le tableau de bord
ne s'ouvrait plus du tout. Deux causes, l'une derrière l'autre :
- la case « suivi » vivait DANS le groupe de l'installation ERPLibre, et
`build_spec` ne la recopiait même pas dans la spec finale ;
- l'épilogue du déploiement était gardé par « if install or desktop » : sans
rien à installer, il ne se passait rien.
Et si le suivi s'ouvrait quand même, il n'aurait rien montré : la commande
distante valait « true », donc un journal vide et un ✅ instantané. Elle
regarde maintenant la VM ARRIVER — cloud-init, puis un relevé système — ce qui
donne un début, une fin, et de quoi juger qu'elle est prête.
"""
import contextlib
import io
import os
import subprocess
import sys
import unittest
sys.argv = ["todo.py"]
from script.todo import qemu_install_monitor as mon # noqa: E402
from script.todo.qemu_deploy_form import build_spec # noqa: E402
from script.todo.todo import TODO # noqa: E402
class TestLaCommandeDistante(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
def _sans_rien(self):
return self.todo._qemu_erplibre_remote_cmd(None)
def test_it_is_no_longer_a_bare_true(self):
"""« true » rendait un journal vide et un ✅ instantané : le suivi
s'ouvrait sur rien."""
self.assertNotEqual("true", self._sans_rien().strip())
def test_it_is_valid_shell(self):
res = subprocess.run(
["bash", "-n"],
input=self._sans_rien(),
capture_output=True,
text=True,
)
self.assertEqual(0, res.returncode, res.stderr)
def test_it_waits_for_cloud_init(self):
"""C'est le vrai « suivi » d'une VM neuve : tant que cloud-init tourne,
la machine n'est pas prête."""
self.assertIn("cloud-init status --wait", self._sans_rien())
def test_it_frames_the_step_so_the_monitor_can_bound_it(self):
"""Le tableau lit ces marqueurs pour dire où en est la VM."""
cmd = self._sans_rien()
self.assertIn("===>", cmd)
self.assertIn("<===", cmd)
def test_it_reports_what_says_the_vm_is_up(self):
cmd = self._sans_rien()
for morceau in ("/etc/os-release", "uname -r", "hostname -I", "df -h"):
self.assertIn(morceau, cmd, morceau)
def test_it_runs_and_says_something(self):
"""Exécutée ici, hors VM : l'attente de cloud-init est neutralisée, le
relevé doit sortir et rendre 0."""
cmd = self._sans_rien().replace(
"sudo timeout 900 cloud-init status --wait", "true"
)
res = subprocess.run(
["bash", "-c", cmd], capture_output=True, text=True, timeout=60
)
self.assertEqual(0, res.returncode, res.stderr[-300:])
self.assertIn("===>", res.stdout)
self.assertIn("<===", res.stdout)
# Trois lignes de relevé au moins : sans elles, le journal est creux.
self.assertGreaterEqual(len(res.stdout.strip().splitlines()), 5)
def test_a_desktop_only_vm_still_installs_its_desktop(self):
"""Le chemin qui marchait déjà ne doit pas changer de sens."""
self.todo._qemu_desktop_remote_cmd = lambda d, s: "INSTALLE_BUREAU; "
self.todo._qemu_tools_remote_cmd = lambda *a, **k: ""
self.todo._qemu_no_auto_upgrade = lambda *a, **k: ""
cmd = self.todo._qemu_erplibre_remote_cmd(None, desktop="gnome")
self.assertIn("INSTALLE_BUREAU", cmd)
class TestLaSpecDuFormulaire(unittest.TestCase):
def _form(self, **extra):
base = {
"res_label": "x1",
"ssh_key": "/k.pub",
"install": None,
"add_ssh_config": False,
"parallelism": 1,
}
base.update(extra)
return base
def test_the_choice_reaches_the_spec(self):
"""Il ne la recopiait pas : le choix du formulaire n'atteignait jamais
le déploiement."""
spec = build_spec([], [], self._form(monitor=True))
self.assertTrue(spec["monitor"])
spec = build_spec([], [], self._form(monitor=False))
self.assertFalse(spec["monitor"])
def test_an_old_form_without_the_key_still_monitors(self):
"""Compatibilité : une spec enregistrée avant ce changement ne doit pas
perdre son tableau de bord."""
self.assertTrue(build_spec([], [], self._form())["monitor"])
class TestLaDecisionDuDeploiement(unittest.TestCase):
"""L'épilogue : qui est appelé, et avec quoi."""
def _joue(self, spec):
todo = TODO.__new__(TODO)
appels = []
todo._qemu_install_erplibre_monitored = lambda *a, **k: appels.append(
"suivi"
)
todo._qemu_install_erplibre_vm = lambda *a, **k: appels.append("muet")
todo._qemu_resolve_ips = lambda names, labels=None: {}
# Même en ligne, le déploiement relit l'état de la coupure : un hôte
# simulé sans guet et un verrou introuvable tiennent la machine de
# test hors du jeu.
import tempfile
from pathlib import Path
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
todo._qemu_shell = lambda cmd, timeout=60: 1
todo._qemu_verrou_hors_ligne_chemin = lambda: Path(tmp.name) / "absent"
base = {
"vms": [],
"existing": ["vm-a"],
"install": None,
"add_ssh_config": False,
"parallelism": 1,
}
base.update(spec)
with contextlib.redirect_stdout(io.StringIO()):
todo._qemu_run_spec(base)
return appels
def test_without_erplibre_the_monitor_still_opens(self):
"""Le cœur du problème rapporté."""
self.assertEqual(["suivi"], self._joue({"monitor": True}))
def test_a_spec_without_the_key_monitors_too(self):
self.assertEqual(["suivi"], self._joue({}))
def test_refusing_the_monitor_does_nothing_at_all(self):
"""Et surtout : ne pas partir installer un profil qui n'existe pas.
L'ancien repli faisait « install['cmd'] » sur un None."""
self.assertEqual([], self._joue({"monitor": False}))
def test_an_install_without_the_monitor_takes_the_quiet_path(self):
appels = self._joue(
{
"monitor": False,
"install": {
"branch": "develop",
"prod": False,
"cmd": "make x",
"monitor": False,
},
}
)
self.assertEqual(["muet"], appels)
def test_the_install_keeps_the_last_word_on_its_own_monitoring(self):
appels = self._joue(
{
"monitor": False,
"install": {
"branch": "develop",
"prod": False,
"cmd": "make x",
"monitor": True,
},
}
)
self.assertEqual(["suivi"], appels)
class TestLeJournal(unittest.TestCase):
"""L'en-tête et le prologue ne doivent pas annoncer ce qui n'a pas lieu."""
def _vm(self):
return {
"name": "vm-a",
"ip": "10.0.0.9",
"distro": "debian",
"version": "13",
"arch": "amd64",
}
def test_an_install_is_titled_an_install(self):
head = mon._log_header(self._vm(), "develop", "2026-01-01 00:00:00")
self.assertIn(mon.t("installation"), head)
self.assertIn("develop", head)
def test_without_a_branch_it_is_not_called_an_install(self):
"""« ERPLibre — installation » puis « Branche : » vide, sur un
déploiement qui n'installe rien : le journal se contredisait."""
head = mon._log_header(self._vm(), "", "2026-01-01 00:00:00")
self.assertIn(mon.t("VM start-up"), head)
self.assertNotIn("Branche", head)
def test_what_the_host_decided_reaches_the_log(self):
"""La console qui a dit la décision a défilé ; le journal est ce
qu'on rouvre quand l'installation a échoué. Sans ces lignes il ne
porte que le symptôme — des centaines de lignes de construction et
un certificat refusé, sans un mot sur ce qui l'a voulu."""
vm = dict(
self._vm(),
notes=[
"⚠ pas de magasin de confiance : nixos",
"✓ hôte soustrait au cache : pve-local",
],
)
head = mon._log_header(vm, "develop", "2026-01-01 00:00:00")
self.assertIn(mon.t("Prepared by the host:"), head)
self.assertIn("pas de magasin de confiance", head)
self.assertIn("hôte soustrait au cache", head)
def test_a_log_without_notes_is_unchanged(self):
"""Le cas ordinaire ne gagne pas une section vide."""
head = mon._log_header(self._vm(), "develop", "2026-01-01 00:00:00")
self.assertNotIn(mon.t("Prepared by the host:"), head)
def test_the_prologue_says_what_actually_follows(self):
"""« installation ERPLibre en cours » alors que rien ne s'installe."""
import tempfile
from pathlib import Path
vus = []
vrai_launch, vrai_dir = mon._launch_one, mon.session_dir
# « **kw » et non une liste figée : chaque paramètre ajouté au
# lanceur (comme « pve ») casserait sinon ce test, qui ne vérifie
# pourtant que le prologue du journal.
mon._launch_one = lambda ip, cmd, log, name="", installs=True, **kw: (
vus.append(installs)
)
# session_dir détournée : sans cela le test écrivait de VRAIES sessions
# dans ~/.erplibre/qemu-install, qui polluaient l'historique que
# « Rouvrir le suivi » propose à l'utilisateur.
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
mon.session_dir = lambda: Path(tmp.name)
try:
mon.launch_installs([self._vm()], "", "true")
mon.launch_installs([self._vm()], "develop", "true")
finally:
mon._launch_one, mon.session_dir = vrai_launch, vrai_dir
self.assertEqual([False, True], vus)
def test_the_host_key_notice_is_not_a_warning(self):
"""ssh l'écrit à CHAQUE première connexion : comptée, la colonne ⚠
s'allumait sur toute installation, dès sa première ligne."""
import tempfile
with tempfile.NamedTemporaryFile(
"w", suffix=".log", delete=False, encoding="utf-8"
) as fh:
fh.write(
"Warning: Permanently added '10.0.0.9' (ED25519) to the"
" list of known hosts.\n__ERPLIBRE_EXIT__ 0\n"
)
chemin = fh.name
self.addCleanup(os.unlink, chemin)
self.assertEqual((0, 0), mon.scan_log_errors(chemin))
if __name__ == "__main__":
unittest.main(verbosity=1)