erplibre/test/test_qemu_deploy_monitor.py
Mathieu Benoit a7706adf3d [ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris
VMs of one host pulled the same packages again and again, and an install
could not be proven to work without the network. A Go service intercepts the
bridge transparently: a package file is served from disk, an index is always
revalidated upstream and replayed only when upstream is mute, git is mirrored.
Deployment poses the cache authority in QEMU and Proxmox VE guests, and can cut
the network for a whole install. The TODO menu installs, diagnoses, fills,
cleans and copies the store; long_test/qemu_cache.py measures that a second VM
pulls no package upstream. Covered by Go tests and test/test_qemu_cache_*.py.

--- FR ---

Les VM d'un hôte tiraient sans cesse les mêmes paquets, et rien ne prouvait
qu'une installation marche sans réseau. Un service Go intercepte le pont de
façon transparente : un fichier de paquet est servi du disque, un index est
toujours revalidé à l'amont et rejoué seulement quand l'amont est muet, git
est mis en miroir. Le déploiement pose l'autorité du cache dans les invités
QEMU et Proxmox VE, et peut couper le réseau pour toute une installation. Le
menu TODO installe, diagnostique, remplit, nettoie et emporte le magasin ;
long_test/qemu_cache.py mesure qu'une seconde VM ne tire aucun paquet de
l'amont. Couvert par les tests Go et test/test_qemu_cache_*.py.

Assisted-by: Claude Opus 5
2026-09-14 16:46:15 -04:00

264 lines
9.9 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_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)