From 4d26155901c2b6f0da4685cc078657bc2b00437e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 07:32:38 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20imbrication=20:=20la=20ressource=20qui?= =?UTF-8?q?=20borne,=20l'attente,=20le=20d=C3=A9compte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incident sur une descente réelle : un agent de relecture, chargé de vérifier ce que redemarrer_et_verifier PROUVE, l'a appelé sur l'étage 1 vivant. Le reboot a éteint les étages 2, 3 et 4 d'un coup. La descente a alors attendu son délai entier — quarante minutes — un ssh qui ne pouvait plus aboutir, puis a conclu « jamais joignable ». L'attente surveille désormais la MAISON. Le décompte de la destruction mentait dans l'autre sens : les étages injoignables étaient annoncés « il reste des machines » alors que le disque de l'étage 1, effacé, les contenait. Les entrées ~/.ssh/config sont retirées aussi, sinon leur ProxyJump désigne un hôte disparu. Et « arret » nommait la RAM quand le vCPU bornait : la chaîne était figée en ram > disque > vcpu et évaluée à la profondeur demandée. Il nomme maintenant le plus bas des trois plafonds, et les trois sont affichés. --- EN --- Incident on a real descent: a review agent, tasked with checking what redemarrer_et_verifier PROVES, called it on the living level 1. The reboot took levels 2, 3 and 4 down at once. The descent then waited its whole timeout — forty minutes — for an ssh that could no longer land, and concluded "never reachable". The wait now watches the HOUSE. The destroy count lied the other way: unreachable levels were reported as "il reste des machines" when level 1's erased disk contained them. The ~/.ssh/config entries are removed too, else their ProxyJump names a host that is gone. And "arret" named RAM when vCPU was the bound: the chain was frozen as ram > disque > vcpu and evaluated at the requested depth. It now names the lowest of the three ceilings, and all three are shown. Assisted-by: claude-opus-5 (cherry picked from commit 2d84b62bdd9907e9a30383774015bcb1ae379de1) --- LongTest/deep_proxmox.py | 103 ++++++++++++++++++++--- script/proxmox/nesting.py | 80 +++++++++++------- test/test_proxmox_nesting.py | 44 +++++++++- test/test_todo_longtest.py | 155 +++++++++++++++++++++++++++++++++-- 4 files changed, 333 insertions(+), 49 deletions(-) diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py index 76fbcdb..17ed41a 100755 --- a/LongTest/deep_proxmox.py +++ b/LongTest/deep_proxmox.py @@ -180,12 +180,20 @@ class Descente: self.dire(f" {ligne}") return code, sortie - def attendre_ssh(self, hote, delai): + def attendre_ssh(self, hote, delai, parent=None): """Attend que la machine réponde. Rend les secondes, ou None. Des connexions COURTES successives : cloud-init régénère les clés d'hôte et redémarre sshd au premier démarrage, ce qui tuerait une session longue. + + `parent` : si l'hôte qui HÉBERGE la machine attendue cesse de + répondre, on abandonne tout de suite. Constaté : l'étage 1 a redémarré + pendant l'installation de l'étage 4, ce qui a éteint les étages 2, 3 et + 4 d'un coup ; la descente a attendu son délai entier — quarante + minutes — un ssh qui ne pouvait plus aboutir, puis a rendu « jamais + joignable en ssh ». Le diagnostic était faux : la machine n'était pas + lente, sa MAISON n'existait plus. """ if self.dry_run: return 0 @@ -196,10 +204,19 @@ class Descente: # joignable en ssh ». Le transport marchait ; c'est le diagnostic qui # était faux. sonde = dict(hote, sudo="") + sonde_parent = dict(parent, sudo="") if parent else None while time.time() - debut < delai: code, _o = pve.run(sonde, "true", 60) if code == 0: return int(time.time() - debut) + if sonde_parent is not None: + code_parent, _p = pve.run(sonde_parent, "true", 60) + if code_parent != 0: + self.dire( + f" ✗ l'hôte {sonde_parent['target']} ne répond" + " plus : l'attente n'aboutira pas" + ) + return None time.sleep(15) return None @@ -549,7 +566,7 @@ class Descente: etage["alias"] = alias etage["etape"] = "ssh" - attente = self.attendre_ssh(cible, self.delai("ssh")) + attente = self.attendre_ssh(cible, self.delai("ssh"), parent) if attente is None: self.dire(" ✗ jamais joignable en ssh") self.etages.append(etage) @@ -653,6 +670,19 @@ class Descente: f" profondeur atteinte : {atteint}" f" / {self.plan['demandee']}" ) + # Deux causes très différentes rendaient le même « 5 / 10 » : la + # machine trop petite pour dix, ou un étage tombé en route. La + # première n'est pas un défaut du code, la seconde si. + if atteint == self.plan["atteignable"] < self.plan["demandee"]: + self.dire( + f" (plan borné à {self.plan['atteignable']} par le" + f" {self.plan['arret']} : tout le plan a tenu)" + ) + elif atteint < self.plan["atteignable"]: + self.dire( + f" (le plan annonçait {self.plan['atteignable']} :" + " un étage est tombé, voir plus haut)" + ) for e in self.etages: if self.dry_run: marque, detail = "·", "plan" @@ -947,18 +977,64 @@ def detruire(journal=None, dry_run=False): # machines » et sortait 1, si bien que le seul avertissement censé # prévenir qu'un disque de plusieurs dizaines de Go reste alloué # s'affichait toujours, et qu'on apprenait à ne plus le lire. - if detruire_etage1(journal): + racine = detruire_etage1(journal) + if racine: faits += 1 + retirer_alias(rapport, journal) + if racine: + # L'étage 1 est un DISQUE, et tout le reste vit dedans. « virsh + # undefine --remove-all-storage » l'a effacé : les étages injoignables + # — leur parent était éteint — ont disparu avec, qu'on ait pu leur + # parler ou non. Annoncer « il reste des machines » dans ce cas était + # faux dans l'autre sens, et un avertissement faux ne se lit plus. + reste = len(liste) + 1 - faits + dire( + f"\n {len(liste) + 1} / {len(liste) + 1} défait(s)." + + ( + f" ({reste} injoignable(s), emporté(s) avec le disque de" + " l'étage 1.)" + if reste + else "" + ), + journal, + ) + return 0 dire( f"\n {faits} / {len(liste) + 1} défait(s)." - + ( - "" - if faits == len(liste) + 1 - else " ⚠ il reste des machines : voir plus haut." - ), + " ⚠ l'étage 1 est DEBOUT : ce qu'il contient vit encore.", journal, ) - return 0 if faits == len(liste) + 1 else 1 + return 1 + + +def retirer_alias(rapport, journal=None): + """Retire de ~/.ssh/config les entrées de la descente défaite. + + Sans cela, elles survivaient aux machines : des entrées mortes dont le + ProxyJump désigne un hôte qui n'existe plus, et qu'on retrouve plus tard + sans savoir à quoi elles servaient. + """ + alias = [] + for etage in rapport.get("etages") or []: + nom = etage.get("alias") + if not nom: + # L'étage abandonné avant l'écriture de son alias : le calculer, + # il est déterminé par (niveau, alias du parent). + parent = etage.get("parent_alias") + if parent: + nom = alias_etage(int(etage["niveau"]), parent) + if nom and nom not in alias: + alias.append(nom) + if not alias: + return + try: + from script.todo.todo import TODO + + TODO.__new__(TODO)._write_ssh_config_entry( + [], "erplibre", "", also_drop=tuple(alias) + ) + except Exception as err: # noqa: BLE001 - jamais bloquer la destruction + dire(f" ⚠ entrées ~/.ssh/config non retirées : {err}", journal) def principal(argv=None): @@ -992,9 +1068,16 @@ def principal(argv=None): f" {n['disque']:>5} Go" ) if plan["arret"]: + # Les TROIS plafonds, pas seulement celui qui borne : sans eux on + # ajoute la ressource nommée sans savoir de combien, ni laquelle + # bornera ensuite. + plafonds = " · ".join( + f"{nom} {valeur}" for nom, valeur in plan["plafonds"].items() + ) print( f"\n ⚠ demandée {plan['demandee']}, atteignable" - f" {plan['atteignable']} — manque de {plan['arret']}" + f" {plan['atteignable']} — c'est le {plan['arret']} qui borne" + f"\n profondeur permise par chaque ressource : {plafonds}" ) if not plan["niveaux"]: if args.depth < 1: diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index dd8595e..fa3714f 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -90,9 +90,19 @@ DISQUE_MIN_GO = 15 # a deux, son parent trois, et ainsi de suite. Le premier étage d'une descente # à dix en demande onze — sur vingt-huit cœurs réels, cela passe. VCPU_IMBRIQUE = 2 -# Ce qu'on accepte de prendre à la machine physique : la moitié de ses cœurs. -# L'orchestrateur tourne dessus, et la suite de tests aussi. -VCPU_HOTE_PART = 2 +# Ce qu'on LAISSE à la machine physique. L'orchestrateur tourne dessus, son +# ssh vers chaque étage aussi, et la suite de tests avec. +# +# Un nombre fixe, et non une fraction : « la moitié des cœurs » gardait +# quatorze cœurs inutilisés sur vingt-huit, et sur une machine à deux cœurs le +# plancher qui l'accompagnait rendait un budget de deux — soit la machine +# entière, hôte compris. +# +# Deux, et pas plus : l'orchestrateur passe son temps à ATTENDRE du ssh, il ne +# calcule rien. Quatre auraient interdit toute descente sur un hôte à quatre +# cœurs, où un étage tient très bien. Sur une machine trop petite le plan rend +# franchement zéro étage plutôt que de surengager l'hôte. +HOTE_RESERVE_VCPU = 2 # Au-delà, l'imbrication n'est pas un terrain documenté par les fabricants. # On ne refuse pas — on le DIT. @@ -107,9 +117,18 @@ def nesting_plan( ) -> dict: """Les ressources de chaque étage, dimensionnées DEPUIS LE BAS. - Rend {"demandee", "atteignable", "niveaux": [...], "arret"}. `arret` nomme - ce qui a manqué — « ram » ou « disque » — quand la profondeur demandée - n'est pas atteinte, sinon "". + Rend {"demandee", "atteignable", "niveaux", "arret", "plafonds"}. + + `arret` nomme la ressource qui BORNE réellement la profondeur — "ram", + "disque" ou "vcpu" — et "" si la profondeur demandée tient. `plafonds` + donne les trois profondeurs, une par ressource, pour qu'on puisse voir + d'un coup ce qu'il faudrait ajouter et de combien. + + Nommer la bonne, c'est le sujet : la version d'avant prenait la première + d'une chaîne figée ram > disque > vcpu, évaluée à la profondeur DEMANDÉE. + Sur une machine à deux cœurs et 20 Go, elle annonçait « manque de ram » + quand le processeur bornait à un seul étage ; l'opérateur doublait la + mémoire et n'y gagnait pas un étage. Depuis le bas, et c'est tout le sujet. De haut en bas, chaque étage recevait ce que son parent pouvait céder : mesuré, l'étage 4 se retrouvait @@ -144,26 +163,25 @@ def nesting_plan( VCPU_IMBRIQUE + d - 1, ) - budget_vcpu = max(VCPU_IMBRIQUE, int(cpu_hote) // VCPU_HOTE_PART) - atteignable, arret = 0, "" - for d in range(max(0, int(profondeur)), 0, -1): - ram1, disque1, vcpu1 = besoin(d) - if ( - ram1 <= budget_ram - and disque1 <= budget_disque - and vcpu1 <= budget_vcpu - ): - atteignable = d - break - if atteignable < int(profondeur): - # Nommer CE qui a manqué, à la profondeur demandée. - ram1, disque1, vcpu1 = besoin(max(1, int(profondeur))) - if ram1 > budget_ram: - arret = "ram" - elif disque1 > budget_disque: - arret = "disque" - else: - arret = "vcpu" + budget_vcpu = int(cpu_hote) - HOTE_RESERVE_VCPU + # La profondeur que chaque budget permet À LUI SEUL. C'est de l'inverse de + # `besoin` : un balayage décroissant donnait le même résultat, mais son + # coût suivait la profondeur demandée — nesting_plan(10**6, …) tournait un + # million de tours pour rendre le même plan. + plafonds = { + "ram": (budget_ram - PVE_RAM_CIBLE_MO) // PVE_RAM_MO + 1, + "disque": (budget_disque - PVE_DISQUE_CIBLE_GO) // PVE_DISQUE_GO + 1, + "vcpu": budget_vcpu - VCPU_IMBRIQUE + 1, + } + plafonds = {nom: max(0, valeur) for nom, valeur in plafonds.items()} + demandee = max(0, int(profondeur)) + atteignable = min(demandee, *plafonds.values()) + arret = "" + if atteignable < demandee: + # La ressource qui BORNE, c'est-à-dire celle dont le plafond est le + # plus bas — pas la première d'un ordre figé. En ajouter une autre ne + # ferait pas monter la profondeur d'un seul étage. + arret = min(plafonds, key=lambda nom: (plafonds[nom], nom)) niveaux = [ { "niveau": niveau, @@ -171,8 +189,13 @@ def nesting_plan( # enfant, c'est cent pour cent de surengagement — et l'hyperviseur # à servir en plus. "vcpu": VCPU_IMBRIQUE + (atteignable - niveau), - "ram": PVE_RAM_CIBLE_MO + (atteignable - niveau) * PVE_RAM_MO, - "disque": PVE_DISQUE_CIBLE_GO + # Les planchers ne sont pas décoratifs : ils tiennent même si + # quelqu'un baisse une CIBLE un jour. Sans eux, ils n'étaient plus + # lus par personne et les tests qui les vérifiaient passaient + # d'eux-mêmes. + "ram": max(RAM_MIN_MO, PVE_RAM_CIBLE_MO) + + (atteignable - niveau) * PVE_RAM_MO, + "disque": max(DISQUE_MIN_GO, PVE_DISQUE_CIBLE_GO) + (atteignable - niveau) * PVE_DISQUE_GO, } for niveau in range(1, atteignable + 1) @@ -182,6 +205,7 @@ def nesting_plan( "atteignable": atteignable, "niveaux": niveaux, "arret": arret, + "plafonds": plafonds, } diff --git a/test/test_proxmox_nesting.py b/test/test_proxmox_nesting.py index 72f9271..8a20fd0 100644 --- a/test/test_proxmox_nesting.py +++ b/test/test_proxmox_nesting.py @@ -86,11 +86,51 @@ class TestLePlanDesEtages(unittest.TestCase): ) self.assertEqual(plan["arret"], "vcpu") self.assertLess(plan["atteignable"], 10) - # Et le premier étage ne dépasse pas la part concédée à l'hôte. + # Et le premier étage laisse à l'hôte ce qui lui est réservé. self.assertLessEqual( - plan["niveaux"][0]["vcpu"], 8 // nesting.VCPU_HOTE_PART + plan["niveaux"][0]["vcpu"], 8 - nesting.HOTE_RESERVE_VCPU ) + def test_the_named_resource_is_the_one_that_really_binds(self): + """La version d'avant prenait la première d'une chaîne figée + ram > disque > vcpu, évaluée à la profondeur DEMANDÉE. Sur deux cœurs + et 20 Go elle annonçait « manque de ram » quand le processeur bornait à + zéro étage : l'opérateur doublait la mémoire et n'y gagnait rien.""" + for cpu, ram, disque, attendu in ( + (2, 20000, 5000, "vcpu"), + (2, 200000, 100, "vcpu"), + (4, 16384, 200, "vcpu"), + (28, 12288, 500, "ram"), + (28, 200000, 60, "disque"), + ): + with self.subTest(cpu=cpu, ram=ram, disque=disque): + plan = nesting.nesting_plan(10, cpu, ram, disque) + self.assertEqual(plan["arret"], attendu) + # Et c'est bien le plus BAS des trois plafonds. + self.assertEqual( + plan["plafonds"][attendu], min(plan["plafonds"].values()) + ) + self.assertEqual( + plan["atteignable"], min(10, *plan["plafonds"].values()) + ) + + def test_doubling_the_named_resource_gains_a_level(self): + """L'épreuve utile du diagnostic : ce qu'il nomme, ajouté, PAIE.""" + base = dict(cpu_hote=28, ram_dispo_mo=12288, disque_libre_go=500) + avant = nesting.nesting_plan(10, **base) + self.assertEqual(avant["arret"], "ram") + apres = nesting.nesting_plan( + 10, **{**base, "ram_dispo_mo": base["ram_dispo_mo"] * 2} + ) + self.assertGreater(apres["atteignable"], avant["atteignable"]) + + def test_a_huge_depth_costs_nothing(self): + # Le balayage décroissant tournait autant de tours que la profondeur + # demandée pour rendre exactement le même plan. + plan = nesting.nesting_plan(10**6, 28, 58000, 165) + self.assertEqual(plan["atteignable"], min(plan["plafonds"].values())) + self.assertEqual(len(plan["niveaux"]), plan["atteignable"]) + def test_running_out_of_ram_is_named(self): plan = nesting.nesting_plan( 10, cpu_hote=28, ram_dispo_mo=12288, disque_libre_go=500 diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 0e7e043..d2d79b6 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -331,7 +331,7 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): f"10.10.10.{niveau}", ) d.ecrire_alias = lambda *a, **k: None - d.attendre_ssh = lambda cible, delai: 1 + d.attendre_ssh = lambda cible, delai, parent=None: 1 d.redemarrer_et_verifier = lambda cible: True d.reparer_pmxcfs = lambda cible: True @@ -557,6 +557,89 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): self.assertIn("descente tourne", sortie.getvalue()) +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. + + La descente a attendu son délai entier — quarante minutes — un ssh qui ne + pouvait plus aboutir, puis a conclu « jamais joignable en ssh ». Le + diagnostic était faux : la machine n'était pas lente, sa MAISON n'existait + plus.""" + + def setUp(self): + sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + self.dp = deep_proxmox + self.vrai_run = deep_proxmox.pve.run + self.addCleanup(setattr, deep_proxmox.pve, "run", self.vrai_run) + self.d = deep_proxmox.Descente.__new__(deep_proxmox.Descente) + self.d.dry_run = False + self.d.journal = None + + def _cibles(self): + return ( + {"target": "enfant", "sudo": "sudo ", "jump": ""}, + {"target": "parent", "sudo": "sudo ", "jump": ""}, + ) + + def test_it_gives_up_as_soon_as_the_parent_stops_answering(self): + appels = [] + + def faux(hote, cmd, timeout=None): + appels.append(hote["target"]) + # Une borne DURE : si le garde-fou disparaissait, la boucle + # sonderait l'enfant jusqu'à l'expiration du délai. On la fait + # éclater au troisième tour plutôt que de laisser le test tourner + # — et ce test-ci doit échouer vite quand le code régresse. + if appels.count("enfant") > 2: + raise AssertionError(f"sondé sans fin : {appels[:6]}") + return 255, "" + + self.dp.pve.run = faux + enfant, parent = self._cibles() + vrai_sleep = time.sleep + time.sleep = lambda _s: None + self.addCleanup(setattr, time, "sleep", vrai_sleep) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + res = self.d.attendre_ssh(enfant, 45, parent) + self.assertIsNone(res) + # DEUX sondes, et c'est tout : l'enfant, puis sa maison. Sans le + # garde-fou la liste comptait autant d'« enfant » que le délai le + # permet, et la descente attendait pour rien. + self.assertEqual(appels, ["enfant", "parent"]) + self.assertIn("ne répond plus", sortie.getvalue()) + + def test_a_slow_child_with_a_living_parent_is_still_waited_for(self): + """Le contrôle NÉGATIF : un étage lent n'est pas un étage mort. Sans + lui, ce garde-fou abandonnerait toute descente profonde.""" + etat = {"tours": 0} + + def faux(hote, cmd, timeout=None): + if hote["target"] == "parent": + return 0, "" # la maison tient + etat["tours"] += 1 + return (0, "") if etat["tours"] >= 3 else (255, "") + + self.dp.pve.run = faux + self.dp.time = time # même horloge + enfant, parent = self._cibles() + vrai_sleep = time.sleep + time.sleep = lambda _s: None + self.addCleanup(setattr, time, "sleep", vrai_sleep) + with contextlib.redirect_stdout(io.StringIO()): + res = self.d.attendre_ssh(enfant, 3600, parent) + self.assertIsNotNone(res) + self.assertEqual(etat["tours"], 3) + + def test_without_a_parent_the_behaviour_is_unchanged(self): + # L'étage 1 n'a pas de parent : il tourne sur du métal. + self.dp.pve.run = lambda hote, cmd, timeout=None: (0, "") + enfant, _ = self._cibles() + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.d.attendre_ssh(enfant, 60), 0) + + class TestLeDecompteDeLaDestruction(unittest.TestCase): """« if not detruire_etage1(…) : faits -= 1 » — un succès de l'étage 1 n'ajoutait RIEN, alors que le total est len(liste) + 1. @@ -578,9 +661,11 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): "dernier_rapport", "detruire_une", "detruire_etage1", + "retirer_alias", ) } deep_proxmox.autre_deep_proxmox = lambda: [] + deep_proxmox.retirer_alias = lambda *a, **k: None deep_proxmox.dernier_rapport = lambda: { "fichier": "/x.json", "etages": [ @@ -601,11 +686,11 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): builtins.input = self._entree - def _lancer(self, etage1_ok): + def _lancer(self, etage1_ok, une=True): import builtins builtins.input = lambda _prompt="": "OUI" - self.dp.detruire_une = lambda *a, **k: True + self.dp.detruire_une = lambda *a, **k: une self.dp.detruire_etage1 = lambda *a, **k: etage1_ok with contextlib.redirect_stdout(io.StringIO()) as sortie: code = self.dp.detruire(None, dry_run=False) @@ -615,15 +700,67 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): code, texte = self._lancer(etage1_ok=True) self.assertEqual(code, 0) self.assertIn("3 / 3", texte) - self.assertNotIn("il reste des machines", texte) + self.assertNotIn("⚠", texte) - def test_a_surviving_level_one_is_still_warned_about(self): - # L'avertissement doit rester CRÉDIBLE : il ne sort que quand il a - # quelque chose à dire. + def test_unreachable_levels_go_with_the_root_disk(self): + """Mesuré sur un arbre réel : les étages 3 et 4 étaient injoignables + — leur parent était éteint — et le compte disait « 2 / 4, il reste des + machines ». Or « virsh undefine --remove-all-storage » sur l'étage 1 + efface le disque où ils VIVENT. L'avertissement était faux dans + l'autre sens, et un avertissement faux ne se lit plus.""" + self.dp.detruire_une = lambda *a, **k: False + code, texte = self._lancer(etage1_ok=True, une=False) + self.assertEqual(code, 0) + self.assertIn("3 / 3", texte) + self.assertIn("emporté(s) avec le disque de l'étage 1", texte) + + def test_a_surviving_level_one_is_the_only_real_warning(self): + # Là, et là seulement, quelque chose vit encore : le disque est + # debout, et tout ce qu'il contient avec lui. code, texte = self._lancer(etage1_ok=False) self.assertEqual(code, 1) - self.assertIn("2 / 3", texte) - self.assertIn("il reste des machines", texte) + self.assertIn("l'étage 1 est DEBOUT", texte) + + def test_the_ssh_aliases_of_a_destroyed_descent_are_removed(self): + """Elles survivaient aux machines : des entrées mortes dont le + ProxyJump désigne un hôte qui n'existe plus.""" + retires = [] + self.dp.retirer_alias = lambda rapport, journal=None: retires.append( + [e.get("alias") for e in rapport["etages"]] + ) + self._lancer(etage1_ok=True) + self.assertEqual(len(retires), 1) + + def test_the_aliases_are_computed_when_the_report_lacks_them(self): + """Un étage abandonné avant l'écriture de son alias en a tout de même + un : il est déterminé par (niveau, alias du parent).""" + vus = {} + import script.todo.todo as module_todo + + vrai = module_todo.TODO._write_ssh_config_entry + + def espion(self, host, user, ip, **kw): + vus["drop"] = kw.get("also_drop") + + module_todo.TODO._write_ssh_config_entry = espion + self.addCleanup( + setattr, module_todo.TODO, "_write_ssh_config_entry", vrai + ) + with contextlib.redirect_stdout(io.StringIO()): + # La VRAIE fonction : setUp en a posé un bouchon pour les autres + # tests de cette classe. + self._vrais["retirer_alias"]( + { + "etages": [ + {"niveau": 1, "alias": "deep-pve-1"}, + {"niveau": 2, "parent_alias": "deep-pve-1"}, + ] + } + ) + self.assertEqual( + vus["drop"], + ("deep-pve-1", self.dp.alias_etage(2, "deep-pve-1")), + ) class TestLeMenu(unittest.TestCase):