From 07df33b3ffcc3af0727b9f8eed80046a6e3468d0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 05:11:58 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20proxmox=20:=20suivre=20une=20VM=20dista?= =?UTF-8?q?nte,=20changer=20son=20=C3=A9tat,=20=C3=A9tendre=20le=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les trois manques restants, demandés. Les colonnes vivantes du suivi — écrit par seconde, RAM, disque — venaient de virsh, qui ne connaît pas les VM d'un hôte Proxmox : elles restaient vides, et le relevé d'état les déclarait même « effacées », ce qui éteignait le reste. L'hôte sait tout cela en un appel (« pvesh get /cluster/resources »), et le relevé prend la forme de celui de virsh pour que rien en aval ne distingue la source. Un appel par hôte, mis en cache cinq secondes : une poignée de main ssh coûte 1 s, virsh 0,03 s. « Lister les VM » propose maintenant d'en changer l'état, comme QEMU/KVM — éteindre proprement avant de couper le courant, l'ordre le dit. Et le menu accepte les commandes ajoutées par todo.json. Enfin « models » manquait aux traductions : le rapport de migration sortait « 812 models » en français. --- EN --- The three remaining gaps, as asked. The dashboard's live columns — written per second, RAM, disk — came from virsh, which knows nothing of a Proxmox host's VMs: they stayed empty, and the state probe even declared them "gone", which switched off the rest. The host knows all of it in one call ("pvesh get /cluster/resources"), and the reading takes virsh's own shape so nothing downstream tells the sources apart. One call per host, cached five seconds: an ssh handshake costs 1 s, virsh 0.03 s. "List VMs" now offers to change their state, like QEMU/KVM — clean shutdown before pulling the plug, the order says so. And the menu accepts the commands todo.json adds. Finally "models" was missing from the translations: the migration report printed "812 models" in French. Assisted-by: Claude Opus 5 --- script/todo/proxmox_menu.py | 102 ++++++++++++++++++- script/todo/qemu_deploy.py | 6 ++ script/todo/qemu_install_monitor.py | 151 +++++++++++++++++++++++++--- script/todo/todo_i18n.py | 23 +++++ test/test_qemu_monitor_pve.py | 148 +++++++++++++++++++++++++++ test/test_todo_menu.py | 35 +++++++ 6 files changed, 449 insertions(+), 16 deletions(-) create mode 100644 test/test_qemu_monitor_pve.py diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 03d6ade..2cf0df5 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -406,6 +406,69 @@ class ProxmoxMenuMixin: ) actives = sum(1 for v in vms if v["status"] == "running") print(f"\n {len(vms)} VM, {actives} {t('running')}") + # Le pendant du sous-menu de QEMU/KVM : la liste est le bon endroit + # pour agir sur ce qu'on vient de lire. + print(f"\n [1] {t('Change the state of one or more VMs')}") + if input(t("Choice (blank = back): ")).strip() == "1": + self._pve_change_state(vms) + + def _pve_change_state(self, vms=None): + """Démarre ou éteint des VM de l'hôte, avec double validation. + + « shutdown » et non « stop » : on demande à l'invité de s'arrêter, ce + qui laisse Odoo fermer ses connexions PostgreSQL. « stop » coupe le + courant — il est offert en second, nommé pour ce qu'il est. + """ + from script.proxmox import proxmox_deploy as pve + + vms = vms if vms is not None else self._pve_vms() + if not vms: + print(f"\n{t('No VM on this Proxmox host.')}") + return + print(f"\n{t('Available VMs:')}") + for i, vm in enumerate(vms, 1): + print( + f" [{i}] {vm['vmid']:<7} {vm['name'][:34]:<34} {vm['status']}" + ) + print(f" [all] {t('select all')}") + brut = input(t("Selection (numbers, or 'all'): ")).strip().lower() + if not brut: + print(t("Nothing selected.")) + return + noms = [vm["name"] for vm in vms] + if brut in ("all", "*"): + choisies = list(vms) + else: + retenus = set(self._parse_index_selection(brut, noms)) + choisies = [vm for vm in vms if vm["name"] in retenus] + if not choisies: + print(t("Nothing selected.")) + return + print( + f"\n [1] {t('start')} [2] {t('shutdown (clean)')}" + f" [3] {t('stop (pulls the plug)')}" + ) + geste = input(t("Choice: ")).strip() + verbe = {"1": "start", "2": "shutdown", "3": "stop"}.get(geste) + if not verbe: + print(t("Cancelled.")) + return + print( + f"\n {verbe} : " + + ", ".join(f"{vm['name']} ({vm['vmid']})" for vm in choisies) + ) + if not self._is_yes(input(t("Confirm? (y/N): "))): + print(t("Cancelled.")) + return + for vm in choisies: + code, _o = self._pve_show(f"qm {verbe} {vm['vmid']}", timeout=300) + marque = "✓" if code == 0 else "✗" + print(f" {marque} {vm['name']}") + # L'état APRÈS : c'est la seule preuve que le geste a porté. + _c, out = self._pve_show("qm list", quiet=True) + for vm in pve.parse_qm_list(out): + if any(vm["vmid"] == c["vmid"] for c in choisies): + print(f" {vm['name']:<34} {vm['status']}") def _pve_vm_ip(self): """Adresse d'une VM, par l'agent invité. @@ -1073,8 +1136,27 @@ class ProxmoxMenuMixin: # Rien à installer ? La commande distante regarde alors la VM # ARRIVER (cloud-init, puis relevé système) : c'est ce que le # tableau de bord montre. + # + # La carte des hôtes suit : sans elle, les colonnes vivantes + # (état, durée, écrit/s, RAM, disque) restaient VIDES pour une VM + # posée sur un Proxmox distant — elles viennent de virsh, qui ne + # connaît pas cet hôte. + cartes_pve = { + vm["name"]: { + "target": host["target"], + "sudo": host.get("sudo") or "", + "jump": host.get("jump") or "", + "vmid": vm.get("vmid"), + } + for vm in joignables + if vm.get("vmid") + } self._qemu_install_erplibre_monitored( - noms, branche, {n: n for n in noms}, finale + noms, + branche, + {n: n for n in noms}, + finale, + pve=cartes_pve, ) return # Sans suivi mais avec quelque chose à installer : en série, sortie à @@ -1384,6 +1466,11 @@ class ProxmoxMenuMixin: {"section": t("Host")}, {"prompt_description": t("Change the Proxmox host")}, ] + # Même extension que le menu QEMU/KVM : ce que todo.json ajoute + # s'affiche à la suite et se lance par son numéro. + supplement = self.config_file.get_config("proxmox_from_makefile") + if supplement: + choices.extend(supplement) help_info = self.fill_help_info(choices) while True: hote = self._pve_host(ask=False) @@ -1432,7 +1519,18 @@ class ProxmoxMenuMixin: self._pve_forget_host() self._pve_pick_host() else: - print(t("Command not found !")) + introuvable = True + try: + numero = int(status) + # Les sections ne comptent pas dans la numérotation. + reelles = [c for c in choices if not c.get("section")] + if 0 < numero <= len(reelles): + introuvable = False + self.execute_from_configuration(reelles[numero - 1]) + except ValueError: + pass + if introuvable: + print(t("Command not found !")) def _pve_fetch_image(self): """Télécharge une image cloud SUR l'hôte Proxmox. diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 270d0b3..55d102c 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -270,6 +270,7 @@ class QemuDeployMixin: python_provider="", app_store="deb", vm_tools=(), + pve=None, ): """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre le dashboard Textual. Quitter le dashboard n'arrête pas les installs. @@ -327,6 +328,11 @@ class QemuDeployMixin: "version": v, "arch": a, } + # {nom: {target, sudo, vmid}} quand la VM vit sur un hôte + # Proxmox : le suivi lui demandera son état, virsh ne le + # connaît pas. + if (pve or {}).get(name): + entry["pve"] = pve[name] # Les outils imposent une commande PAR VM même quand tout le # reste est commun : ils dépendent de l'architecture de la # machine et de sa saveur de bureau, que seule cette boucle diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index c51e395..5bd6a46 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -291,17 +291,19 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: vm["name"], installs=bool(branch), ) - entries.append( - { - "name": vm["name"], - "ip": vm["ip"], - "distro": vm.get("distro"), - "version": vm.get("version"), - "arch": vm.get("arch"), - "log": log_path, - "ssh": f"ssh erplibre@{vm['ip']}", - } - ) + entree = { + "name": vm["name"], + "ip": vm["ip"], + "distro": vm.get("distro"), + "version": vm.get("version"), + "arch": vm.get("arch"), + "log": log_path, + "ssh": f"ssh erplibre@{vm['ip']}", + } + # Une VM posée sur un hôte Proxmox : c'est LUI qui connaît son état. + if vm.get("pve"): + entree["pve"] = vm["pve"] + entries.append(entree) manifest = { "branch": branch, "started": time.time(), @@ -1293,6 +1295,105 @@ def read_domstats() -> str: return res.stdout if res.returncode == 0 else "" +# UN appel par HÔTE : « /cluster/resources » rend l'état, la mémoire, le +# disque et le cumul écrit de TOUTES ses VM d'un coup. Le « du » qui suit +# donne la taille RÉELLEMENT occupée : sur un stockage en fichiers, Proxmox +# rapporte « disk: 0 » — il ne la calcule pas. +PVE_STATS_CMD = ( + "pvesh get /cluster/resources --type vm --output-format json;" + " echo '---ERPLIBRE-DU---';" + " du -sb /var/lib/vz/images/*/ 2>/dev/null || true" +) +# Une VM distante se relève moins souvent qu'une locale : chaque tour coûte +# 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 +# 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"} +_PVE_CACHE = {"at": 0.0, "stats": {}} + + +def parse_pvestats(text: str) -> dict: + """Sortie de PVE_STATS_CMD -> {nom: relevé}, même forme que domstats. + + Même forme exprès : les colonnes, le débit d'écriture et la RAM se + calculent alors sans savoir d'où vient la mesure. Une VM sur un hôte + Proxmox distant n'avait aucune de ces colonnes — elles viennent de virsh, + qui ne sait rien de cet hôte. + """ + brut, _, tailles = (text or "").partition("---ERPLIBRE-DU---") + try: + ressources = json.loads(brut.strip() or "[]") + except ValueError: + return {} + # {vmid: octets} depuis « du -sb /var/lib/vz/images// ». + occupe = {} + for ligne in tailles.splitlines(): + parts = ligne.split() + if len(parts) == 2 and parts[0].isdigit(): + vmid = parts[1].rstrip("/").rsplit("/", 1)[-1] + if vmid.isdigit(): + occupe[int(vmid)] = int(parts[0]) + out = {} + maintenant = time.time() + for r in ressources if isinstance(ressources, list) else (): + nom = r.get("name") + if not nom: + continue + total = int(r.get("maxdisk") or 0) + utilise = int(r.get("disk") or 0) or occupe.get( + int(r.get("vmid") or 0), 0 + ) + out[nom] = { + "ram_used": int(r.get("mem") or 0), + "ram_total": int(r.get("maxmem") or 0), + # Le relevé vient d'être fait : il n'est pas périmé, et c'est ce + # que `ram_pair` vérifie avant d'afficher quoi que ce soit. + "ram_at": maintenant, + "wr_bytes": int(r.get("diskwrite") or 0), + "disk_used": utilise, + "disk_total": total, + "state": r.get("status") or "", + "uptime": int(r.get("uptime") or 0), + } + return out + + +def read_pvestats(vms, now=None) -> dict: + """{nom: relevé} des VM posées sur un hôte Proxmox, ou {}. + + Les VM concernées sont celles dont le manifeste porte un bloc « pve » + (adresse de l'hôte, sudo, vmid). Un appel par hôte, mis en cache + PVE_STATS_INTERVAL secondes. + """ + hotes = {} + for vm in vms or (): + info = vm.get("pve") or {} + if info.get("target"): + hotes[(info["target"], info.get("sudo") or "")] = info + if not hotes: + return {} + maintenant = now if now is not None else time.time() + if maintenant - _PVE_CACHE["at"] < PVE_STATS_INTERVAL: + return dict(_PVE_CACHE["stats"]) + try: + from script.proxmox import proxmox_deploy as pve + except ImportError: # pragma: no cover - le module est dans le dépôt + return {} + stats = {} + for (target, sudo), info in hotes.items(): + code, sortie = pve.run( + {"target": target, "sudo": sudo, "jump": info.get("jump", "")}, + PVE_STATS_CMD, + 30, + ) + if code == 0: + stats.update(parse_pvestats(sortie)) + _PVE_CACHE.update({"at": maintenant, "stats": stats}) + return dict(stats) + + def arm_balloon(names) -> None: """Arme la période de collecte du ballon (5 s) sur chaque VM. @@ -1931,6 +2032,10 @@ def run_monitor(manifest_path: str, run_app: bool = True): # calcule sur les relevés successifs, donc il faut échantillonner # à chaque tour (2 s) et non au rythme lent des états. stats = parse_domstats(read_domstats()) + # Les VM d'un hôte Proxmox distant : virsh ne les voit pas, leurs + # 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() for name, rec in stats.items(): self._wrate.add(name, rec["wr_bytes"], now_s) @@ -2191,13 +2296,28 @@ def run_monitor(manifest_path: str, run_app: bool = True): states = await asyncio.to_thread(virsh_domstates) except Exception: return + # Une VM posée sur un hôte Proxmox est ABSENTE de « virsh list » : + # elle passait donc pour EFFACÉE, ce qui éteignait du même coup + # ses colonnes vivantes. Son état vient de l'hôte. + distants = await asyncio.to_thread(read_pvestats, vms) for vm in vms: - self._domstate[vm["name"]] = states.get(vm["name"], "gone") + nom = vm["name"] + if vm.get("pve"): + releve = distants.get(nom) + self._domstate[nom] = PVE_ETATS.get( + (releve or {}).get("state"), "gone" + ) + continue + self._domstate[nom] = states.get(nom, "gone") # Réarmer la période du ballon sur les VM qui tournent : sans elle # la RAM affichée serait celle du dernier rapport du pilote, et une # installation redémarre la VM — ce qui remet la période à zéro. + # Les VM distantes en sont exclues : « virsh dommemstat » ne les + # atteint pas, et l'hôte donne déjà leur mémoire. vivantes = [ - vm["name"] for vm in vms if states.get(vm["name"]) == "running" + vm["name"] + for vm in vms + if not vm.get("pve") and states.get(vm["name"]) == "running" ] if vivantes: await asyncio.to_thread(arm_balloon, vivantes) @@ -2210,7 +2330,10 @@ def run_monitor(manifest_path: str, run_app: bool = True): # appel virsh par VM — celui-ci est déjà le relevé lent. changed = False for vm in vms: - if self._domstate.get(vm["name"]) == "gone": + if self._domstate.get(vm["name"]) == "gone" or vm.get("pve"): + # Une VM distante n'a pas de bail chez nous : son adresse + # est celle que cloud-init a posée, et virsh ne la voit + # pas. La chercher rendrait « gone » à chaque tour. continue ip = await asyncio.to_thread(virsh_ip, vm["name"]) if ip and ip != vm.get("ip"): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6c4cffb..5095dce 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3982,6 +3982,29 @@ TRANSLATIONS = { "fr": "Fichiers orphelins :", "en": "Orphan files:", }, + # « models » manquait alors que ses trois voisines de la même ligne + # étaient traduites : le rapport de qualité de migration sortait + # « 42 modules · 812 models · 1 204 vues ». + "models": { + "fr": "modèles", + "en": "models", + }, + "shutdown (clean)": { + "fr": "éteindre (proprement)", + "en": "shutdown (clean)", + }, + "stop (pulls the plug)": { + "fr": "arrêter (coupe le courant)", + "en": "stop (pulls the plug)", + }, + "Choice (blank = back): ": { + "fr": "Choix (vide = retour) : ", + "en": "Choice (blank = back): ", + }, + "Confirm? (y/N): ": { + "fr": "Confirmer ? (o/N) : ", + "en": "Confirm? (y/N): ", + }, "Creating the bridge on the host…": { "fr": "Création du pont sur l'hôte…", "en": "Creating the bridge on the host…", diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py new file mode 100644 index 0000000..30a4ea0 --- /dev/null +++ b/test/test_qemu_monitor_pve.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les colonnes vivantes d'une VM posée sur un hôte Proxmox distant. + +Elles venaient toutes de virsh : état, durée, écrit/s, RAM, disque. Or virsh +ne connaît pas les VM d'un hôte Proxmox — elles restaient donc VIDES, et le +relevé d'état les déclarait même « effacées », ce qui éteignait le reste. + +L'hôte sait tout cela : « pvesh get /cluster/resources --type vm » rend +l'état, la mémoire, le disque et le cumul écrit de TOUTES ses VM en un appel. +Le relevé prend la forme exacte de celui de virsh, pour que le calcul du +débit, de la RAM et des colonnes ne sache pas d'où vient la mesure. +""" + +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo import qemu_install_monitor as mon # noqa: E402 + +# Sortie RÉELLE relevée sur l'hôte d'essai (une VM, stockage en fichiers : +# Proxmox y rapporte « disk: 0 », d'où le « du » qui suit). +REPONSE = ( + '[{"cpu":0.01,"disk":0,"diskread":32633856,"diskwrite":328233472,' + '"id":"qemu/100","maxcpu":1,"maxdisk":4294967296,"maxmem":536870912,' + '"mem":385351680,"name":"pve-suivi","netin":190,"netout":0,' + '"node":"erplibre-proxmox-9","status":"running","template":0,' + '"type":"qemu","uptime":127,"vmid":100}]\n' + "---ERPLIBRE-DU---\n" + "4294971392\t/var/lib/vz/images/100/\n" +) + + +class TestLaLecture(unittest.TestCase): + def setUp(self): + self.releves = mon.parse_pvestats(REPONSE) + + def test_the_vm_is_keyed_by_its_name(self): + # Le suivi raisonne en NOMS : c'est ce que porte le manifeste. + self.assertEqual(list(self.releves), ["pve-suivi"]) + + def test_the_shape_matches_the_virsh_one(self): + # Même forme exprès : `ram_pair`, `WriteWindow` et les colonnes + # fonctionnent alors sans distinguer la source. + attendus = { + "ram_used", + "ram_total", + "ram_at", + "wr_bytes", + "disk_used", + "disk_total", + } + self.assertTrue(attendus <= set(self.releves["pve-suivi"])) + + def test_the_measures_are_the_ones_the_host_gave(self): + rec = self.releves["pve-suivi"] + self.assertEqual(rec["ram_used"], 385351680) + self.assertEqual(rec["ram_total"], 536870912) + self.assertEqual(rec["wr_bytes"], 328233472) + self.assertEqual(rec["state"], "running") + self.assertEqual(rec["uptime"], 127) + + def test_a_zero_disk_falls_back_to_the_real_size(self): + # Sur un stockage en fichiers, Proxmox NE CALCULE PAS la taille + # occupée et rapporte 0 : la colonne aurait affiché « 0/4G ». + self.assertEqual(self.releves["pve-suivi"]["disk_used"], 4294971392) + self.assertEqual(self.releves["pve-suivi"]["disk_total"], 4294967296) + + def test_the_reading_is_fresh_so_the_ram_is_shown(self): + # `ram_pair` refuse un relevé périmé : sans horodatage, la RAM d'une + # VM distante ne s'afficherait jamais. + rec = self.releves["pve-suivi"] + self.assertNotEqual(mon.ram_pair(rec, rec["ram_at"]), "-") + + def test_garbage_yields_nothing_rather_than_raising(self): + for brut in ("", "pas du json", "{}", "---ERPLIBRE-DU---"): + self.assertEqual(mon.parse_pvestats(brut), {}) + + +class TestLAppel(unittest.TestCase): + """Un appel par HÔTE, mis en cache : chaque tour coûte une poignée de + main ssh (1 s mesuré) quand virsh coûte 0,03 s pour tout le parc.""" + + def setUp(self): + mon._PVE_CACHE.update({"at": 0.0, "stats": {}}) + + def _vms(self, n=2): + return [ + { + "name": f"vm-{i}", + "pve": { + "target": "erplibre@pve1", + "sudo": "sudo ", + "vmid": 100 + i, + }, + } + for i in range(n) + ] + + def test_two_vms_of_the_same_host_cost_one_call(self): + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(0, REPONSE) + ) as appel: + mon.read_pvestats(self._vms(2), now=1000.0) + self.assertEqual(appel.call_count, 1) + + def test_a_local_only_plan_calls_nothing(self): + with mock.patch("script.proxmox.proxmox_deploy.run") as appel: + self.assertEqual(mon.read_pvestats([{"name": "locale"}]), {}) + appel.assert_not_called() + + def test_the_cache_spares_the_next_tick(self): + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(0, REPONSE) + ) as appel: + mon.read_pvestats(self._vms(1), now=1000.0) + mon.read_pvestats(self._vms(1), now=1000.0 + 1) + self.assertEqual(appel.call_count, 1) + # Passé l'intervalle, on redemande. + mon.read_pvestats( + self._vms(1), now=1000.0 + mon.PVE_STATS_INTERVAL + 0.1 + ) + self.assertEqual(appel.call_count, 2) + + def test_a_failing_host_yields_nothing_rather_than_wrong_numbers(self): + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(255, "timeout") + ): + self.assertEqual(mon.read_pvestats(self._vms(1), now=1.0), {}) + + +class TestLEtat(unittest.TestCase): + """Une VM absente de « virsh list » passait pour EFFACÉE.""" + + def test_proxmox_states_map_to_libvirt_ones(self): + self.assertEqual(mon.PVE_ETATS.get("running"), "running") + self.assertEqual(mon.PVE_ETATS.get("stopped"), "shut off") + + def test_an_unknown_state_is_really_gone(self): + # Absente de la réponse de l'hôte : la VM a vraiment disparu. + self.assertIsNone(mon.PVE_ETATS.get(None)) + self.assertIsNone(mon.PVE_ETATS.get("n'importe quoi")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 6e3d7a6..1852e20 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -230,6 +230,41 @@ class MenuCoherence: self.assertEqual(set(self.EXPECTED) - keys, set()) +class TestLaParitéProxmox(unittest.TestCase): + """Deux manques signalés par l'audit du découpage, comblés. + + Le menu Proxmox n'offrait pas de changer l'état d'une VM (QEMU/KVM l'a + dans « Lister les VM »), et n'acceptait pas les commandes ajoutées par + todo.json — deux capacités que son vis-à-vis avait. + """ + + @classmethod + def setUpClass(cls): + cls.src = (TODO_DIR / "proxmox_menu.py").read_text(encoding="utf-8") + + def test_the_list_offers_to_change_the_state(self): + self.assertIn("_pve_change_state", self.src) + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO as CLASSE + + self.assertTrue(callable(CLASSE._pve_change_state)) + + def test_a_clean_shutdown_comes_before_pulling_the_plug(self): + # « shutdown » laisse Odoo fermer ses connexions PostgreSQL ; « stop » + # coupe le courant. L'ordre des choix est la seule chose qui le dit. + self.assertLess( + self.src.index("shutdown (clean)"), + self.src.index("stop (pulls the plug)"), + ) + + def test_the_menu_reads_its_extra_commands_from_todo_json(self): + self.assertIn('get_config("proxmox_from_makefile")', self.src) + # Et le dispatch sait les lancer, sections non comptées. + self.assertIn("execute_from_configuration", self.src) + + class TestLArbreDesMenus(unittest.TestCase): """L'écran de télémétrie lit le CODE, pas la classe assemblée.