From 6e6e99c3041d5dd3f1e9bf85187d5209e0b850a6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 06:05:59 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20long=5Ftest=20:=20bail=20attendu,=20ral?= =?UTF-8?q?lumage=20=C3=A0=20froid,=20marge=20m=C3=A9moire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois mesures faites sur une descente à cinq étages, et une conclusion de ma part corrigée par le contre-essai. Le bail DHCP se fait attendre. L'étage 3 était créé, en type='kvm', et « domifaddr » ne rendait rien : l'invité n'avait pas encore demandé son adresse — 87 s puis 94 s selon les tours, quand deploy_qemu s'accorde 90 s et rend 0 sans l'avoir trouvée. Lu une fois, cela ne prouvait rien. Un redémarrage demandé à l'invité peut le laisser bloqué dans son micrologiciel : RIP immobile 46 minutes, pas un octet lu, trois vCPU à fond. J'ai d'abord conclu que c'était la taille de la mémoire, parce que la même machine à 2 Go démarrait. Le contre-essai à 4 Go l'a réfuté : elle démarre aussi, à froid. La différence est le REDÉMARRAGE, pas la mémoire — à chaud elle reste dans l'UEFI, à froid elle charge son noyau en 60 à 90 s, à 2, 3 et 4 Go. La descente rallume donc une fois par le parent, et une seule : une boucle de rallumage cacherait un vrai échec. La mémoire de la pile QEMU est doublée pour une autre raison, mesurée elle aussi : l'étage 2 avec 5 Go hébergeait un invité de 4 Go et n'avait plus que 127 Mo de libre. Ce n'est pas le plancher qui compte, c'est l'écart. --- EN --- Three measurements from a five-level descent, and a conclusion of mine refuted by the counter-test. The DHCP lease takes its time. Level 3 was created, type='kvm', and "domifaddr" returned nothing: the guest had not yet asked for its address — 87 s then 94 s depending on the run, while deploy_qemu allows itself 90 s and returns 0 without having found it. Read once, that proved nothing. A reboot asked of the guest can leave it stuck in its firmware: static RIP for 46 minutes, not a byte read, three vCPU at full tilt. I first concluded it was the memory size, because the same machine booted at 2 GB. The counter-test at 4 GB refuted it: it boots too, cold. The difference is the REBOOT, not the memory — warm it stays in UEFI, cold it loads its kernel in 60 to 90 s, at 2, 3 and 4 GB. The descent therefore power-cycles once through the parent, and only once: a restart loop would hide a real failure. The QEMU stack's memory is doubled for another, also measured reason: level 2 with 5 GB hosted a 4 GB guest and had 127 MB left. It is not the floor that matters, it is the gap. Assisted-by: claude-opus-5 (cherry picked from commit e3f60e3ddf066eb76d434bbfe6b01f2271fb1874) --- long_test/deep_qemu.py | 63 +++++++++++++++++++--- long_test/descente.py | 32 ++++++++++- script/proxmox/nesting.py | 11 ++-- test/test_deep_qemu.py | 105 ++++++++++++++++++++++++++++++++++--- test/test_todo_longtest.py | 4 +- 5 files changed, 193 insertions(+), 22 deletions(-) diff --git a/long_test/deep_qemu.py b/long_test/deep_qemu.py index 3dffe04..12e83e6 100644 --- a/long_test/deep_qemu.py +++ b/long_test/deep_qemu.py @@ -36,6 +36,7 @@ import re import shlex import subprocess import sys +import time RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, RACINE) @@ -327,6 +328,31 @@ class Descente(descente.Descente): ) return vu["libvirtd"] and vu["reseau"] + def rallumer_a_froid(self, parent, nom): + """« virsh destroy » puis « start » : un processus QEMU neuf. + + Mesuré sur la machine bloquée : à chaud elle restait 46 minutes au + même pointeur d'instruction, dans son micrologiciel ; à froid elle a + chargé son noyau en 60 à 90 secondes, trois fois de suite, à 2, 3 et + 4 Go. Ce n'est donc pas la taille de la mémoire — c'est la façon de + redémarrer. + """ + if self.dry_run or not parent: + return False + self.executer( + parent, + f"virsh -c qemu:///system destroy {nom} 2>/dev/null; true", + self.delai("controle"), + "extinction", + ) + code, _o = self.executer( + parent, + f"virsh -c qemu:///system start {nom}", + self.delai("controle"), + "rallumage", + ) + return code == 0 + def controler(self, hote): """CET étage peut-il héberger le suivant SANS l'émuler ? @@ -388,6 +414,35 @@ class Descente(descente.Descente): return None return ("default",) + def attendre_adresse(self, parent, nom): + """Le bail DHCP de l'enfant, attendu. Rend l'adresse, ou "". + + ATTENDU, et non lu une fois. Constaté au troisième étage : le domaine + était créé, en type='kvm', et « domifaddr » ne rendait rien — l'invité + n'avait pas encore demandé son bail. Plus l'étage est profond, plus il + démarre lentement, et c'est justement ce qu'on mesure. + + `deploy_qemu` attend lui-même l'adresse — 90 secondes par défaut — puis + rend 0 quand il ne l'a pas trouvée. Son code de sortie ne prouve donc + rien ici non plus. + """ + debut = time.time() + delai = self.delai("ssh") + while time.time() - debut < delai: + _c, sortie = self.executer( + parent, + f"virsh -c qemu:///system domifaddr {nom} --source lease", + DELAIS["controle"], + "domifaddr", + ) + adresse = parse_domifaddr(sortie) + if adresse: + if time.time() - debut > 20: + self.dire(f" bail après {int(time.time() - debut)} s") + return adresse + time.sleep(15) + return "" + def creer_enfant(self, parent, niveau, res, prepare, noter=None): """Une VM dans le parent, par NOTRE deploy_qemu.py. @@ -449,13 +504,7 @@ class Descente(descente.Descente): ) return None, None self.dire(f" domaine kvm, cpu {vu['cpu'] or '?'}") - _c, sortie = self.executer( - parent, - f"virsh -c qemu:///system domifaddr {nom} --source lease", - self.delai("ssh"), - "domifaddr", - ) - adresse = parse_domifaddr(sortie) + adresse = self.attendre_adresse(parent, nom) if not adresse: self.dire(" ✗ créée, mais sans adresse : rien à joindre") return None, None diff --git a/long_test/descente.py b/long_test/descente.py index 9762723..1e67be2 100644 --- a/long_test/descente.py +++ b/long_test/descente.py @@ -209,6 +209,22 @@ class Descente: """Les services de la pile répondent-ils, une fois redémarrés ?""" raise NotImplementedError + def rallumer_a_froid(self, parent, nom): + """Éteindre puis rallumer la machine DEPUIS SON PARENT. Rend True si + la pile sait le faire. + + Un redémarrage demandé à l'invité — « systemctl reboot » — peut le + laisser bloqué dans son micrologiciel : constaté une fois au troisième + étage, pointeur d'instruction immobile pendant 46 minutes, pas un + octet lu. Un démarrage à FROID, lui, repart d'un processus neuf, et + les trois essais faits ensuite ont tous abouti. + + La cause du gel n'est pas établie ; le remède ne l'attend pas. Une + descente ne doit pas passer six heures à guetter une machine qui ne + reviendra jamais, quand son parent peut la rallumer. + """ + return False + def controler(self, hote): """Cet étage peut-il HÉBERGER le suivant ? @@ -337,7 +353,7 @@ class Descente: time.sleep(15) return None - def redemarrer_et_verifier(self, hote): + def redemarrer_et_verifier(self, hote, parent=None, nom=""): """Redémarre, attend le retour, exige le noyau voulu. L'installation pose le noyau sans redémarrer — lancée par ssh, un @@ -379,6 +395,13 @@ class Descente: f" noyau {noyau} après {int(time.time() - debut)} s" ) return True + # Une seule reprise, et seulement si le parent peut la donner : la + # machine est peut-être bloquée dans son micrologiciel, où un + # redémarrage demandé à l'invité ne la sortira jamais. Une fois, pas + # deux — une boucle de rallumage cacherait un vrai échec. + if parent and nom and self.rallumer_a_froid(parent, nom): + self.dire(" ↻ rallumée à froid par son parent") + return self.redemarrer_et_verifier(hote) self.dire(" ✗ pas revenue sur le noyau attendu") return False @@ -582,7 +605,12 @@ class Descente: # terminé sans qu'on sache s'il pouvait héberger le suivant. for etape, action in ( ("install", lambda: self.installer(cible)), - ("reboot", lambda: self.redemarrer_et_verifier(cible)), + ( + "reboot", + lambda: self.redemarrer_et_verifier( + cible, parent, etage.get("nom", "") + ), + ), ("systeme", lambda: self.preparer_systeme(cible)), ("services", lambda: self.remettre_debout(cible)), ("controle", lambda: self.controler(cible)), diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index 14cab13..ca32175 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -183,12 +183,17 @@ COUTS_PVE = Couts( # Un hôte libvirt nu : libvirtd et qemu-kvm, rien d'autre. Le poste qui domine # n'est plus le système mais l'IMAGE CLOUD que l'étage télécharge pour créer # son enfant — d'où un disque cible qui n'est pas si petit. +# +# La mémoire a été DOUBLÉE après une descente à cinq étages : l'étage 2, avec +# 5 Go, hébergeait un invité de 4 Go et il ne lui restait que 127 Mo de libre. +# Un parent qui ne garde pas de quoi respirer sert mal son enfant, et deux +# gibioctets d'écart plutôt qu'un lui laissent cette marge. COUTS_QEMU = Couts( - ram_par_etage=1024, + ram_par_etage=2048, disque_par_etage=6, - ram_cible=2048, + ram_cible=4096, disque_cible=20, - ram_min=1024, + ram_min=2048, disque_min=12, ) diff --git a/test/test_deep_qemu.py b/test/test_deep_qemu.py index 6e7bf99..7e1380d 100644 --- a/test/test_deep_qemu.py +++ b/test/test_deep_qemu.py @@ -308,15 +308,18 @@ class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase): self.d.niveau_courant = 2 self.d._envoyer_cli = lambda hote: True - def _machine(self, xml, adresse=" x y ipv4 10.0.0.9/24"): + def _machine(self, xml, adresse="10.0.0.9"): + """Chaque test une seule chose : l'attente du bail est bouchonnée ici, + elle a sa propre classe. Sans ce bouchon, un enfant sans adresse + faisait tourner la vraie boucle d'attente — des heures.""" + def executer(hote, cmd, delai, etiquette="", **k): if "dumpxml" in cmd: return 0, xml - if "domifaddr" in cmd: - return 0, adresse return 0, "" self.d.executer = executer + self.d.attendre_adresse = lambda parent, nom: adresse def test_an_emulated_child_is_refused(self): self._machine("x") @@ -364,7 +367,7 @@ class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase): self.assertEqual(vus, ["deep-qemu-4"]) def test_a_child_without_an_address_is_refused(self): - self._machine("", adresse=" x y N/A N/A") + self._machine("", adresse="") with contextlib.redirect_stdout(io.StringIO()) as sortie: identite, _a = self.d.creer_enfant( {"target": "p"}, @@ -376,6 +379,74 @@ class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase): self.assertIn("sans adresse", sortie.getvalue()) +class TestLeBailSeFaitAttendre(unittest.TestCase): + """Constaté au troisième étage : le domaine était créé, en type='kvm', et + « domifaddr » ne rendait rien — l'invité n'avait pas encore demandé son + bail. Plus l'étage est profond, plus il démarre lentement, et c'est + justement ce qu'on mesure. + + `deploy_qemu` attend lui-même l'adresse puis rend 0 quand il ne l'a pas + trouvée : son code de sortie ne prouve rien ici non plus.""" + + def setUp(self): + self.d = deep_qemu.Descente.__new__(deep_qemu.Descente) + self.d.dry_run = False + self.d.journal = None + self.d.niveau_courant = 3 + self.d.profondeur_racine = 0 + vrai = deep_qemu.time.sleep + deep_qemu.time.sleep = lambda _s: None + self.addCleanup(setattr, deep_qemu.time, "sleep", vrai) + + def test_it_retries_until_the_lease_appears(self): + tours = {"n": 0} + + def executer(hote, cmd, delai, etiquette="", **k): + tours["n"] += 1 + if tours["n"] < 3: + return 0, " x y N/A N/A" + return 0, " vnet0 52:54:00:aa:bb:cc ipv4 192.168.133.42/24" + + self.d.executer = executer + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual( + self.d.attendre_adresse({"target": "p"}, "deep-qemu-3"), + "192.168.133.42", + ) + self.assertEqual(tours["n"], 3) + + def test_a_single_probe_would_have_missed_it(self): + """Le contrôle qui dit pourquoi la boucle existe : au premier tour, il + n'y a rien à lire. + + Borné DANS la sonde plutôt qu'en détournant l'horloge : détourner + time.time détourne aussi celle d'unittest, et le test ne finissait + plus. Vécu il y a dix minutes. + """ + tours = {"n": 0} + + def executer(hote, cmd, delai, etiquette="", **k): + tours["n"] += 1 + if tours["n"] > 4: + raise AssertionError("sondé sans fin") + return 0, " x y N/A N/A" + + self.d.executer = executer + with contextlib.redirect_stdout(io.StringIO()): + with self.assertRaises(AssertionError): + self.d.attendre_adresse({"target": "p"}, "deep-qemu-3") + # Plusieurs tours, pas un seul : c'est tout l'objet de la boucle. + self.assertGreater(tours["n"], 1) + + def test_the_wait_grows_with_the_depth(self): + """Le budget est celui du ssh à cet étage : un invité au quatrième + démarre des dizaines de fois plus lentement qu'au premier.""" + self.d.niveau_courant = 1 + court = self.d.delai("ssh") + self.d.niveau_courant = 4 + self.assertGreater(self.d.delai("ssh"), court) + + class TestNeDetruireQueLeSien(unittest.TestCase): """« virsh undefine --remove-all-storage » efface un disque pour de bon.""" @@ -463,18 +534,36 @@ class TestLesDeuxTestsLongsSeRessemblent(unittest.TestCase): self.pve.FAMILLE.detruire_une, self.qemu.FAMILLE.detruire_une ) - def test_the_qemu_stack_asks_for_less(self): - """libvirtd seul tient dans un gibioctet là où cinq démons PVE en - demandent deux.""" + def test_the_qemu_stack_asks_for_less_disk(self): + """Une Debian avec qemu-kvm occupe ~3 Go là où un nœud Proxmox en + prend 5,6 — et l'image cloud que l'étage télécharge pour son enfant + pèse plus lourd que son propre système.""" from script.proxmox import nesting pve = nesting.nesting_plan(3, 28, 39000, 150) qemu = nesting.nesting_plan(3, 28, 39000, 150, nesting.COUTS_QEMU) - self.assertLess(qemu["niveaux"][0]["ram"], pve["niveaux"][0]["ram"]) self.assertLess( qemu["niveaux"][0]["disque"], pve["niveaux"][0]["disque"] ) + def test_every_parent_keeps_room_to_breathe(self): + """La mémoire de la pile QEMU a été DOUBLÉE après mesure, et n'est + donc plus inférieure à celle de Proxmox : à cinq étages, l'étage 2 + avait 5 Go, hébergeait un invité de 4 Go, et il ne lui restait que + 127 Mo de libre. Ce qui compte n'est pas le plancher mais l'ÉCART — + un parent qui ne respire pas sert mal son enfant.""" + from script.proxmox import nesting + + niveaux = nesting.nesting_plan(5, 28, 39000, 200, nesting.COUTS_QEMU)[ + "niveaux" + ] + for parent, enfant in zip(niveaux, niveaux[1:]): + self.assertGreaterEqual( + parent["ram"] - enfant["ram"], + 2048, + f"étage {parent['niveau']} n'a pas 2 Go de marge", + ) + if __name__ == "__main__": unittest.main() diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index d87120d..a35de96 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -405,7 +405,7 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): d.creer_enfant = creer_enfant d.ecrire_alias = lambda *a, **k: None d.attendre_ssh = lambda cible, delai, parent=None: 1 - d.redemarrer_et_verifier = lambda cible: True + d.redemarrer_et_verifier = lambda cible, parent=None, nom="": True d.remettre_debout = lambda cible: True d.preparer_systeme = lambda cible: True d.controler = lambda cible: True @@ -917,7 +917,7 @@ class TestUneVmCreeeEstToujoursNommee(unittest.TestCase): d.ecrire_alias = lambda *a, **k: None d.attendre_ssh = lambda cible, delai, parent=None: 1 d.installer = lambda cible: True - d.redemarrer_et_verifier = lambda cible: True + d.redemarrer_et_verifier = lambda cible, parent=None, nom="": True d.remettre_debout = lambda cible: True d.preparer_systeme = lambda cible: True d.controler = lambda cible: True