From 178785b90a320f5c90bda87ff1980a7242940093 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 07:48:19 -0400 Subject: [PATCH] [FIX] LongTest : garder le VMID, le code des lectures, le journal PVE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois trouvailles de la relecture adversaire, toutes de la même famille : une information qu'on possédait et qu'on jetait. Le VMID ne remontait qu'au RETOUR de creer_enfant, qui enchaîne six commandes sur le parent. Un échec à la quatrième — « qm resize » sur un stockage plein — laissait une VM allumée et un disque alloué que le rapport ne nommait nulle part : « --detruire » ne pouvait pas la défaire. preparer_parent jetait le code de retour de ses lectures. Un « ip link show » qui échoue se lisait « pas de pont », et de là on POSAIT un pont et un NAT sur une machine qui en avait déjà un. Pour une lecture, le code de retour est la seule chose qui distingue « j'ai lu, il n'y a rien » de « je n'ai pas pu lire ». reparer_pmxcfs jetait le journal des unités PVE, que pve_unit_cmd joint exprès à un échec. Il ne restait qu'un « /etc/pve : ABSENT » sans cause, à chercher sur un hyperviseur mesuré 36 fois plus lent que son hôte. Les trois meurent sous mutation, chacune avec son contrôle négatif. --- EN --- Three findings from the adversarial review, all the same family: information we already held and threw away. The VMID only surfaced on creer_enfant's RETURN, and that function chains six commands on the parent. A failure at the fourth — "qm resize" on a full storage — left a running VM and an allocated disk that the report named nowhere: "--detruire" could not undo it. preparer_parent discarded its reads' exit codes. An "ip link show" that fails read as "no bridge", and from there we CREATED a bridge and a NAT on a machine that already had one. For a read, the exit code is the only thing separating "I read it, there is nothing" from "I could not read". reparer_pmxcfs discarded the PVE units' journal, which pve_unit_cmd attaches to a failure on purpose. All that remained was "/etc/pve : ABSENT" with no cause, to be hunted on a hypervisor measured 36 times slower than its host. All three die under mutation, each with its negative control. Assisted-by: claude-opus-5 (cherry picked from commit 4c0279665e590169c23c4629e69ad945c4ae3fe4) --- LongTest/deep_proxmox.py | 73 +++++++++++-- test/test_todo_longtest.py | 214 ++++++++++++++++++++++++++++++++++++- 2 files changed, 276 insertions(+), 11 deletions(-) diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py index 17ed41a..aa3e864 100755 --- a/LongTest/deep_proxmox.py +++ b/LongTest/deep_proxmox.py @@ -327,35 +327,72 @@ class Descente: if code or "-KO" in pve.strip_ssh_noise(sortie): self.dire(f" ✗ {etiquette}") return False + # « pve_unit_cmd » joint le journal de l'unité à un échec — « la seule + # façon de dire la cause à quelqu'un dont le seul accès à l'hôte est + # cet outil », dit son propre commentaire. On le JETAIT : quand le + # montage échouait ensuite, il ne restait qu'un « /etc/pve : ABSENT » + # sans cause, et il fallait retourner sur la machine pour la chercher. + echecs = [] for unite in pve.PVE_UNITS: - self.executer( + code, sortie = self.executer( hote, pve.pve_unit_cmd(unite, remonte=True), 300, unite ) + propre = pve.strip_ssh_noise(sortie) + if code or "-KO" in propre: + echecs.append((unite, propre)) _c, out = self.executer( hote, pve.mount_wait_cmd(), self.delai("reparation"), "montage" ) vu = pve.parse_mount_wait(out) self.dire(f" /etc/pve : {vu['verdict']}") + if vu["verdict"] != "MONTE": + for unite, propre in echecs: + self.dire(f" ↳ {unite} : {propre.strip()[-400:]}") + if not echecs: + # Toutes debout et le montage absent : le dire, plutôt que de + # laisser croire qu'on n'a pas regardé. + self.dire(" ↳ toutes les unités PVE sont debout") return vu["verdict"] == "MONTE" def preparer_parent(self, parent): - """Stockage, pont et réseau interne du parent, ou None.""" - _c, out = self.executer( + """Stockage, pont et réseau interne du parent, ou None. + + Les codes de retour des LECTURES sont regardés, et c'est tout le + sujet ici. Ailleurs dans ce dépôt un code de retour ne prouve rien — + celui d'une commande distante composée est celui du dernier maillon. + Mais pour une lecture, il est la SEULE chose qui distingue « j'ai lu, + il n'y a rien » de « je n'ai pas pu lire ». + + La différence n'est pas académique : de l'absence de pont on + RECONFIGURE le réseau du parent. Un « ip link show » qui échoue — un + hoquet ssh, un sudo pas encore prêt — se lisait « pas de pont », et on + posait un pont et un NAT sur une machine qui en avait déjà un. + """ + code, out = self.executer( parent, "pvesm status --content images", DELAIS["controle"], "pvesm", ) + if code and not self.dry_run: + self.dire(" ✗ « pvesm status » a échoué : rien conclu") + return None stockage = pve.pick_storage(pve.parse_storages(out)) if not stockage and not self.dry_run: self.dire(" ✗ aucun stockage sur le parent") return None - _c, out = self.executer( + code, out = self.executer( parent, "ip -o link show type bridge", self.delai("controle"), "ponts", ) + if code and not self.dry_run: + self.dire( + " ✗ liste des ponts illisible : on ne touche PAS au" + " réseau du parent" + ) + return None ponts = pve.parse_bridges(out) if not ponts: _c, nets = self.executer( @@ -444,8 +481,16 @@ class Descente: ) return nom - def creer_enfant(self, parent, niveau, res, prepare): - """« qm create » sur le parent. Rend (vmid, adresse) ou (None, None).""" + def creer_enfant(self, parent, niveau, res, prepare, noter=None): + """« qm create » sur le parent. Rend (vmid, adresse) ou (None, None). + + `noter` reçoit le VMID AVANT la première commande qui peut créer la + VM. Sans lui, le VMID ne remontait qu'au RETOUR : une création qui + échouait à la quatrième de ses six commandes — « qm resize » sur un + stockage plein, par exemple — laissait une VM allumée et un disque + alloué que le rapport ne nommait nulle part, donc que « --detruire » + ne pouvait pas défaire. + """ stockage, pont, info_pont, dns = prepare mod = module_qemu() version = mod.DISTROS[DISTRO][1] @@ -501,6 +546,12 @@ class Descente: DELAIS["controle"], "clé", ) + # Le VMID est annoncé MAINTENANT. Un numéro noté pour une VM qui + # n'existera jamais ne coûte rien — « --detruire » lit « qm list » et + # la dit absente — alors qu'une VM créée et non notée reste sur le + # parent, invisible. + if noter: + noter(vmid) for cmd in [pve.image_fetch_cmd(url, image)] + pve.create_cmds( vmid, spec ): @@ -547,7 +598,15 @@ class Descente: self.etages.append(etage) self.interrompu = True break - vmid, adresse = self.creer_enfant(parent, niveau, res, prepare) + + def noter(numero, etage=etage, parent_alias=parent_alias): + etage["vmid"] = numero + etage["parent_alias"] = parent_alias + self._sauver(etage) + + vmid, adresse = self.creer_enfant( + parent, niveau, res, prepare, noter + ) if vmid is None: self.etages.append(etage) self.interrompu = True diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index d2d79b6..b1a724e 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -326,10 +326,14 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): appels = [] d.creer_etage1 = lambda res: "deep-pve-1" d.preparer_parent = lambda parent: {"stockage": "local-lvm"} - d.creer_enfant = lambda parent, niveau, res, prep: ( - 100 + niveau, - f"10.10.10.{niveau}", - ) + + def creer_enfant(parent, niveau, res, prep, noter=None): + # Le VRAI ordre : le VMID est annoncé AVANT que la VM existe. + if noter: + noter(100 + niveau) + return 100 + niveau, f"10.10.10.{niveau}" + + 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 @@ -557,6 +561,208 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): self.assertIn("descente tourne", sortie.getvalue()) +class TestLaCauseDUnMontageAbsent(unittest.TestCase): + """« pve_unit_cmd » joint le journal de l'unité à un échec — « la seule + façon de dire la cause à quelqu'un dont le seul accès à l'hôte est cet + outil », dit son propre commentaire dans proxmox_deploy.py. + + reparer_pmxcfs le jetait. Quand le montage échouait ensuite, il ne restait + qu'un « /etc/pve : ABSENT » sans cause, et il fallait retourner sur la + machine pour la chercher — sur un hyperviseur imbriqué mesuré 36 fois plus + lent que son hôte.""" + + def setUp(self): + sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + self.dp = deep_proxmox + self.d = deep_proxmox.Descente.__new__(deep_proxmox.Descente) + self.d.dry_run = False + self.d.journal = None + self.d.niveau_courant = 3 + self.vrai_run = deep_proxmox.pve.run + self.addCleanup(setattr, deep_proxmox.pve, "run", self.vrai_run) + deep_proxmox.pve.run = lambda h, c, t=None: ( + 0, + "10.0.0.2 22 10.0.0.1 22", + ) + + def _monter(self, verdict, unite_ko=None): + def executer(hote, cmd, delai, etiquette=""): + if etiquette == "montage": + return 0, f"MOUNT-{verdict}" + if unite_ko and etiquette == unite_ko: + return 1, "pmxcfs-KO\nquorum_initialize failed: 2" + return 0, "OK" + + self.d.executer = executer + vrai = self.dp.pve.parse_mount_wait + self.dp.pve.parse_mount_wait = lambda out: { + "verdict": "MONTE" if "MONTE" in out else "ABSENT" + } + self.addCleanup(setattr, self.dp.pve, "parse_mount_wait", vrai) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + res = self.d.reparer_pmxcfs({"target": "h", "sudo": "sudo "}) + return res, sortie.getvalue() + + def test_a_failing_unit_is_named_with_its_journal(self): + unite = self.dp.pve.PVE_UNITS[0] + res, texte = self._monter("ABSENT", unite_ko=unite) + self.assertFalse(res) + self.assertIn(unite, texte) + self.assertIn("quorum_initialize failed", texte) + + def test_all_units_up_and_still_no_mount_is_said_so(self): + # Le silence ici se lisait « on n'a pas regardé ». + res, texte = self._monter("ABSENT") + self.assertFalse(res) + self.assertIn("toutes les unités PVE sont debout", texte) + + def test_a_successful_mount_stays_quiet(self): + """Le contrôle NÉGATIF : ne pas déverser un journal quand tout va + bien. Un diagnostic qui sort toujours ne se lit plus.""" + res, texte = self._monter("MONTE", unite_ko=self.dp.pve.PVE_UNITS[0]) + self.assertTrue(res) + self.assertNotIn("↳", texte) + + +class TestUneLectureRateeNeConclutRien(unittest.TestCase): + """De l'absence de pont, preparer_parent RECONFIGURE le réseau du parent. + + Les codes de retour des lectures étaient jetés. Un « ip link show » qui + échoue — hoquet ssh, sudo pas encore prêt — se lisait « pas de pont », et + on posait un pont et un NAT sur une machine qui en avait déjà un.""" + + def setUp(self): + sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + self.dp = deep_proxmox + self.d = deep_proxmox.Descente.__new__(deep_proxmox.Descente) + self.d.dry_run = False + self.d.journal = None + self.d.niveau_courant = 2 + + def _descente_qui_lit(self, reponses): + """`reponses` : [(code, sortie)] rendus dans l'ordre des lectures.""" + faites = [] + + def executer(hote, cmd, delai, etiquette=""): + faites.append(etiquette or cmd[:20]) + return reponses[len(faites) - 1] if reponses else (0, "") + + self.d.executer = executer + return faites + + def test_an_unreadable_bridge_list_touches_nothing(self): + faites = self._descente_qui_lit( + [ + (0, "local-lvm lvmthin active 1 1 1 1.00%"), + (255, ""), # « ip link show » : échec de transport + ] + ) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertIsNone(self.d.preparer_parent({"target": "p"})) + # Rien après la lecture ratée : pas de USED_NETS_CMD, pas de « pont ». + self.assertEqual(faites, ["pvesm", "ponts"]) + self.assertIn("on ne touche PAS au réseau", sortie.getvalue()) + + def test_an_empty_but_successful_read_does_create_the_bridge(self): + """Le contrôle NÉGATIF. Sans lui, ce garde-fou interdirait la seule + chose que preparer_parent est là pour faire.""" + faites = self._descente_qui_lit( + [ + (0, "local-lvm lvmthin active 1 1 1 1.00%"), + (0, ""), # lu, et il n'y a vraiment aucun pont + (0, ""), # USED_NETS_CMD + (0, "default via 10.0.0.1 dev eth0"), + ] + + [(0, "")] * 12 + ) + with contextlib.redirect_stdout(io.StringIO()): + self.d.preparer_parent({"target": "p"}) + self.assertIn("réseaux", faites) + self.assertIn("pont", faites) + + def test_an_unreadable_storage_list_is_named_as_such(self): + faites = self._descente_qui_lit([(255, "")]) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertIsNone(self.d.preparer_parent({"target": "p"})) + self.assertEqual(faites, ["pvesm"]) + texte = sortie.getvalue() + self.assertIn("a échoué", texte) + # Et NON « aucun stockage » : ce serait imputer au parent un défaut + # qu'on n'a pas constaté. + self.assertNotIn("aucun stockage", texte) + + +class TestUneVmCreeeEstToujoursNommee(unittest.TestCase): + """Le VMID ne remontait qu'au RETOUR de creer_enfant. + + Or celle-ci enchaîne six commandes sur le parent. Un échec à la quatrième + — « qm resize » sur un stockage plein — laissait une VM allumée et un + disque alloué que le rapport ne nommait nulle part : « --detruire » ne + pouvait pas la défaire, et il fallait la retrouver par son NOM.""" + + def setUp(self): + sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + self.dp = deep_proxmox + self.dossier = tempfile.mkdtemp(prefix="longtest-vmid-") + self.addCleanup(shutil.rmtree, self.dossier, ignore_errors=True) + + def test_a_creation_that_dies_midway_still_names_the_vm(self): + niveaux = [ + {"niveau": n, "vcpu": 2, "ram": 4096, "disque": 25} for n in (1, 2) + ] + chemin = os.path.join(self.dossier, "rapport.json") + d = self.dp.Descente( + {"demandee": 2, "atteignable": 2, "niveaux": niveaux}, + None, + False, + chemin, + ) + d.creer_etage1 = lambda res: "deep-pve-1" + d.preparer_parent = lambda parent: ( + "local-lvm", + "vmbr1", + {}, + "1.1.1.1", + ) + d.ecrire_alias = lambda *a, **k: None + d.attendre_ssh = lambda cible, delai, parent=None: 1 + d.installer_proxmox = lambda cible: True + d.redemarrer_et_verifier = lambda cible: True + d.reparer_pmxcfs = lambda cible: True + + # La création note son VMID, puis MEURT — comme « qm resize » sur un + # stockage plein. + def creer_enfant(parent, niveau, res, prep, noter=None): + noter(142) + return None, None + + d.creer_enfant = creer_enfant + with contextlib.redirect_stdout(io.StringIO()): + d.parcourir() + with open(chemin, encoding="utf-8") as fh: + rapport = json.load(fh) + self.assertEqual( + self.dp.a_defaire(rapport), + [(2, "deep-pve-1", 142, self.dp.nom_etage(2))], + ) + + def test_the_vmid_is_announced_before_the_creating_commands(self): + """Par l'ORDRE, pas par le résultat : noter après la première commande + laisserait déjà passer un « qm create » réussi suivi d'un échec.""" + import inspect + + src = inspect.getsource(self.dp.Descente.creer_enfant) + i_noter = src.index("noter(vmid)") + i_boucle = src.index("for cmd in [pve.image_fetch_cmd") + self.assertLess(i_noter, i_boucle) + + class TestNePasAttendreUneMaisonDisparue(unittest.TestCase): """L'étage 1 a redémarré pendant l'installation de l'étage 4, éteignant les étages 2, 3 et 4 d'un coup.