From 562ad1c87334f5997a4e78853f1d7b1196a7fc58 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 01:11:09 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20todo=20:=20dire=20la=20place=20qui=20re?= =?UTF-8?q?ste=20sous=20le=20plan=20de=20d=C3=A9ploiement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La ligne de totaux annonçait « ~126 G » sans dire sur quoi : la demande seule ne dit pas si ça rentre, et on l'apprenait au déploiement. Elle dit maintenant la demande, ce qui reste et la capacité — « ~126 G / 20 G libres sur 270 G » — et prévient dès que le plan dépasse. Les trois limites (RAM, disque, cœurs) s'affichent ensemble : n'en montrer qu'une cachait les autres. Sur Proxmox, la place vient du stockage choisi, que « pvesm status » donnait déjà. Deux défauts trouvés en le faisant : la marque de génération, exigée de tous les widgets, faisait taire chaque réglage commun de l'écran Proxmox, et « [x1] » disparaissait, lu comme une balise Rich. --- EN --- The totals line said "~126 G" without saying out of what: the demand alone does not tell whether it fits, and one found out at deploy time. It now says the demand, what is left and the capacity — "~126 G / 20 G free of 270 G" — and warns as soon as the plan exceeds it. The three limits (RAM, disk, cores) show together: showing only one hid the others. On Proxmox the room comes from the chosen storage, which "pvesm status" already gave. Two defects found on the way: the generation mark, required of every widget, silenced each common setting on the Proxmox screen, and "[x1]" vanished, read as a Rich tag. Assisted-by: Claude Opus 5 --- script/todo/deploy_form_lib.py | 25 ++++++ script/todo/proxmox_deploy_form.py | 27 ++++-- script/todo/proxmox_menu.py | 81 ++++++++++++------ script/todo/qemu_deploy.py | 6 ++ script/todo/qemu_deploy_form.py | 22 +++-- script/todo/todo.py | 22 +++++ script/todo/todo_i18n.py | 12 +++ test/test_proxmox_form.py | 90 ++++++++++++++++++-- test/test_qemu_deploy_form_disk.py | 129 +++++++++++++++++++++++++++++ test/test_todo_deploy_form_lib.py | 37 ++++++++- 10 files changed, 401 insertions(+), 50 deletions(-) create mode 100644 test/test_qemu_deploy_form_disk.py diff --git a/script/todo/deploy_form_lib.py b/script/todo/deploy_form_lib.py index 147aab9..1937215 100644 --- a/script/todo/deploy_form_lib.py +++ b/script/todo/deploy_form_lib.py @@ -291,6 +291,31 @@ def plan_rows(vms, domains, extra_disk_gb=0, orphelin=None): return rows +def gib(nbytes) -> int: + """Octets -> Gio entiers. Le plan compte en Go partout ailleurs : mêler + des unités sur la même ligne de totaux la rendrait illisible.""" + try: + return int(nbytes) // (1 << 30) + except (TypeError, ValueError): + return 0 + + +def disk_note(plan_gb, free_gb, total_gb=0) -> str: + """« ~50 G / 20 G libres sur 270 G » — la demande, le reste, la capacité. + + La demande seule ne dit pas si ça rentre : c'est le rapprochement qui + décide, et c'est pourquoi la place s'affiche là même où la demande était + déjà écrite. Les deux nombres ne sont pas redondants — 20 Go libres sur + 270 se lit autrement que 20 sur 24. Sans mesure (0), on n'invente rien : + la demande s'affiche seule. + """ + if not free_gb: + return f"~{plan_gb} G" + if not total_gb: + return f"~{plan_gb} G / {free_gb} G {t('free')}" + return f"~{plan_gb} G / {free_gb} G {t('free of')} {total_gb} G" + + def plan_totals(rows): """Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de neuf) : (nb, vcpus, ram_mo, disque_go).""" diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index d3cb875..9b447f9 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -21,6 +21,7 @@ Le formulaire ne touche à rien : il rend une spec. C'est l'appelant """ import os +import re from script.todo.deploy_form_lib import ( CSS_BASE, @@ -28,7 +29,9 @@ from script.todo.deploy_form_lib import ( RES_FIELDS, SELECT_TO_FIELD, build_vms, + disk_note, entry_key, + gib, plan_rows, plan_totals, res_row_widgets, @@ -466,14 +469,18 @@ def run_proxmox_form(ctx, run_app: bool = True): pass n, cpu, ram, disque = plan_totals(self.rows) libre = ctx.get("free_ram") or 0 - alerte = ( - f" ⚠ {t('more RAM than the host has free')}" - if libre and ram > libre - else "" - ) + # La place du stockage CHOISI : elle change avec la liste, donc + # elle se relit à chaque rendu plutôt qu'une fois au montage. + place = gib((ctx.get("storage_avail") or {}).get(self._storage())) + alertes = [] + if libre and ram > libre: + alertes.append(t("more RAM than the host has free")) + if place and disque > place: + alertes.append(t("more disk than the storage has free")) + alerte = f" ⚠ {' · '.join(alertes)}" if alertes else "" self.query_one("#totals", Static).update( f" {n} {t('VM')} {cpu} vCPU {ram} Mo RAM " - f"{disque} Go [{res_label(self.profile)}]" + f"{disk_note(disque, place)} {res_label(self.profile)}" f" {t('storage')} {self._storage() or '?'}" f" {t('bridge')} {self._bridge() or '?'}{alerte}" ) @@ -537,9 +544,15 @@ def run_proxmox_form(ctx, run_app: bool = True): self._refresh_after() def on_select_changed(self, event) -> None: - if self._syncing or not self._is_current(event.select): + if self._syncing: return ident = event.select.id or "" + # La marque de génération ne concerne QUE les widgets de rangée : + # un widget global n'en porte pas. L'exiger de tous revenait à + # ignorer chaque réglage commun — mesuré, ni le stockage, ni la + # RAM générale n'atteignaient le plan. + if re.match(r"v\d+_", ident) and not self._is_current(event.select): + return if ident in ("f_storage", "f_bridge"): self._refresh_after() return diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 249660b..66c7a97 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -142,9 +142,7 @@ class ProxmoxMenuMixin: def _pve_host_from_ssh_config(self): """Un alias de ~/.ssh/config : il porte déjà utilisateur, port et ProxyJump — rien à redemander, et le rebond traverse.""" - entrees = self._ssh_config_entries( - os.path.expanduser("~/.ssh/config") - ) + entrees = self._ssh_config_entries(os.path.expanduser("~/.ssh/config")) if not entrees: print(f"\n{t('No SSH hosts found in ~/.ssh/config')}") return None @@ -205,7 +203,9 @@ class ProxmoxMenuMixin: chemin = os.path.expanduser("~/.ssh/known_hosts") os.makedirs(os.path.dirname(chemin), exist_ok=True) with open(chemin, "a", encoding="utf-8") as fh: - fh.write(res.stdout if res.stdout.endswith("\n") else res.stdout + "\n") + fh.write( + res.stdout if res.stdout.endswith("\n") else res.stdout + "\n" + ) lignes = len(res.stdout.strip().splitlines()) print(f" ✓ {lignes} {t('key(s) recorded in ~/.ssh/known_hosts')}") return True @@ -233,7 +233,9 @@ class ProxmoxMenuMixin: print(f" ✗ {t('Not a Proxmox host (or unreachable):')}") premiere = (out or "").strip().splitlines() print(f" {premiere[0] if premiere else t('no answer')}") - print(f" → {t('Check the address, the SSH access and pveversion.')}") + print( + f" → {t('Check the address, the SSH access and pveversion.')}" + ) return None # « qm » exige les privilèges. La voie « VM QEMU locale » donne # l'accès d'erplibre, pas de root : il faut donc sudo, et il faut le @@ -244,7 +246,9 @@ class ProxmoxMenuMixin: if qui.strip() != "0": code, _o = pve.run(host, "sudo -n true", timeout=20) if code: - print(f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}") + print( + f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}" + ) print(f" → {t('Connect as root@, or allow NOPASSWD sudo.')}") return None prefixe = "sudo " @@ -295,9 +299,7 @@ class ProxmoxMenuMixin: return [] if multiple else None print(f"\n{titre or t('VMs on this host:')}") for i, vm in enumerate(vms, 1): - print( - f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}" - ) + print(f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}") if multiple: print(f" [all] {t('select all')}") brut = input(t("Selection (number): ")).strip() @@ -390,9 +392,7 @@ class ProxmoxMenuMixin: if not vm: return print(f"\n ⚠ {t('Proxmox can only GROW a disk, never shrink it.')}") - taille = input( - t("Size (+10G to add, 40G for a target): ") - ).strip() + taille = input(t("Size (+10G to add, 40G for a target): ")).strip() if not re.match(r"^\+?\d+[MGT]$", taille): print(t("Invalid selection!")) return @@ -485,7 +485,9 @@ class ProxmoxMenuMixin: """Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un FICHIER là-bas, pas une clé en ligne.""" try: - with open(os.path.expanduser(chemin_local), encoding="utf-8") as fh: + with open( + os.path.expanduser(chemin_local), encoding="utf-8" + ) as fh: cle = fh.read().strip() except OSError as exc: print(f" ⚠ {t('SSH key unreadable:')} {exc}") @@ -525,11 +527,11 @@ class ProxmoxMenuMixin: print(" address /24") print(" gateway ") print(" bridge-ports ") - print(f" ⚠ {t('This moves the host address: do it from a console.')}") + print( + f" ⚠ {t('This moves the host address: do it from a console.')}" + ) return "" - _c, sortie = self._pve_show( - "ip -o -4 route show default", quiet=True - ) + _c, sortie = self._pve_show("ip -o -4 route show default", quiet=True) uplink = "" parts = (sortie or "").split() if "dev" in parts: @@ -629,6 +631,12 @@ class ProxmoxMenuMixin: "next_vmid": pve.next_vmid(vms), "storages": [s["name"] for s in stockages if s.get("actif")], "storage": pve.pick_storage(stockages), + # La place libre par stockage, en octets : « pvesm status » la + # donne dans la même sortie, donc l'écran peut dire si le plan + # rentre sans un aller-retour de plus vers l'hôte. + "storage_avail": { + s["name"]: s.get("avail") or 0 for s in stockages + }, "bridges": ponts, "bridge": pve.pick_bridge(ponts), "ipconfig": ipconfig, @@ -797,7 +805,9 @@ class ProxmoxMenuMixin: print(f"\n {t('Waiting for the VM address…')} {vm['name']}") ip = self._pve_guest_ip(vm["vmid"]) if not ip: - print(f" ⚠ {vm['name']} : {t('No address yet. Try [6] later.')}") + print( + f" ⚠ {vm['name']} : {t('No address yet. Try [6] later.')}" + ) continue print(f" ✓ {vm['name']} : {ip}") if spec.get("add_ssh_config"): @@ -839,9 +849,10 @@ class ProxmoxMenuMixin: distro = self._qemu_prompt_distro() version = self._qemu_prompt_version(distro) arch = "amd64" - nom = input( - t("VM name (default: erplibre-): ") - ).strip() or f"erplibre-{distro}" + nom = ( + input(t("VM name (default: erplibre-): ")).strip() + or f"erplibre-{distro}" + ) memoire = ( self._qemu_ask_ram(t("RAM in MB, blank = 4096"), 4096) or 4096 ) @@ -884,7 +895,9 @@ class ProxmoxMenuMixin: # l'afficher avant de l'avoir choisi ne pouvait pas marcher. vmid = pve.next_vmid(self._pve_vms()) ipconfig = pve.ipconfig_for(infos_ponts.get(pont, {}), vmid) - print(f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})") + print( + f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})" + ) print(f" {t('bridge')} : {pont}") print(f" {t('address')} {ipconfig}") print(f" VMID : {vmid}") @@ -989,7 +1002,9 @@ class ProxmoxMenuMixin: identity_file=cle, proxy_jump=host["target"], ) - print(f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})") + print( + f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})" + ) def _pve_test_vm(self): """Ouvre Odoo (:8069) d'une VM Proxmox dans un navigateur en ligne. @@ -1007,7 +1022,9 @@ class ProxmoxMenuMixin: return if not self._qemu_ip_reachable(ip, port=8069, timeout=3): print(f"\n ⚠ {ip}:8069 {t('unreachable from here.')}") - print(f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}") + print( + f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}" + ) return navigateur = self._qemu_choose_cli_browser() if not navigateur: @@ -1032,7 +1049,9 @@ class ProxmoxMenuMixin: "sshkey_path": "/root/.ssh/erplibre-deploy.pub", } print(f"\n── {t('Example: demo-vm, Debian 13, on a Proxmox host')} ──") - print(f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}") + print( + f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}" + ) for cmd in pve.create_cmds(101, spec): print(f" {cmd}") @@ -1091,8 +1110,16 @@ class ProxmoxMenuMixin: "SSH configuration (~/.ssh/config, ProxyJump)" ) }, - {"prompt_description": t("Remote desktop tunnel (VNC/RDP over SSH)")}, - {"prompt_description": t("Android emulator (start, tunnel, scrcpy)")}, + { + "prompt_description": t( + "Remote desktop tunnel (VNC/RDP over SSH)" + ) + }, + { + "prompt_description": t( + "Android emulator (start, tunnel, scrcpy)" + ) + }, {"section": t("Catalog")}, {"prompt_description": t("List available images and their specs")}, {"prompt_description": t("Proxmox - example sequence (dry-run)")}, diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index eddfcfc..5419578 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -1215,6 +1215,7 @@ class QemuDeployMixin: catalog[a] = entries print(f"\n{t('Loading (VM list, branches)...')}") + disque_libre, disque_total = self._host_disk_gb() return { "catalog": catalog, "arches": arches, @@ -1226,6 +1227,11 @@ class QemuDeployMixin: "timezone": self._qemu_host_timezone(), "host_cpu": os.cpu_count() or 2, "free_ram": self._host_free_ram_mb(), + # La place du système de fichiers qui portera les qcow2. Mesurée + # ICI, comme le reste : une lecture disque pendant que Textual + # affiche n'a pas sa place. + "free_disk": disque_libre, + "total_disk": disque_total, "base_vcpus": self._QEMU_BASE_VCPUS, "cpu_presets": self._QEMU_CPU_PRESETS, "ram_presets": self._QEMU_RAM_PRESETS, diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 4f807c0..c049733 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -47,9 +47,11 @@ from script.todo.deploy_form_lib import ( # noqa: F401 clip_payload, copy_name, disk_gb, + disk_note, entry_key, expand_copies, fmt_dur, + gib, parse_disk, parse_ram, plan_rows, @@ -107,6 +109,8 @@ def run_deploy_form(ctx, run_app: bool = True): branches = ctx.get("branches") or ["master"] host_cpu = ctx.get("host_cpu") or 2 free_ram = ctx.get("free_ram") or 0 + free_disk = ctx.get("free_disk") or 0 + total_disk = ctx.get("total_disk") or 0 base_vcpus = ctx.get("base_vcpus") or 2 extra_disk = ctx.get("extra_disk_gb") or 0 desktop_disk = ctx.get("desktop_disk_gb") or 0 @@ -919,11 +923,18 @@ def run_deploy_form(ctx, run_app: bool = True): ) return n, cpus, ram, disk = plan_totals(self.rows) - warn = "" + # Une liste et non un seul avertissement : la RAM, les cœurs et le + # disque sont trois limites distinctes, et n'en montrer qu'une + # cachait les autres — on corrigeait la première pour découvrir la + # suivante au déploiement. + alertes = [] if free_ram and ram > free_ram: - warn = f" ⚠ {t('> host free RAM')}" - elif cpus > host_cpu: - warn = f" ⚠ {t('> host cores')} ({host_cpu})" + alertes.append(t("> host free RAM")) + if free_disk and disk > free_disk: + alertes.append(t("> host free disk")) + if cpus > host_cpu: + alertes.append(f"{t('> host cores')} ({host_cpu})") + warn = f" ⚠ {' · '.join(alertes)}" if alertes else "" dupes = len({vm["name"] for vm in self.vms}) != len(self.vms) dup_txt = ( f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}" @@ -942,7 +953,8 @@ def run_deploy_form(ctx, run_app: bool = True): else "" ) self.query_one("#totals", Static).update( - f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G" + f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · " + f"{disk_note(disk, free_disk, total_disk)}" f"{skip_txt}{warn}{dup_txt}" ) diff --git a/script/todo/todo.py b/script/todo/todo.py index 00b0fb7..9487319 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1514,6 +1514,28 @@ class TODO( invites « défaut oui » où tout sauf « non » vaut oui.""" return ans.strip().lower() in ("n", "no", "non") + @staticmethod + def _host_disk_gb(path="/var/lib/libvirt/images"): + """(libre, total) en Go du système de fichiers qui portera les disques. + + On remonte vers le premier parent qui existe : le répertoire d'images + n'est créé qu'au premier déploiement, et « /var/lib/libvirt » ou « / » + répondent de la même partition dans la quasi-totalité des cas. (0, 0) + si rien ne répond — la place libre s'affiche alors comme inconnue + plutôt qu'inventée. + """ + chemin = path + while chemin and not os.path.isdir(chemin): + parent = os.path.dirname(chemin) + if parent == chemin: + break + chemin = parent + try: + usage = shutil.disk_usage(chemin or "/") + except OSError: + return 0, 0 + return usage.free // (1 << 30), usage.total // (1 << 30) + @staticmethod def _host_free_ram_mb(): """RAM disponible de l'hôte en Mo (MemAvailable), 0 si inconnu.""" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 699a6b2..1d597b6 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1253,6 +1253,14 @@ TRANSLATIONS = { "fr": "> RAM libre de l'hôte", "en": "> host free RAM", }, + "> host free disk": { + "fr": "> disque libre de l'hôte", + "en": "> host free disk", + }, + "free of": { + "fr": "libres sur", + "en": "free of", + }, "total RAM": { "fr": "RAM totale", "en": "total RAM", @@ -3359,6 +3367,10 @@ TRANSLATIONS = { "fr": "Aucun pont sur l'hôte.", "en": "No bridge on the host.", }, + "more disk than the storage has free": { + "fr": "plus de disque que le stockage n'en a de libre", + "en": "more disk than the storage has free", + }, "more RAM than the host has free": { "fr": "plus de RAM que l'hôte n'en a de libre", "en": "more RAM than the host has free", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 3f76996..7860f88 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -78,9 +78,7 @@ class TestVmid(unittest.TestCase): def test_the_address_is_derived_from_the_vmid(self): rows = [rangee("a"), rangee("b")] - assign_vmids( - rows, [], 100, lambda v: f"ip=10.10.10.{50 + v % 200}/24" - ) + assign_vmids(rows, [], 100, lambda v: f"ip=10.10.10.{50 + v % 200}/24") self.assertEqual(rows[0]["vm"]["ipconfig"], "ip=10.10.10.150/24") self.assertEqual(rows[1]["vm"]["ipconfig"], "ip=10.10.10.151/24") @@ -100,7 +98,11 @@ class TestSpec(unittest.TestCase): "ssh_key": "/home/x/.ssh/id_ed25519.pub", "start": True, "add_ssh_config": True, - "install": {"branch": "develop", "label": "Odoo 18", "cmd": "make"}, + "install": { + "branch": "develop", + "label": "Odoo 18", + "cmd": "make", + }, "monitor": True, "parallelism": 2, } @@ -141,7 +143,11 @@ def contexte(): } return { - "host": {"target": "erplibre@10.0.0.5", "sudo": "sudo ", "label": "pve"}, + "host": { + "target": "erplibre@10.0.0.5", + "sudo": "sudo ", + "label": "pve", + }, "node": "pve1", "catalog": { "amd64": [ @@ -158,6 +164,10 @@ def contexte(): "next_vmid": 102, "storages": ["local-lvm", "local"], "storage": "local-lvm", + "storage_avail": { + "local-lvm": 90 * (1 << 30), + "local": 12 * (1 << 30), + }, "bridges": ["vmbr0"], "bridge": "vmbr0", "ipconfig": lambda pont, vmid: f"ip=10.10.10.{50 + vmid % 200}/24", @@ -195,6 +205,14 @@ class TestEcran(unittest.TestCase): await pilote.pause() await pilote.pause() await gestes(app, pilote) + # Relevé AVANT la sortie du contexte : `run_test` démonte + # l'écran, et « #totals » n'existe plus après. + from textual.widgets import Static + + widget = app.query_one("#totals", Static) + app.ligne_totaux = str( + getattr(widget, "_content", "") or widget.render() + ) resultat["app"] = app asyncio.run(scenario()) @@ -256,9 +274,7 @@ class TestEcran(unittest.TestCase): app = self._rendu(gestes) self.assertEqual(len(app.rows), 4) - vmids = [ - r["vm"]["vmid"] for r in app.rows if r["state"] != "exists" - ] + vmids = [r["vm"]["vmid"] for r in app.rows if r["state"] != "exists"] self.assertEqual(len(vmids), len(set(vmids))) def test_deploying_yields_a_spec_the_engine_can_run(self): @@ -276,6 +292,64 @@ class TestEcran(unittest.TestCase): self.assertIn("ip=", vm["ipconfig"]) self.assertEqual(spec["install"]["branch"], "develop") + def _totaux(self, app): + return app.ligne_totaux + + def test_the_totals_line_shows_the_room_left_on_the_storage(self): + async def rien(app, pilote): + pass + + ligne = self._totaux(self._rendu(rien)) + # « pvesm status » donne déjà la place : la demande du plan s'affiche + # donc à côté d'elle, sans un aller-retour de plus vers l'hôte. + self.assertIn("/ 90 G", ligne) + self.assertIn("local-lvm", ligne) + + def test_changing_the_storage_changes_the_room(self): + # La marque de génération ne vaut que pour les widgets de RANGÉE : + # l'exiger des widgets globaux faisait taire tous les réglages + # communs, stockage compris. + async def gestes(app, pilote): + from textual.widgets import Select + + app.query_one("#f_storage", Select).value = "local" + await pilote.pause() + await pilote.pause() + + ligne = self._totaux(self._rendu(gestes)) + self.assertIn("/ 12 G", ligne) + self.assertIn("local", ligne) + + def test_a_plan_bigger_than_the_storage_is_flagged(self): + async def gestes(app, pilote): + from textual.widgets import Select + + app.query_one("#f_storage", Select).value = "local" + await pilote.pause() + await pilote.pause() + + self.assertIn("⚠", self._totaux(self._rendu(gestes))) + + def test_a_common_setting_reaches_every_vm(self): + async def gestes(app, pilote): + # « value = True » sur le bouton : action_next_button() ne + # déplace que la surbrillance et n'émet aucun message. + list(app.query("#f_profile RadioButton"))[2].value = True + await pilote.pause() + await pilote.pause() + + app = self._rendu(gestes) + self.assertEqual(app.profile, "3") + self.assertTrue(all(r["vm"]["ram"] == 6144 for r in app.rows)) + + def test_the_resource_label_survives_the_markup(self): + # « [x1] » se faisait manger : Static lit le balisage Rich, et une + # balise inconnue disparaît avec son contenu. + async def rien(app, pilote): + pass + + self.assertIn("x1", self._totaux(self._rendu(rien))) + def test_text_prompts_are_not_a_cancellation(self): # {} n'est pas None : l'appelant distingue « annulé » de # « pose-moi les questions à l'ancienne ». diff --git a/test/test_qemu_deploy_form_disk.py b/test/test_qemu_deploy_form_disk.py new file mode 100644 index 0000000..b39decf --- /dev/null +++ b/test/test_qemu_deploy_form_disk.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La ligne de totaux du formulaire QEMU/KVM : la place, pas seulement la +demande. + +La ligne disait « ~126 G » sans dire sur quoi : on découvrait au déploiement +qu'il ne restait pas la place. Elle dit maintenant la demande, ce qui reste et +la capacité — et elle prévient quand ça ne rentre pas, pour les trois limites +(RAM, disque, cœurs) au lieu d'une seule à la fois. + +La sonde disque est vérifiée à part : elle doit tomber sur une partition qui +existe même quand le répertoire d'images n'a jamais été créé. +""" + +import asyncio +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +try: + import textual # noqa: F401 + + TEXTUAL = True +except Exception: # pragma: no cover - dépend de l'environnement + TEXTUAL = False + + +class TestSondeDisque(unittest.TestCase): + def test_it_measures_the_filesystem_that_will_hold_the_disks(self): + libre, total = TODO._host_disk_gb() + self.assertGreater(total, 0) + self.assertLessEqual(libre, total) + + def test_a_missing_directory_falls_back_to_a_parent(self): + # /var/lib/libvirt/images n'existe qu'après le premier déploiement : + # sans repli, la place s'afficherait comme inconnue sur une machine + # neuve, là où elle est justement la plus utile. + libre, total = TODO._host_disk_gb("/n/existe/pas/du/tout") + self.assertGreater(total, 0) + + def test_it_never_raises(self): + self.assertEqual(len(TODO._host_disk_gb("")), 2) + + +def contexte(): + """Contexte minimal du formulaire, avec des mesures CHOISIES : la ligne + doit se lire pareil quelle que soit la machine qui lance le test.""" + todo = TODO.__new__(TODO) + mod = todo._qemu_import_module() + todo._qemu_list_domains = lambda: [] + todo._qemu_branch_list = lambda: ["develop", "master"] + ctx = todo._qemu_form_context(mod) + ctx["free_disk"] = 500 + ctx["total_disk"] = 900 + ctx["free_ram"] = 64000 + ctx["host_cpu"] = 64 + return ctx + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLigneDeTotaux(unittest.TestCase): + def _ligne(self, combien, ctx=None): + from script.todo.qemu_deploy_form import run_deploy_form + + ctx = ctx or contexte() + vu = {} + + async def scenario(): + from textual.widgets import SelectionList, Static + + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 50)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + for i in range(combien): + liste.select(liste.get_option_at_index(i).value) + await pilote.pause() + await pilote.pause() + widget = app.query_one("#totals", Static) + vu["ligne"] = str( + getattr(widget, "_content", "") or widget.render() + ) + vu["vms"] = len(app.vms) + + asyncio.run(scenario()) + return vu + + def test_nothing_ticked_says_how_to_fill_the_list(self): + # Un total à zéro n'apprend rien. + self.assertNotIn("~", self._ligne(0)["ligne"]) + + def test_it_shows_the_demand_the_room_and_the_capacity(self): + ligne = self._ligne(2)["ligne"] + self.assertIn("~", ligne) + self.assertIn("500 G", ligne) + self.assertIn("900 G", ligne) + + def test_a_plan_bigger_than_the_room_is_flagged(self): + ctx = contexte() + ctx["free_disk"] = 1 + ctx["total_disk"] = 900 + self.assertIn("⚠", self._ligne(2, ctx)["ligne"]) + + def test_the_three_limits_are_reported_together(self): + # N'en montrer qu'une cachait les autres : on corrigeait la première + # pour découvrir la suivante au déploiement. + ctx = contexte() + ctx["free_disk"] = 1 + ctx["free_ram"] = 1 + ctx["host_cpu"] = 1 + ligne = self._ligne(2, ctx)["ligne"] + self.assertEqual(ligne.count("⚠"), 1) + for morceau in ("RAM", "disque", "cœurs"): + self.assertIn(morceau, ligne, ligne) + + def test_an_unknown_measure_shows_no_room_at_all(self): + ctx = contexte() + ctx["free_disk"] = 0 + ctx["total_disk"] = 0 + ligne = self._ligne(2, ctx)["ligne"] + self.assertIn("~", ligne) + self.assertNotIn("/", ligne) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_todo_deploy_form_lib.py b/test/test_todo_deploy_form_lib.py index eec56e3..60af5da 100644 --- a/test/test_todo_deploy_form_lib.py +++ b/test/test_todo_deploy_form_lib.py @@ -52,11 +52,38 @@ class TestRessourcesLibres(unittest.TestCase): self.assertEqual(lib.res_value("8", "", 2), 8) +class TestPlaceDisque(unittest.TestCase): + """La demande du plan ne dit pas si ça rentre : il faut la place à côté.""" + + def test_it_shows_the_demand_the_room_and_the_capacity(self): + self.assertEqual( + lib.disk_note(50, 20, 270), "~50 G / 20 G libres sur 270 G" + ) + + def test_without_a_capacity_it_still_shows_the_room(self): + self.assertEqual(lib.disk_note(50, 20), "~50 G / 20 G libres") + + def test_without_a_measure_it_invents_nothing(self): + # Une place inconnue ne doit pas se lire comme « 0 Go libres ». + self.assertEqual(lib.disk_note(50, 0, 0), "~50 G") + self.assertEqual(lib.disk_note(50, 0, 270), "~50 G") + + def test_bytes_become_whole_gibibytes(self): + self.assertEqual(lib.gib(90 * (1 << 30)), 90) + self.assertEqual(lib.gib(0), 0) + # Une mesure absente ou illisible vaut zéro, pas une exception : la + # ligne de totaux doit s'afficher même quand l'hôte n'a rien répondu. + self.assertEqual(lib.gib(None), 0) + self.assertEqual(lib.gib("x"), 0) + + class TestEtatDuNom(unittest.TestCase): """Un nom déjà pris, un disque resté seul : deux gravités différentes.""" def test_a_defined_vm_is_skipped(self): - etat, note = lib.vm_status("erplibre-debian-13", ["erplibre-debian-13"]) + etat, note = lib.vm_status( + "erplibre-debian-13", ["erplibre-debian-13"] + ) self.assertEqual(etat, "exists") self.assertTrue(note) @@ -105,7 +132,9 @@ def entree(distro, version, arch="amd64"): class TestSocleDuPlan(unittest.TestCase): def setUp(self): - self.app = FauxPlan([entree("debian", "13"), entree("ubuntu", "26.04")]) + self.app = FauxPlan( + [entree("debian", "13"), entree("ubuntu", "26.04")] + ) self.app.rows = [ { "vm": { @@ -190,7 +219,9 @@ def membres(chemin, classe=None): arbre = ast.parse(pathlib.Path(chemin).read_text(encoding="utf-8")) trouves = set() for n in ast.walk(arbre): - if isinstance(n, ast.ClassDef) and (classe is None or n.name == classe): + if isinstance(n, ast.ClassDef) and ( + classe is None or n.name == classe + ): trouves |= { m.name for m in n.body if isinstance(m, ast.FunctionDef) }