diff --git a/script/todo/todo.py b/script/todo/todo.py index ca88ff4..168a072 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2928,6 +2928,107 @@ class TODO: except (OSError, subprocess.SubprocessError, ValueError): return 0, 0 + @staticmethod + def _fmt_uptime(secs): + """Durée depuis le démarrage, en six caractères au plus. + + « _fmt_dur » s'arrête aux minutes — bon pour une installation, illisible + pour une VM debout depuis trois jours. Ici la précision décroît avec la + durée : personne ne lit les secondes d'un uptime de 19 heures.""" + secs = int(secs) + if secs < 60: + return f"{secs}s" + if secs < 3600: + return f"{secs // 60}m" + if secs < 86400: + return f"{secs // 3600}h{(secs % 3600) // 60:02d}" + days = secs // 86400 + # Au-delà de 99 jours, les heures ne rentrent plus dans la colonne — et + # personne ne les lit sur une machine debout depuis un an. + if days >= 100: + return f"{days}j" + return f"{days}j{(secs % 86400) // 3600:02d}h" + + @staticmethod + def _qemu_domain_uptime(name): + """Secondes depuis le démarrage du domaine, ou None. + + libvirt n'expose pas l'uptime d'un invité : ni dominfo, ni domstats, ni + l'agent. Mais le processus QEMU du domaine est né avec lui, et son âge + est donc exactement celui de la VM. « guest=, » est le motif que + libvirt met dans sa ligne de commande — la virgule évite qu'un nom + préfixe d'un autre matche à sa place.""" + try: + res = subprocess.run( + ["pgrep", "-f", f"guest={name},"], + capture_output=True, + text=True, + timeout=10, + ) + pid = (res.stdout or "").split()[0] + age = subprocess.run( + ["ps", "-o", "etimes=", "-p", pid], + capture_output=True, + text=True, + timeout=10, + ) + return int((age.stdout or "").strip()) + except (OSError, subprocess.SubprocessError, ValueError, IndexError): + return None + + @staticmethod + def _qemu_dommemstat(name): + """(utilisée, totale) en KiO vues par l'INVITÉ, ou (0, 0). + + « available » est ce que l'invité voit, « usable » ce qu'il peut encore + rendre : leur différence est son « used », à quelques mégaoctets près — + calibré contre le « free » de deux VM (1186 contre 1216, 4831 contre + 4838). « unused » ne convient pas : il ignore le cache, et donnait + 10,8 Go d'« utilisé » sur une VM qui en occupait 1,2. + + La période de collecte est posée d'abord, et c'est indispensable : sans + elle le ballon ne rafraîchit rien, et une VM qui occupait 4,8 Go en + annonçait 490 Mo — vécu. « --live » ne touche pas le XML : le réglage + disparaît au prochain démarrage du domaine.""" + try: + subprocess.run( + [ + "sudo", + "virsh", + "dommemstat", + name, + "--period", + "5", + "--live", + ], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + res = subprocess.run( + ["sudo", "virsh", "dommemstat", name], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return 0, 0 + stat = {} + for line in (res.stdout or "").splitlines(): + parts = line.split() + if len(parts) == 2: + try: + stat[parts[0]] = int(parts[1]) + except ValueError: + continue + total = stat.get("available", 0) + usable = stat.get("usable", 0) + if not total or not usable: + return 0, total + return max(0, total - usable), total + def _qemu_list_vms_advanced(self): """Tableau détaillé par VM : état, vCPU, RAM allouée, disque (virtuel + réel), plus l'espace total disponible du stockage des images.""" @@ -2936,9 +3037,12 @@ class TODO: print(f"\n{t('No VM found.')}") return g = 1 << 30 + # Largeurs serrées pour que la ligne tienne en 80 colonnes AVEC le nom + # entier : c'est lui qui distingue les machines, et « erplibre-ubuntu- + # 2604-gno » tronqué ne distingue plus rien. header = ( - f"\n{'VM':<28} {'État':<10} {'vCPU':>4} {'RAM':>8} " - f"{'Disque':>9} {'Réel':>9}" + f"\n{'VM':<26} {'État':<8} {'vCPU':>4} {'RAM':>10} " + f"{'Disque':>7} {'Réel':>7} {'Uptime':>6}" ) print(header) print("─" * len(header.strip())) @@ -2951,9 +3055,27 @@ class TODO: if disk: disk_dirs.add(os.path.dirname(disk)) ram_g = (mem_kib * 1024) / g if mem_kib else 0 + # « RAM » dit désormais l'USAGE et non la seule allocation : sur un + # hyperviseur, savoir qu'une VM de 32 Go n'en occupe que 4,7 décide + # s'il reste de la place pour la suivante. Deux nombres dans une + # colonne plutôt que deux colonnes — le tableau tient encore sur + # une ligne de terminal. + used_kib, _total_kib = self._qemu_dommemstat(name) + # Le total sans décimale quand il est entier — une allocation + # vaut 8, 12 ou 32 Go, jamais 32,0. + alloc = f"{ram_g:.0f}" if ram_g == int(ram_g) else f"{ram_g:.1f}" + ram = ( + f"{used_kib * 1024 / g:.1f}G/{alloc}G" + if used_kib + else f"-/{alloc}G" + ) + # L'uptime vient de l'âge du processus QEMU : libvirt ne l'expose + # nulle part, et ce processus est né avec le domaine. + up = self._qemu_domain_uptime(name) print( - f"{name:<28.28} {state:<10.10} {vcpus:>4} " - f"{ram_g:>7.1f}G {virt / g:>8.1f}G {actual / g:>8.1f}G" + f"{name:<26.26} {state:<8.8} {vcpus:>4} " + f"{ram:>10} {virt / g:>6.1f}G {actual / g:>6.1f}G " + f"{self._fmt_uptime(up) if up else '-':>6}" ) # Espace total disponible sur le(s) stockage(s) des disques. for d in sorted(disk_dirs) or ["/var/lib/libvirt/images"]: diff --git a/test/test_qemu_vm_table.py b/test/test_qemu_vm_table.py new file mode 100644 index 0000000..b479f19 --- /dev/null +++ b/test/test_qemu_vm_table.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Tableau des infos avancées : RAM utilisée, uptime, largeur. + +Deux colonnes ont changé de sens. « RAM » disait l'allocation, elle dit +maintenant l'usage — sur un hyperviseur, savoir qu'une VM de 32 Go n'en occupe +que 4,7 décide s'il reste de la place pour la suivante. Et l'uptime est apparu : +libvirt ne l'expose nulle part, mais le processus QEMU du domaine est né avec +lui. + +Ce que ces tests gardent : la formule de la RAM, calibrée contre le « free » de +deux VM réelles, et la largeur de la ligne — un tableau qui déborde de 80 +colonnes se replie et devient illisible. +""" + +import io +import contextlib +import subprocess +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +# Sortie réelle de « virsh dommemstat » sur la VM de migration, en KiO. Le +# « free » de l'invité disait alors : total 11955, used 1216. +DOMMEMSTAT = """actual 12582912 +swap_in 0 +swap_out 0 +major_fault 0 +minor_fault 0 +unused 1197056 +available 12242432 +usable 11027456 +last_update 1787359366 +disk_caches 8211456 +""" + + +class TestUptimeFormat(unittest.TestCase): + def test_seconds_then_minutes_then_hours_then_days(self): + self.assertEqual("45s", TODO._fmt_uptime(45)) + self.assertEqual("12m", TODO._fmt_uptime(12 * 60 + 30)) + self.assertEqual("19h55", TODO._fmt_uptime(19 * 3600 + 55 * 60)) + self.assertEqual("10j13h", TODO._fmt_uptime(10 * 86400 + 13 * 3600)) + + def test_it_never_exceeds_six_characters(self): + """La colonne fait six caractères : au-delà, le tableau se décale.""" + for secs in (0, 59, 60, 3599, 3600, 86399, 86400, 400 * 86400): + self.assertLessEqual(len(TODO._fmt_uptime(secs)), 6, secs) + + def test_precision_drops_as_the_duration_grows(self): + """Personne ne lit les secondes d'un uptime de dix jours.""" + self.assertNotIn("s", TODO._fmt_uptime(19 * 3600)) + self.assertNotIn("m", TODO._fmt_uptime(10 * 86400)) + + +class TestMemStat(unittest.TestCase): + def _stat(self, out): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 0, out, ""), + ): + return TODO._qemu_dommemstat("vm") + + def test_used_is_available_minus_usable(self): + """Calibré contre le « free » de deux VM : 1186 contre 1216 Mo lus dans + l'invité, et 4831 contre 4838 sur l'autre. « available - unused » + donnait 10,8 Go pour une VM qui en occupait 1,2 — il compte le cache. + """ + used, total = self._stat(DOMMEMSTAT) + self.assertAlmostEqual(used / 1024, 1186, delta=5) + self.assertAlmostEqual(total / 1024, 11955, delta=5) + + def test_a_stopped_vm_gives_zero_without_crashing(self): + used, total = self._stat("") + self.assertEqual((0, 0), (used, total)) + + def test_it_survives_virsh_failing(self): + with mock.patch("subprocess.run", side_effect=OSError): + self.assertEqual((0, 0), TODO._qemu_dommemstat("vm")) + + def test_missing_usable_is_not_taken_for_zero_use(self): + """Sans « usable », on ne sait pas : mieux vaut ne rien dire que + d'annoncer une VM qui n'utiliserait rien.""" + used, total = self._stat("available 12242432\nunused 1197056\n") + self.assertEqual(0, used) + self.assertGreater(total, 0) + + def test_it_asks_for_a_collection_period_first(self): + """Sans période, le ballon ne rafraîchit rien : une VM qui occupait + 4,8 Go en annonçait 490 Mo — vécu. « --live » ne touche pas le XML.""" + calls = [] + + def fake_run(cmd, **kw): + calls.append(cmd) + return subprocess.CompletedProcess([], 0, DOMMEMSTAT, "") + + with mock.patch("subprocess.run", side_effect=fake_run): + TODO._qemu_dommemstat("vm") + self.assertIn("--period", calls[0]) + self.assertIn("--live", calls[0]) + + +class TestDomainUptime(unittest.TestCase): + def test_it_reads_the_age_of_the_qemu_process(self): + outs = [ + subprocess.CompletedProcess([], 0, "1137455\n", ""), + subprocess.CompletedProcess([], 0, " 1195\n", ""), + ] + with mock.patch("subprocess.run", side_effect=outs): + self.assertEqual(1195, TODO._qemu_domain_uptime("vm")) + + def test_the_pattern_ends_with_a_comma(self): + """« guest=vm, » et non « guest=vm » : sinon « vm » matcherait aussi + « vm-2 », et l'uptime affiché serait celui d'une autre machine.""" + seen = {} + + def fake_run(cmd, **kw): + seen.setdefault("cmd", cmd) + return subprocess.CompletedProcess([], 0, "", "") + + with mock.patch("subprocess.run", side_effect=fake_run): + TODO._qemu_domain_uptime("vm") + self.assertIn("guest=vm,", seen["cmd"]) + + def test_a_stopped_domain_has_no_uptime(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 1, "", ""), + ): + self.assertIsNone(TODO._qemu_domain_uptime("vm")) + + +class TestTheTable(unittest.TestCase): + def _render(self, uptime=1195, mem=(1214 * 1024, 11955 * 1024)): + todo = TODO.__new__(TODO) + todo._qemu_list_domains = lambda: ["erplibre-ubuntu-2604-gnome"] + todo._qemu_domstate = lambda n: "running" + todo._qemu_dominfo = staticmethod(lambda n: (8, 12 * 1024 * 1024)) + todo._qemu_main_disk = lambda n: "/var/lib/libvirt/images/x.qcow2" + todo._qemu_disk_sizes = staticmethod( + lambda d: (65 * (1 << 30), 62 * (1 << 30)) + ) + todo._qemu_dommemstat = staticmethod(lambda n: mem) + todo._qemu_domain_uptime = staticmethod(lambda n: uptime) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + todo._qemu_list_vms_advanced() + return buf.getvalue() + + def test_every_line_fits_in_eighty_columns(self): + """Le tableau est lu dans un terminal : au-delà de 80, il se replie.""" + for line in self._render().splitlines(): + if line.startswith("Stockage") or not line.strip(): + continue + self.assertLessEqual(len(line), 80, line) + + def test_ram_shows_use_over_allocation(self): + out = self._render() + self.assertIn("1.2G/12G", out) + + def test_the_uptime_column_is_there(self): + out = self._render() + self.assertIn("Uptime", out) + self.assertIn("19m", out) + + def test_a_vm_without_stats_shows_a_dash_not_a_zero(self): + """« 0.0G/12G » ferait croire à une VM au repos ; « -/12G » dit qu'on + ne sait pas.""" + out = self._render(uptime=None, mem=(0, 0)) + self.assertIn("-/12G", out) + self.assertNotIn("0.0G/12G", out) + + def test_the_full_vm_name_survives_when_it_fits(self): + """C'est le nom qui distingue les machines : le tronquer trop tôt les + rend indiscernables.""" + self.assertIn("erplibre-ubuntu-2604-gnome", self._render()) + + +if __name__ == "__main__": + unittest.main()