[FIX] long_test : bail attendu, rallumage à froid, marge mémoire

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)
This commit is contained in:
Mathieu Benoit 2026-08-28 06:05:59 -04:00
parent 5af6c94c0a
commit 6e6e99c304
5 changed files with 193 additions and 22 deletions

View file

@ -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

View file

@ -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)),

View file

@ -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,
)

View file

@ -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("<domain type='qemu' id='1'><name>x</name>")
@ -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("<domain type='kvm'>", adresse=" x y N/A N/A")
self._machine("<domain type='kvm'>", 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()

View file

@ -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