diff --git a/script/todo/deploy_form_plan.py b/script/todo/deploy_form_plan.py index d90625d..a472f2b 100644 --- a/script/todo/deploy_form_plan.py +++ b/script/todo/deploy_form_plan.py @@ -344,6 +344,32 @@ class PlanMixin: rename_screen()(self.rows[index]["vm"]["name"], auto), done ) + # ---------------------------------------------------------------- # + # Le catalogue — les trois raccourcis de sélection + # ---------------------------------------------------------------- # + def _catalog(self): + from textual.widgets import SelectionList + + return self.query_one("#f_catalog", SelectionList) + + def action_select_all(self) -> None: + self._catalog().select_all() + + def action_select_none(self) -> None: + self._catalog().deselect_all() + + def action_select_main(self) -> None: + """Une VM par distribution : la version marquée par défaut. + + Ici et non dans un formulaire : les trois gestes sont les mêmes des + deux côtés, et celui-ci manquait à l'écran Proxmox — qui affiche + pourtant le même catalogue, drapeau « default » compris.""" + widget = self._catalog() + widget.deselect_all() + for i, e in enumerate(self._entries()): + if e.get("default"): + widget.select(i) + def _auto_name(self, index): """Nom que la VM reprendrait si on effaçait le sien.""" return self._plan_entries()[index]["name"] diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index 5b57363..80245a4 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -161,7 +161,13 @@ def run_proxmox_form(ctx, run_app: bool = True): } def entry_label(e): - return f"{e['distro']} {e['version']} [{e['arch']}] {e['name']}" + # L'étoile marque la version par défaut d'une distribution : c'est + # elle que « F7 » retient, et sans repère le raccourci choisissait + # sans qu'on sache quoi. + star = " *" if e.get("default") else "" + return ( + f"{e['distro']} {e['version']}{star} [{e['arch']}] {e['name']}" + ) class ProxmoxForm(ExtrasMixin, PlanMixin, App): TITLE = t("Deploy one or more ERPLibre VMs on Proxmox VE!") @@ -170,6 +176,7 @@ def run_proxmox_form(ctx, run_app: bool = True): ("f4", "clear_vm", t("Reset VM")), ("f3", "preview", t("Preview")), ("f6", "select_all", t("All")), + ("f7", "select_main", t("Main versions")), ("f8", "select_none", t("None")), ("escape", "cancel", t("Cancel")), ] @@ -871,12 +878,6 @@ def run_proxmox_form(ctx, run_app: bool = True): self.overrides.pop(cle, None) self._refresh_after(remonter=True) - def action_select_all(self) -> None: - self.query_one("#f_catalog", SelectionList).select_all() - - def action_select_none(self) -> None: - self.query_one("#f_catalog", SelectionList).deselect_all() - def action_cancel(self) -> None: self.result = None self.exit() diff --git a/script/todo/qemu_access.py b/script/todo/qemu_access.py index 2a3dec2..645f38d 100644 --- a/script/todo/qemu_access.py +++ b/script/todo/qemu_access.py @@ -735,6 +735,9 @@ class QemuAccessMixin: # Les commandes de réparation se lancent SUR l'hyperviseur : le préfixe # évite de les copier sur la mauvaise machine, l'erreur naturelle ici. pre = f"ssh {jump} " if jump else "" + if not port and self._hypervisor_is_proxmox(jump): + self._pve_console_hint(jump, domain) + return if not port: print(f"\n ⚠ {t('This VM exposes no VNC port.')}") print(f" {t('Its display is likely spice with listen=none:')}") @@ -772,6 +775,55 @@ class QemuAccessMixin: print(f" {t('then point your VNC client at')} localhost:{port}") print(f" {t('The tunnel stays open as long as that ssh runs.')}") + @staticmethod + def _hypervisor_is_proxmox(jump) -> bool: + """Cet hyperviseur est-il un Proxmox VE ? + + La question se pose quand « virsh vncdisplay » n'a rien rendu : un + Proxmox n'a PAS de libvirt, donc l'absence de port n'y veut pas dire + « écran fermé », elle veut dire « mauvaise question ». Sans cette + distinction, on conseillait « virsh edit » sur une machine où la + commande n'existe pas. + + « qm » et non « pveversion » : c'est le binaire dont on parle ensuite. + """ + if not jump: + # Un Proxmox n'est jamais l'hôte local ici : ce menu tourne sur le + # poste de travail, et un Proxmox se joint par ssh. + return False + try: + res = subprocess.run( + ["ssh", "-o", "BatchMode=yes", jump, "command -v qm"], + capture_output=True, + text=True, + timeout=25, + ) + except (OSError, subprocess.SubprocessError): + return False + return res.returncode == 0 and bool(res.stdout.strip()) + + def _pve_console_hint(self, jump, domain) -> None: + """Les deux vraies façons de voir l'écran d'une VM Proxmox. + + Proxmox ne sert pas son écran par un port VNC qu'on tunnelise : il le + sert par un TICKET, sur son interface web (« qm vncproxy » ouvre un + websocket authentifié, pas un socket qu'on relaie). Un « ssh -L » vers + un port VNC n'y trouve rien, quel que soit le port.""" + print( + f"\n ⚠ {t('This hypervisor is Proxmox VE: it has no libvirt.')}" + ) + print(f" {t('Its screen is served by a ticket, not by a VNC port.')}") + print(f"\n {t('Two ways in:')}") + print(f" • {t('the serial console, VMID from the Proxmox menu:')}") + print(f" ssh {jump} sudo qm list # {domain}") + print(f" ssh -t {jump} sudo qm terminal ") + print(f" • {t('the web interface, through a tunnel:')}") + print(f" ssh -N -L 8006:127.0.0.1:8006 {jump}") + print(f" https://localhost:8006 → {t('VM')} → Console") + print( + f"\n {t('TODO > Execute > Deploy > Proxmox VE does the first.')}" + ) + @staticmethod def _qemu_vnc_port(domain, jump=""): """Port VNC réel d'un domaine, localement ou sur un hyperviseur distant. diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 55c23a0..f2f81d6 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -1001,20 +1001,6 @@ def run_deploy_form(ctx, run_app: bool = True): self._recompute() # -- actions ---------------------------------------------------- # - def action_select_all(self) -> None: - self.query_one("#f_catalog", SelectionList).select_all() - - def action_select_none(self) -> None: - self.query_one("#f_catalog", SelectionList).deselect_all() - - def action_select_main(self) -> None: - """Une VM par distro : la version marquée par défaut.""" - widget = self.query_one("#f_catalog", SelectionList) - widget.deselect_all() - for i, e in enumerate(self._entries()): - if e.get("default"): - widget.select(i) - def action_clear_vm(self) -> None: """Rend au profil commun la VM dont un widget a le focus. Sans cette sortie, un réglage posé par erreur ne se défaisait qu'en diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 81bcd52..5e085a1 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -1322,6 +1322,40 @@ PVE_STATS_CMD = ( # une poignée de main ssh (mesuré 1 s), quand « virsh domstats » coûte 0,03 s # pour tout le parc. Cinq secondes suffisent à voir une installation avancer. PVE_STATS_INTERVAL = 5.0 + +# Une VM locale DÉJÀ verte est resondée à cette cadence, pas à chaque tour. +# La sonde est un connect() TCP par VM : à chaque tour (2 s) c'est cher pour +# une réponse qui ne bouge presque jamais, jamais c'est un mensonge — Odoo +# redémarre au moins une fois pendant l'installation, et il lui arrive de +# mourir. Trente secondes bornent le mensonge à un demi-écran de journal. +ODOO_RECHECK = 30.0 + + +def odoo_reading(vm, releve, deja_vert, dernier, maintenant, sonde): + """Odoo répond-il sur cette VM ? (état ou None, sondé ?) + + None veut dire « pas de réponse ce tour-ci », et NON « Odoo est tombé » : + l'appelant garde alors le dernier état connu. C'est toute la différence + entre un hôte muet et une VM qui ne sert plus rien. + + Deux voies, parce que la sonde n'a pas le même prix. Sur une VM Proxmox + le port a été testé DEPUIS L'HÔTE, dans l'appel des statistiques — d'ici, + une adresse de pont interne ne répond jamais — donc c'est gratuit et relu + à chaque tour. Sur une VM locale c'est un connect() TCP par VM : on la + refait tant qu'elle est rouge, puis seulement toutes les ODOO_RECHECK + secondes. + + Ce qu'on ne fait plus, c'est ne jamais la refaire. « Odoo ne redescend pas + en cours d'install » était faux : le service redémarre au moins une fois, + et il lui arrive de mourir. Le 🟢 restait alors acquis pour toujours. + """ + if vm.get("pve"): + return (bool(releve.get("odoo")) if releve else None), False + if deja_vert and maintenant - dernier < ODOO_RECHECK: + return None, False + return bool(sonde(vm.get("ip"), 8069)), True + + # Proxmox dit « running » / « stopped » ; le suivi raisonne en états libvirt. # Une VM absente de la réponse de l'hôte a vraiment disparu. PVE_ETATS = {"running": "running", "stopped": "shut off", "paused": "paused"} @@ -1722,6 +1756,39 @@ def delete_vm_cmd_pve(info, purge: bool = True) -> str: return pve_host_cmd(info, suite) +def delete_lines(vm) -> list: + """Ce qui va RÉELLEMENT disparaître, dit selon l'endroit où la VM vit. + + L'écran annonçait à toute VM « son disque qcow2 EFFACÉ », puis nommait + /var/lib/libvirt/images/.qcow2. Sur une VM Proxmox ce fichier + n'existe pas : son disque vit dans un stockage que seul l'hôte connaît, et + la ligne désignait donc un fichier local — au mieux inexistant, au pire + celui d'une autre VM du même nom. C'est exactement la peur qui a fait + remonter le nettoyage : « le nettoyage risque d'effacer des VM en + production ». + + Une confirmation doit nommer ce qu'elle détruit, sur la machine où elle + le détruit.""" + info = vm.get("pve") + if not info: + return [ + "La VM est arrêtée, sa définition retirée,", + "et son disque qcow2 EFFACÉ. Rien n'est récupérable.", + "", + f" /var/lib/libvirt/images/{vm['name']}.qcow2", + ] + hote = info.get("target") or "?" + return [ + f"Sur l'hôte Proxmox {hote}, la VM {info.get('vmid')} est arrêtée", + "puis DÉTRUITE avec ses disques. Rien n'est récupérable.", + "", + f" qm destroy {info.get('vmid')} --purge", + "", + "Aucun fichier n'est touché ici : le disque vit dans le", + "stockage de l'hôte.", + ] + + def delete_vm_cmd(name: str, with_disks: bool) -> str: """Efface la VM sur l'HÔTE. Même séquence que « TODO._qemu_delete_vm » : arrêt, retrait de la définition (nvram si UEFI, repli sinon), puis les @@ -2016,9 +2083,18 @@ def run_monitor(manifest_path: str, run_app: bool = True): self._errcount = {} # Sommaire de stats déplié (clic) ou non. self._stats_open = False - # VM dont l'UI Odoo (:8069) répond déjà : une fois détectée « up », - # on ne re-teste plus (Odoo ne redescend pas en cours d'install). + # VM dont l'UI Odoo (:8069) répond. RELUE, et non accumulée : + # « Odoo ne redescend pas en cours d'install » est faux — il + # redémarre au moins une fois (service systemd), et il lui arrive + # de mourir. Un 🟢 acquis pour toujours affirmait alors qu'une VM + # servait Odoo alors qu'elle ne servait plus rien. self._odoo_up = set() + # Dernier tour où le port d'une VM DÉJÀ verte a été retesté. Sur + # une VM locale la sonde est un connect() TCP par VM : la refaire + # à chaque tour pour rien serait cher, la refaire jamais serait + # faux. Sur Proxmox la question ne se pose pas — la réponse vient + # avec les statistiques, gratuitement. + self._odoo_revu = {} # Relevés SUCCESSIFS sans la VM, par nom : « effacée » est un état # terminal, il se mérite. self._pve_absences = {} @@ -2229,7 +2305,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): # colonnes restaient vides. Même forme de relevé, donc la suite ne # change pas d'un iota. stats.update(read_pvestats(vms)) - now_s = time.time() + now_s = maintenant = time.time() for name, rec in stats.items(): self._wrate.add(name, rec["wr_bytes"], now_s) self._vmstats = stats @@ -2257,21 +2333,22 @@ def run_monitor(manifest_path: str, run_app: bool = True): # peut contenir des erreurs passées inaperçues. if st[0] in ("done", "failed") and name not in errors: errors[name] = scan_log_errors(vm["log"]) - # Odoo up ? On ne teste que celles pas encore confirmées up - # et non effacées (test TCP court sur :8069). - if ( - name not in self._odoo_up - and self._domstate.get(name) != "gone" - ): - releve = (self._vmstats or {}).get(name) or {} - if vm.get("pve"): - # Le port a été testé DEPUIS L'HÔTE, dans l'appel des - # statistiques : d'ici, une adresse de pont interne ne - # répond jamais. - if releve.get("odoo"): - odoo[name] = True - elif _port_open(vm.get("ip"), 8069): - odoo[name] = True + # Odoo up ? La réponse est RENDUE À CHAQUE TOUR pour les + # VM sondées, pas accumulée : la colonne doit pouvoir + # redescendre à « — ». + if self._domstate.get(name) != "gone": + etat, sonde = odoo_reading( + vm, + (self._vmstats or {}).get(name) or {}, + name in self._odoo_up, + self._odoo_revu.get(name, 0.0), + maintenant, + _port_open, + ) + if sonde: + self._odoo_revu[name] = maintenant + if etat is not None: + odoo[name] = etat return disks, status, self._collect_tele(), errors, odoo, wr, ram async def _tick_table(self): @@ -2291,7 +2368,14 @@ def run_monitor(manifest_path: str, run_app: bool = True): except Exception: return self._errcount.update(errors) - self._odoo_up.update(odoo) + # Remplacement et non union : une VM sondée qui ne répond + # plus doit repasser à « — ». Les VM absentes du relevé (hôte + # muet, VM effacée) gardent leur dernier état connu. + for nom_vm, vivant in odoo.items(): + if vivant: + self._odoo_up.add(nom_vm) + else: + self._odoo_up.discard(nom_vm) try: table = self.query_one("#vms", DataTable) now = time.time() @@ -3011,12 +3095,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): self.push_screen( ConfirmScreen( f"Supprimer {vm['name']} ?", - [ - "La VM est arrêtée, sa définition retirée,", - "et son disque qcow2 EFFACÉ. Rien n'est récupérable.", - "", - f" /var/lib/libvirt/images/{vm['name']}.qcow2", - ], + delete_lines(vm), "Supprimer définitivement", ), confirmed, diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c8e76e9..40f038e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2346,6 +2346,30 @@ TRANSLATIONS = { "fr": "et non la VM : le socket est du côté de QEMU.", "en": "not the VM: the socket is QEMU-side.", }, + "This hypervisor is Proxmox VE: it has no libvirt.": { + "fr": "Cet hyperviseur est un Proxmox VE : il n'a pas de libvirt.", + "en": "This hypervisor is Proxmox VE: it has no libvirt.", + }, + "Its screen is served by a ticket, not by a VNC port.": { + "fr": "Son écran est servi par un ticket, pas par un port VNC.", + "en": "Its screen is served by a ticket, not by a VNC port.", + }, + "Two ways in:": { + "fr": "Deux chemins :", + "en": "Two ways in:", + }, + "the serial console, VMID from the Proxmox menu:": { + "fr": "la console série, le VMID venant du menu Proxmox :", + "en": "the serial console, VMID from the Proxmox menu:", + }, + "the web interface, through a tunnel:": { + "fr": "l'interface web, par un tunnel :", + "en": "the web interface, through a tunnel:", + }, + "TODO > Execute > Deploy > Proxmox VE does the first.": { + "fr": "TODO > Execute > Deploy > Proxmox VE fait le premier.", + "en": "TODO > Execute > Deploy > Proxmox VE does the first.", + }, "This VM exposes no VNC port.": { "fr": "Cette VM n'expose aucun port VNC.", "en": "This VM exposes no VNC port.", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 2f7c622..f972c72 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -598,6 +598,54 @@ class TestDeuxVmDuMemeNom(unittest.TestCase): ) +class TestLEcranDUneVmProxmox(unittest.TestCase): + """« Console de l'hyperviseur » conseillait des commandes virsh sur une + machine qui n'a pas libvirt. + + Le tunnel lit le port VNC par « virsh vncdisplay » sur l'hyperviseur. Un + Proxmox VE n'a pas de libvirt : la commande échoue, et l'absence de port + était lue « écran fermé ». On imprimait alors « sudo virsh edit » — sur un + hôte où le binaire n'existe pas. Ce n'est pas un écran fermé, c'est la + mauvaise question : Proxmox sert son écran par un ticket, sur son + interface web.""" + + def _sortie(self, qm_present, port=0): + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + todo._ssh_proxyjump = lambda nom: "pve9" + todo._qemu_vnc_port = staticmethod(lambda d, j="": port) + todo._hypervisor_is_proxmox = lambda jump: qm_present + tampon = io.StringIO() + with contextlib.redirect_stdout(tampon): + todo._qemu_console_tunnel("pve9+vm-a", "ssh_config") + return tampon.getvalue() + + def test_a_proxmox_host_is_never_told_to_run_virsh(self): + sortie = self._sortie(qm_present=True) + self.assertNotIn("virsh", sortie) + self.assertIn("qm terminal", sortie) + self.assertIn("8006", sortie, "l'interface web est le second chemin") + + def test_a_libvirt_host_keeps_its_repair_commands(self): + # La voie libvirt ne régresse pas : sans port, ses commandes de + # réparation restent la bonne réponse. + sortie = self._sortie(qm_present=False) + self.assertIn("virsh edit", sortie) + + def test_a_working_vnc_port_still_wins(self): + # La sonde ne doit pas s'exécuter quand il y a un port : ce serait un + # aller-retour ssh pour rien. + sortie = self._sortie(qm_present=True, port=5901) + self.assertIn("-L 5901:127.0.0.1:5901", sortie) + self.assertNotIn("qm terminal", sortie) + + class TestUnSeulNomDansSshConfig(unittest.TestCase): """L'entrée portait DEUX noms sur sa ligne « Host » : le nom chaîné « hôte+vm » et le nom court. diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index 221cb6a..d69f5ef 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -230,6 +230,119 @@ class TestLesAutresCheminsVersLaPoubelle(unittest.TestCase): self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2) +class TestCeQueLaConfirmationPromet(unittest.TestCase): + """La confirmation de suppression annonçait un fichier qcow2 local à + TOUTE VM, Proxmox comprise. + + Sur une VM Proxmox ce fichier n'existe pas : son disque vit dans un + stockage que seul l'hôte connaît. La ligne désignait donc un chemin local + — au mieux inexistant, au pire celui d'une autre VM du même nom. C'est + exactement la peur qui avait fait remonter le nettoyage : « le nettoyage + risque d'effacer des VM en production ».""" + + def test_a_proxmox_vm_never_shows_a_local_path(self): + lignes = mon.delete_lines( + {"name": "vm-a", "pve": {"target": "pve9", "vmid": 101}} + ) + texte = " ".join(lignes) + self.assertNotIn("/var/lib/libvirt", texte) + self.assertNotIn("qcow2", texte) + + def test_it_names_the_host_and_the_vmid(self): + # Le nom ne suffit pas : deux VM peuvent le porter, seul le VMID est + # unique — et il faut savoir SUR QUELLE machine ça se passe. + texte = " ".join( + mon.delete_lines( + {"name": "vm-a", "pve": {"target": "pve9", "vmid": 101}} + ) + ) + self.assertIn("pve9", texte) + self.assertIn("101", texte) + self.assertIn("qm destroy 101", texte) + + def test_a_local_vm_still_names_its_file(self): + # La voie libvirt ne régresse pas : là, le fichier EST ce qu'on efface. + texte = " ".join(mon.delete_lines({"name": "vm-a"})) + self.assertIn("/var/lib/libvirt/images/vm-a.qcow2", texte) + + +class TestQuandLeVertRedescend(unittest.TestCase): + """Le 🟢 était acquis pour toujours. + + « Odoo ne redescend pas en cours d'install » : c'était faux. Le service + redémarre au moins une fois — systemd l'active à la fin du make — et il + lui arrive de mourir. La VM restait verte en servant plus rien, et c'est + précisément le moment où on veut le savoir.""" + + def _sonde(self, reponse): + return lambda _ip, _port: reponse + + def test_a_local_vm_that_stopped_answering_goes_back(self): + etat, sonde = mon.odoo_reading( + {"ip": "10.0.0.1"}, + {}, + deja_vert=True, + dernier=0.0, + maintenant=mon.ODOO_RECHECK + 1, + sonde=self._sonde(False), + ) + self.assertIs(etat, False) + self.assertTrue(sonde) + + def test_a_green_local_vm_is_not_probed_every_tick(self): + # La sonde est un connect() TCP par VM : à chaque tour (2 s) c'est + # cher pour une réponse qui ne bouge presque jamais. + etat, sonde = mon.odoo_reading( + {"ip": "10.0.0.1"}, + {}, + deja_vert=True, + dernier=100.0, + maintenant=101.0, + sonde=self._sonde(False), + ) + self.assertIsNone(etat, "rien de neuf ne doit être affirmé") + self.assertFalse(sonde) + + def test_a_red_local_vm_is_probed_every_tick(self): + _etat, sonde = mon.odoo_reading( + {"ip": "10.0.0.1"}, + {}, + deja_vert=False, + dernier=100.0, + maintenant=101.0, + sonde=self._sonde(True), + ) + self.assertTrue(sonde) + + def test_a_proxmox_vm_is_read_every_tick_for_free(self): + # Le port est testé DEPUIS L'HÔTE, avec les statistiques : d'ici, une + # adresse de pont interne ne répond jamais. + def lu(odoo): + return mon.odoo_reading( + {"pve": {"vmid": 1}}, + {"odoo": odoo}, + deja_vert=True, + dernier=0.0, + maintenant=1.0, + sonde=self._sonde(True), + )[0] + + self.assertIs(lu(True), True) + self.assertIs(lu(False), False) + + def test_a_silent_host_is_not_a_dead_odoo(self): + # Sans relevé, on ne sait RIEN : l'appelant garde le dernier état. + etat, _s = mon.odoo_reading( + {"pve": {"vmid": 1}}, + {}, + deja_vert=True, + dernier=0.0, + maintenant=1.0, + sonde=self._sonde(True), + ) + self.assertIsNone(etat) + + class TestLaBonneMachine(unittest.TestCase): """Le pire défaut de la série : l'installation partie AILLEURS. diff --git a/test/test_todo_deploy_form_parity.py b/test/test_todo_deploy_form_parity.py index 01198fd..3402929 100644 --- a/test/test_todo_deploy_form_parity.py +++ b/test/test_todo_deploy_form_parity.py @@ -163,6 +163,25 @@ class TestLesDeuxEcrans(unittest.TestCase): vide.extras_init({}) return vide + def test_both_screens_bind_the_same_catalog_shortcuts(self): + # « Versions principales » (F7) manquait à l'écran Proxmox, qui + # affiche pourtant le même catalogue, drapeau « default » compris. + from script.todo.proxmox_deploy_form import run_proxmox_form + from script.todo.qemu_deploy_form import run_deploy_form + + todo = todo_muet() + mod = todo._qemu_import_module() + touches = {} + for nom, app in ( + ("QEMU/KVM", run_deploy_form(todo._qemu_form_context(mod), False)), + ("Proxmox", run_proxmox_form(contexte_proxmox(todo), False)), + ): + touches[nom] = { + b[0]: b[1] for b in app.BINDINGS if b[1].startswith("select_") + } + self.assertEqual(touches["QEMU/KVM"], touches["Proxmox"]) + self.assertIn("select_main", touches["Proxmox"].values()) + def test_parallelism_follows_the_host_on_both_screens(self): # L'écran Proxmox plafonnait à quatre choix et en proposait UN, quel # que soit le nombre de cœurs de l'hôte.