From 032556544e030fe23be9c8bfb4560ed0430b1618 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 03:29:01 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20long=5Ftest=20:=20partir=20d'un=20h?= =?UTF-8?q?=C3=B4te=20existant,=20et=20le=20menu=20des=20deux=20piles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Créer une VM de tête pour héberger un hyperviseur qu'on possède déjà coûte cinq minutes ET un étage d'imbrication — donc de la lenteur, puisque c'est elle qu'on mesure. « --hote » part d'un hôte existant ; le menu le propose sans le rechercher, l'hôte Proxmox déjà retenu étant lu par _pve_host(ask=False). Trois conséquences que le code ne tirait pas : - le plan se dimensionne sur la RACINE, lue par ssh. Le dimensionner sur la machine locale quand les étages vivent ailleurs annoncerait des étages qui ne tiennent pas ; - les délais comptent la profondeur ABSOLUE. Un enfant de niveau 1 posé dans une racine déjà au troisième étage est en réalité au quatrième, et héritait de délais quatre fois trop courts — le défaut même que « delai » raconte avoir corrigé ; - la racine n'est pas un étage atteint. L'y compter décalait de un le total et le code de sortie ; elle va dans une clé à part, et jamais « cree ». « sudo » est DÉDUIT de « id -u » et non supposé, et une racine illisible fait renoncer au lieu d'inventer une capacité. Le menu offre les deux piles et défait chacune séparément — elles partagent le dossier des rapports mais chacune ne connaît que les siens. Un test vérifie que toute entrée affichée a son branchement : ils sont couplés par position, sans garde. 80 tests, quatre garde-fous morts sous mutation. --- EN --- Creating a head VM to host a hypervisor you already own costs five minutes AND one level of nesting — that is, slowness, which is the very thing being measured. "--hote" starts from an existing host; the menu offers it without searching, reading the already-chosen Proxmox host via _pve_host(ask=False). Three consequences the code did not draw: - the plan is sized on the ROOT, read over ssh. Sizing it on the local machine while the levels live elsewhere would announce levels that do not fit; - delays count ABSOLUTE depth. A level-1 child placed in a root already at the third level is really at the fourth, and inherited delays four times too short — the very defect "delai" recounts having fixed; - the root is not a level reached. Counting it shifted the total and the exit code by one; it goes in its own key, and never as "cree". "sudo" is DEDUCED from "id -u" rather than assumed, and an unreadable root makes us give up instead of inventing a capacity. The menu offers both stacks and undoes each separately — they share the report directory but each knows only its own. A test checks that every displayed entry has its branch: they are coupled by position, with no guard. 80 tests, four guards die under mutation. Assisted-by: claude-opus-5 (cherry picked from commit c2ab1a968346458925f55fc95619eb4ebd9d3efa) --- long_test/descente.py | 174 ++++++++++++++++++++++++++++-- script/todo/longtest_menu.py | 121 +++++++++++++++++---- script/todo/todo_i18n.py | 32 ++++++ test/test_todo_longtest.py | 198 +++++++++++++++++++++++++++++++++++ 4 files changed, 498 insertions(+), 27 deletions(-) diff --git a/long_test/descente.py b/long_test/descente.py index 827c8a5..eb34f07 100644 --- a/long_test/descente.py +++ b/long_test/descente.py @@ -85,6 +85,50 @@ def capacite_hote(): return coeurs, ram, disque +CAPACITE_CMD = ( + "nproc | sed s/^/COEURS=/; " + "grep MemAvailable /proc/meminfo | sed s/^/MEM=/; " + "df --output=avail -BG /var/lib/libvirt/images 2>/dev/null" + " | tail -1 | sed s/^/DISQUE=/" +) + + +def parse_capacite(texte): + """(cœurs, RAM en Mo, disque en Go) lus chez l'hôte, ou (0, 0, 0). + + Zéro quand la ligne manque, jamais une valeur inventée : un plan + dimensionné sur une capacité supposée annoncerait des étages qui ne + tiennent pas. + """ + propre = pve.strip_ssh_noise(texte or "") + + def lire(motif, diviseur=1): + trouve = re.search(motif, propre, re.M) + return int(trouve.group(1)) // diviseur if trouve else 0 + + return ( + lire(r"^COEURS=\s*(\d+)"), + # « MEM=MemAvailable: 7056288 kB » : le sed colle un « = », pas un + # deux-points. L'expression attendait le second et rendait zéro — un + # plan dimensionné sur zéro mébioctet n'annonce aucun étage. + lire(r"^MEM=MemAvailable:\s*(\d+)", 1024), + lire(r"^DISQUE=\s*(\d+)"), + ) + + +def capacite_distante(hote): + """Ce dont dispose la RACINE quand la descente en emprunte une. + + `capacite_hote()` lit la machine LOCALE. Partir d'un hôte distant sans + lire le sien dimensionnerait le plan d'après une machine qui n'héberge + rien — on annoncerait dix étages sur un serveur qui n'en porte pas deux. + """ + code, out = pve.run(dict(hote, sudo=""), CAPACITE_CMD, 120) + if code: + return 0, 0, 0 + return parse_capacite(out) + + def module_qemu(): """deploy_qemu.py chargé comme module : il porte le catalogue d'images.""" import importlib.util @@ -126,6 +170,12 @@ class Descente: demande à la pile qui en hérite — les six crochets plus bas. """ + # Une descente sans racine part d'une VM qu'elle crée elle-même : c'est + # le cas ordinaire, et ces défauts le disent au niveau de la CLASSE plutôt + # que du constructeur, pour qu'une instance montée à la main les ait aussi. + racine = None + profondeur_racine = 0 + # Ce que chaque pile déclare. OUTIL = "" # « deep_proxmox » : écrit au rapport, filtre --detruire NOM_BASE = "" # « deep-pve » : préfixe des noms de machines @@ -175,7 +225,25 @@ class Descente: def alias_etage(self, niveau, parent_alias): return alias_etage(niveau, parent_alias, self.NOM_BASE) - def __init__(self, plan, journal, dry_run=False, chemin_json=None): + def __init__( + self, + plan, + journal, + dry_run=False, + chemin_json=None, + racine=None, + profondeur_racine=0, + ): + # `racine` : un hôte qui EXISTE DÉJÀ, chez qui la descente s'installe + # au lieu de créer sa propre machine de tête. Il n'est pas un étage — + # il n'est pas compté, pas détruit, et son entrée ~/.ssh/config est + # celle de l'utilisateur. + self.racine = racine + # Sa profondeur d'imbrication à LUI. Sans elle, le premier enfant + # d'une racine déjà au troisième étage héritait des délais du premier : + # quatre fois trop courts, exactement le défaut que `delai` raconte + # avoir corrigé. + self.profondeur_racine = profondeur_racine self.plan = plan self.journal = journal self.chemin_json = chemin_json @@ -200,7 +268,11 @@ class Descente: d'hyperviseur à traverser, mais un facteur illimité rendrait un échec réel indiscernable d'une attente sans fin. """ - facteur = min(max(1, self.niveau_courant), 5) ** 2 + # La profondeur ABSOLUE : celle de la racine plus celle de l'étage. + # Un enfant de niveau 1 posé dans une racine déjà au troisième étage + # est en réalité au quatrième, et ses délais doivent le savoir. + profondeur = self.profondeur_racine + max(1, self.niveau_courant) + facteur = min(profondeur, 5) ** 2 return DELAIS[etape] * facteur # ---------------------------------------------------------------- # @@ -404,8 +476,10 @@ class Descente: return "" if res.returncode else res.stdout.strip() def parcourir(self): - parent = None - parent_alias = "" + # Sans racine, le premier étage est une VM qu'on crée en local. Avec + # une racine, TOUS les étages sont des enfants — le premier compris. + parent = self.racine + parent_alias = (self.racine or {}).get("target", "") for res in self.plan["niveaux"]: niveau = res["niveau"] self.niveau_courant = niveau @@ -420,7 +494,7 @@ class Descente: f" ── étage {niveau} : {res['vcpu']} vCPU," f" {res['ram']} Mo, {res['disque']} Go" ) - if niveau == 1: + if parent is None: nom = self.creer_etage1(res) if not nom: self.etages.append(etage) @@ -546,6 +620,18 @@ class Descente: if en_cours is not None and en_cours not in etages: etages.append(en_cours) return { + # La racine EMPRUNTÉE, si la descente en avait une. Hors de + # « etages » : elle n'est pas un étage atteint, et l'y mettre + # décalait de un le compte et le code de sortie. + "racine": ( + { + "alias": self.racine.get("target"), + "profondeur": self.profondeur_racine, + "cree": False, + } + if self.racine + else None + ), # L'outil qui a écrit ce rapport. Sans lui, « deep_qemu # --detruire » prenait le rapport le plus récent — qui pouvait # être celui d'une descente Proxmox — et détruisait d'après lui. @@ -686,6 +772,42 @@ def autre_descente(): return vivants +def joindre_racine(cible, jump=""): + """Le dict d'hôte d'une racine empruntée, ou None si elle ne répond pas. + + `sudo` est DÉDUIT, pas supposé : sur un hôte joint en root, préfixer les + commandes de « sudo » échoue là où l'image n'en a pas, et sur un hôte + joint en utilisateur, ne pas le mettre échoue partout. + """ + hote = {"target": cible, "jump": jump or "", "sudo": ""} + code, out = pve.run(hote, "id -u", 60) + if code: + dire(f" ✗ {cible} : injoignable en ssh.") + return None + if pve.strip_ssh_noise(out).strip() != "0": + hote["sudo"] = "sudo " + code, _o = pve.run(hote, "true", 60) + if code: + dire(f" ✗ {cible} : sudo demande un mot de passe.") + return None + return hote + + +def profondeur_de(cible): + """La profondeur d'imbrication de `cible`, d'après sa chaîne de rebonds. + + C'est la seule mesure dont on dispose de l'extérieur, et elle est exacte + pour les hôtes que nous avons déployés : c'est nous qui écrivons ces + entrées, un ProxyJump par étage. + """ + try: + from script.todo.todo import TODO + + return nesting.depth_from_jumps(TODO._ssh_jump_depth(cible)) + except Exception: # noqa: BLE001 - une profondeur inconnue vaut 1 + return 1 + + def mener(argv, description, famille, classe, couts=None): """La ligne de commande, le plan, la descente et le rapport. @@ -703,6 +825,18 @@ def mener(argv, description, famille, classe, couts=None): parseur.add_argument("--depth", type=int, default=3) parseur.add_argument("--dry-run", action="store_true") parseur.add_argument("--detruire", action="store_true") + # Partir d'un hôte qu'on POSSÈDE DÉJÀ. Créer une VM de tête pour héberger + # un hyperviseur qu'on a sous la main coûte cinq minutes et un étage + # d'imbrication — donc de la lenteur — pour rien. + parseur.add_argument( + "--hote", + default="", + help="partir d'un hôte existant (alias ssh ou user@adresse)" + " au lieu de créer une VM de premier étage", + ) + parseur.add_argument( + "--jump", default="", help="rebond ssh pour joindre --hote" + ) args = parseur.parse_args(argv) journal = os.path.expanduser( @@ -715,9 +849,25 @@ def mener(argv, description, famille, classe, couts=None): # destruction détruisait pour de vrai. return detruire(famille, journal, dry_run=args.dry_run) - coeurs, ram, disque = capacite_hote() + racine, profondeur_racine = None, 0 + if args.hote: + racine = joindre_racine(args.hote, args.jump) + if racine is None: + return 1 + profondeur_racine = profondeur_de(args.hote) + # La capacité de la RACINE, pas celle d'ici. Dimensionner le plan sur + # la machine locale quand les étages vivent ailleurs annoncerait des + # étages qui ne tiennent pas. + coeurs, ram, disque = capacite_distante(racine) + if not coeurs: + print(f"\n ✗ {args.hote} : capacité illisible.\n") + return 1 + print(f"\n racine : {args.hote}, déjà au niveau {profondeur_racine}") + else: + coeurs, ram, disque = capacite_hote() + ou = args.hote or "machine locale" print( - f"\n machine : {coeurs} cœurs, {ram} Mo disponibles," + f"\n {ou} : {coeurs} cœurs, {ram} Mo disponibles," f" {disque} Go de disque" ) plan = nesting.nesting_plan(args.depth, coeurs, ram, disque, couts) @@ -749,7 +899,15 @@ def mener(argv, description, famille, classe, couts=None): if args.dry_run: print(" --dry-run : rien ne sera créé.\n") chemin = journal[:-4] + ("-dryrun.json" if args.dry_run else ".json") - descente = classe(plan, journal, args.dry_run, chemin) + descente = classe( + plan, journal, args.dry_run, chemin, racine, profondeur_racine + ) + # La racine est-elle en état d'héberger ? Le même contrôle que celui de + # fin d'étage — un hôte emprunté n'a pas été préparé par nous, et rien ne + # garantit que sa pile est debout. + if racine and not args.dry_run and not descente.controler(racine): + print(f"\n ✗ {args.hote} ne peut pas héberger d'étage.\n") + return 1 rapport = descente.parcourir() with open(chemin, "w", encoding="utf-8") as fh: json.dump(rapport, fh, indent=2) diff --git a/script/todo/longtest_menu.py b/script/todo/longtest_menu.py index 2d10209..018584b 100644 --- a/script/todo/longtest_menu.py +++ b/script/todo/longtest_menu.py @@ -56,36 +56,119 @@ class LongTestMenuMixin: ) }, {"prompt_description": t("Nested Proxmox depth: run it")}, + { + "prompt_description": t( + "Nested QEMU depth: plan only (dry-run)" + ) + }, + {"prompt_description": t("Nested QEMU depth: run it")}, {"prompt_description": t("Undo what the descent created")}, ] + # Chaque choix : le script, et s'il faut demander d'où l'on part. + scripts = { + "1": ("deep_proxmox.py", True), + "2": ("deep_proxmox.py", True), + "3": ("deep_qemu.py", True), + "4": ("deep_qemu.py", True), + } help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False - if status == "1": - self._longtest_run( - "deep_proxmox.py", - f"--depth {self._longtest_depth()} --dry-run", - ) - elif status == "2": - # La profondeur est DEMANDÉE : c'est le seul réglage du test, - # et il décide de sa durée — dix étages, c'est une nuit. - self._longtest_run( - "deep_proxmox.py", f"--depth {self._longtest_depth()}" - ) - elif status == "3": - # Le script demande « OUI » avant de détruire, mais il liste - # d'abord : on lui fait faire cette liste À BLANC pour que le - # choix « 3 » d'une touche ne mène pas directement à un - # « qm destroy --purge ». - self._longtest_run("deep_proxmox.py", "--detruire --dry-run") - if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")): - self._longtest_run("deep_proxmox.py", "--detruire") + if status in scripts: + script, demander = scripts[status] + # La profondeur est DEMANDÉE : c'est le réglage qui décide de + # la durée — au-delà de trois étages, tout est 15 à 30 fois + # plus lent, et cinq se comptent en heures. + args = f"--depth {self._longtest_depth()}" + if demander: + args += self._longtest_depart(script) + if status in ("1", "3"): + args += " --dry-run" + self._longtest_run(script, args) + elif status == "5": + self._longtest_defaire() else: print(t("Command not found !")) + def _longtest_defaire(self): + """Défaire, chaque pile la sienne. + + Les deux scripts partagent le dossier des rapports mais chacun ne + connaît que les siens : lancer les deux ne peut pas faire détruire à + l'un ce que l'autre a créé. + + Le script demande « OUI » avant de détruire, mais il LISTE d'abord : + on lui fait faire cette liste à blanc pour qu'un choix d'une touche ne + mène pas directement à un « qm destroy --purge ». + """ + for script in ("deep_proxmox.py", "deep_qemu.py"): + self._longtest_run(script, "--detruire --dry-run") + if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")): + self._longtest_run(script, "--detruire") + + def _longtest_depart(self, script): + """D'où part la descente : une VM neuve, ou un hôte qu'on a déjà. + + Créer une machine de tête pour héberger un hyperviseur qu'on possède + déjà coûte cinq minutes ET un étage d'imbrication — donc de la + lenteur, puisque c'est justement elle qu'on mesure. + + L'hôte déjà retenu est proposé sans qu'on ait à le rechercher : c'est + `_pve_host(ask=False)`, qui ne demande rien et ne dit rien s'il n'y en + a pas. + """ + connu = None + if script == "deep_proxmox.py": + try: + connu = self._pve_host(ask=False) + except Exception: # noqa: BLE001 - une préférence illisible + connu = None + print(f"\n{t('Where does the descent start?')}") + print(f" [1] {t('Create a fresh QEMU VM as level one')} *") + if connu: + print(f" [2] {t('Start from:')} {self._pve_label(connu)}") + print(f" [3] {t('Start from another existing host')}") + choix = input(t("Choice (1-3, default 1): ")).strip() + if choix == "2" and connu: + return self._longtest_args_hote(connu) + if choix == "3": + hote = ( + self._pve_pick_host() + if script == "deep_proxmox.py" + else self._longtest_hote_manuel() + ) + if hote: + return self._longtest_args_hote(hote) + print(t("Cancelled.")) + return "" + + @staticmethod + def _longtest_args_hote(hote): + """Les options que le script attend, à partir d'un dict d'hôte.""" + args = f" --hote {hote['target']}" + if hote.get("jump"): + args += f" --jump {hote['jump']}" + return args + + def _longtest_hote_manuel(self): + """Un hôte libvirt de départ, saisi à la main. + + Pas de sélecteur vérifié comme pour Proxmox : ce qu'on veut ici, c'est + un hôte qui porte KVM, et c'est le script qui le CONSTATE au premier + contrôle — /dev/kvm et l'imbrication — plutôt que le menu qui le + suppose. + """ + cible = input(t("Address (user@host, blank = cancel): ")).strip() + if not cible: + return None + return { + "target": cible, + "jump": input(t("SSH jump host (blank = none): ")).strip(), + } + def _longtest_depth(self): """Profondeur demandée. Trois par défaut, parce que trois marche. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d0df4f4..d12f08e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3454,6 +3454,38 @@ TRANSLATIONS = { "fr": "Script introuvable :", "en": "Script not found:", }, + "Nested QEMU depth: plan only (dry-run)": { + "fr": "Profondeur QEMU imbriqué : le plan seulement (à blanc)", + "en": "Nested QEMU depth: plan only (dry-run)", + }, + "Nested QEMU depth: run it": { + "fr": "Profondeur QEMU imbriqué : le lancer", + "en": "Nested QEMU depth: run it", + }, + "Where does the descent start?": { + "fr": "D'où part la descente ?", + "en": "Where does the descent start?", + }, + "Create a fresh QEMU VM as level one": { + "fr": "Créer une VM QEMU neuve comme premier étage", + "en": "Create a fresh QEMU VM as level one", + }, + "Start from:": { + "fr": "Partir de :", + "en": "Start from:", + }, + "Start from another existing host": { + "fr": "Partir d'un autre hôte existant", + "en": "Start from another existing host", + }, + "Choice (1-3, default 1): ": { + "fr": "Choix (1-3, défaut 1) : ", + "en": "Choice (1-3, default 1): ", + }, + "Address (user@host, blank = cancel): ": { + "fr": "Adresse (utilisateur@hôte, vide = annuler) : ", + "en": "Address (user@host, blank = cancel): ", + }, "Depth (default 3): ": { "fr": "Profondeur (défaut 3) : ", "en": "Depth (default 3): ", diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index cc6fd5b..a7be68d 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -1032,6 +1032,204 @@ class TestNePasAttendreUneMaisonDisparue(unittest.TestCase): self.assertEqual(self.d.attendre_ssh(enfant, 60), 0) +class TestLeMenuDesDeuxTests(unittest.TestCase): + """La liste des choix et le dispatch sont couplés PAR POSITION, sans + garde : ajouter une entrée sans son branchement donne un menu qui affiche + une option et répond « commande inconnue ».""" + + def setUp(self): + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + self.todo = TODO.__new__(TODO) + + def test_every_listed_choice_has_a_branch(self): + """fill_help_info numérote à partir de 1 : le choix n° i doit être + traité, sinon le menu affiche une option et répond « commande + inconnue ».""" + import inspect + + src = inspect.getsource(self.todo.prompt_execute_longtest) + entrees = src.count('"prompt_description"') + self.assertGreaterEqual(entrees, 5, "le menu a perdu des entrées") + for i in range(1, entrees + 1): + self.assertIn( + f'"{i}"', src, f"le choix {i} est affiché mais pas traité" + ) + # Et rien au-delà : un branchement sans entrée est un choix caché. + self.assertNotIn(f'"{entrees + 1}"', src) + + def test_both_stacks_are_offered(self): + import inspect + + src = inspect.getsource(self.todo.prompt_execute_longtest) + self.assertIn("deep_proxmox.py", src) + self.assertIn("deep_qemu.py", src) + + def test_undoing_asks_each_stack_separately(self): + """Chacun ne connaît que ses rapports : lancer les deux ne peut pas + faire détruire à l'un ce que l'autre a créé.""" + import inspect + + src = inspect.getsource(self.todo._longtest_defaire) + self.assertIn("deep_proxmox.py", src) + self.assertIn("deep_qemu.py", src) + # À BLANC d'abord, toujours : un choix d'une touche ne doit pas mener + # droit à « qm destroy --purge ». + self.assertLess( + src.index("--detruire --dry-run"), src.index('"--detruire"') + ) + + def test_the_host_options_are_built_from_the_host_dict(self): + self.assertEqual( + self.todo._longtest_args_hote({"target": "pve9", "jump": ""}), + " --hote pve9", + ) + self.assertEqual( + self.todo._longtest_args_hote({"target": "vm", "jump": "porte"}), + " --hote vm --jump porte", + ) + + def test_a_fresh_vm_is_the_default_answer(self): + """Toute réponse hors plage retombe sur l'option 1 : le menu ne doit + jamais partir d'un hôte qu'on n'a pas désigné.""" + import builtins + + vrai = builtins.input + self.addCleanup(setattr, builtins, "input", vrai) + self.todo._pve_host = lambda ask=True: None + for reponse in ("", "1", "n'importe quoi", "9"): + with self.subTest(reponse=reponse): + builtins.input = lambda _p="", r=reponse: r + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual( + self.todo._longtest_depart("deep_proxmox.py"), "" + ) + + def test_the_known_host_is_offered_without_being_searched(self): + import builtins + + vrai = builtins.input + self.addCleanup(setattr, builtins, "input", vrai) + demande = [] + self.todo._pve_host = lambda ask=True: demande.append(ask) or { + "target": "root@10.0.0.5", + "jump": "", + "version": "9.2.11", + } + builtins.input = lambda _p="": "2" + with contextlib.redirect_stdout(io.StringIO()) as sortie: + args = self.todo._longtest_depart("deep_proxmox.py") + self.assertEqual(args, " --hote root@10.0.0.5") + # Sans rien demander : c'est tout l'intérêt de _pve_host(ask=False). + self.assertEqual(demande, [False]) + self.assertIn("9.2.11", sortie.getvalue()) + + def test_a_qemu_descent_does_not_ask_for_a_proxmox(self): + """Le sélecteur Proxmox VÉRIFIE « pveversion » : le proposer pour une + descente QEMU refuserait un hôte libvirt parfaitement bon.""" + import builtins + + vrai = builtins.input + self.addCleanup(setattr, builtins, "input", vrai) + reponses = iter(["3", "erplibre@10.0.0.7", ""]) + builtins.input = lambda _p="": next(reponses) + self.todo._pve_pick_host = lambda: self.fail( + "sélecteur Proxmox appelé" + ) + with contextlib.redirect_stdout(io.StringIO()): + args = self.todo._longtest_depart("deep_qemu.py") + self.assertEqual(args, " --hote erplibre@10.0.0.7") + + +class TestPartirDunHoteExistant(unittest.TestCase): + """Créer une VM de tête pour héberger un hyperviseur qu'on possède déjà + coûte cinq minutes ET un étage d'imbrication — donc de la lenteur.""" + + def test_the_remote_capacity_is_read_as_the_machine_writes_it(self): + # Sortie RÉELLE, prise sur un hôte du parc. + vrai = "COEURS=8\nMEM=MemAvailable: 7056288 kB\nDISQUE= 7G\n" + self.assertEqual(moteur.parse_capacite(vrai), (8, 6890, 7)) + + def test_a_missing_line_reads_as_zero_not_as_a_guess(self): + """Un plan dimensionné sur une capacité SUPPOSÉE annoncerait des + étages qui ne tiennent pas.""" + self.assertEqual(moteur.parse_capacite(""), (0, 0, 0)) + self.assertEqual(moteur.parse_capacite("COEURS=4\n"), (4, 0, 0)) + + def test_the_capacity_probe_is_written_for_dash(self): + for interdit in ("[[", "pipefail", "$("): + self.assertNotIn(interdit, moteur.CAPACITE_CMD, interdit) + + def test_sudo_is_deduced_not_assumed(self): + """Sur un hôte joint en root, préfixer de « sudo » échoue là où + l'image n'en a pas ; en utilisateur, ne pas le mettre échoue + partout.""" + reponses = {} + moteur.pve.run = lambda h, r, t=120: reponses.get(r, (0, "")) + self.addCleanup(setattr, moteur.pve, "run", moteur.pve.run) + vrai = moteur.pve.run + + reponses["id -u"] = (0, "0\n") + self.assertEqual(moteur.joindre_racine("h")["sudo"], "") + reponses["id -u"] = (0, "1000\n") + self.assertEqual(moteur.joindre_racine("h")["sudo"], "sudo ") + moteur.pve.run = vrai + + def test_an_unreachable_root_is_refused_not_guessed(self): + vrai = moteur.pve.run + moteur.pve.run = lambda h, r, t=120: (255, "") + self.addCleanup(setattr, moteur.pve, "run", vrai) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertIsNone(moteur.joindre_racine("nulle-part")) + self.assertIn("injoignable", sortie.getvalue()) + + def test_the_delays_count_the_absolute_depth(self): + """Un enfant de niveau 1 posé dans une racine DÉJÀ au troisième étage + est en réalité au quatrième. Sans cela il héritait des délais du + premier : quatre fois trop courts.""" + d = moteur.Descente.__new__(moteur.Descente) + d.niveau_courant = 1 + d.profondeur_racine = 0 + seul = d.delai("ssh") + d.profondeur_racine = 3 + self.assertEqual(d.delai("ssh"), seul * 16) + + def test_a_borrowed_root_is_never_a_level_that_was_reached(self): + """L'y compter décalerait de un le total ET le code de sortie.""" + d = moteur.Descente.__new__(moteur.Descente) + d.plan = {"demandee": 2, "atteignable": 2} + d.etages = [{"niveau": 1, "ok": True}] + d.dry_run = False + d.OUTIL = "deep_proxmox" + d.racine = {"target": "mon-proxmox"} + d.profondeur_racine = 2 + etat = d._etat(interrompu=False) + self.assertEqual(etat["atteinte"], 1) + self.assertEqual(len(etat["etages"]), 1) + # Elle est dite, mais à part — et jamais « cree ». + self.assertEqual(etat["racine"]["alias"], "mon-proxmox") + self.assertEqual(etat["racine"]["profondeur"], 2) + self.assertFalse(etat["racine"]["cree"]) + + def test_without_a_root_the_report_says_so(self): + d = moteur.Descente.__new__(moteur.Descente) + d.plan = {"demandee": 1, "atteignable": 1} + d.etages = [] + d.dry_run = False + d.OUTIL = "deep_proxmox" + self.assertIsNone(d._etat(interrompu=False)["racine"]) + + def test_the_first_level_is_local_only_without_a_root(self): + """Avec une racine, TOUS les étages sont des enfants — le premier + compris. Sans elle, le premier est une VM créée en local.""" + import inspect + + src = inspect.getsource(moteur.Descente.parcourir) + self.assertIn("if parent is None:", src) + self.assertNotIn("if niveau == 1:", src) + + class TestDeuxPilesNeSeMelangentPas(unittest.TestCase): """Le dossier des rapports et le motif « *.json » sont PARTAGÉS.