« RAM » disait l'allocation. Elle dit maintenant l'usage, dans la même colonne : « 4.7G/32G ». 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 — l'allocation seule ne le dit pas. La formule est « available - usable » de virsh dommemstat, calibrée contre le « free » de deux VM : 1186 contre 1216 Mo, et 4831 contre 4838. Et la période de collecte est posée d'abord, sans quoi le ballon ne rafraîchit rien — une VM qui occupait 4,8 Go en annonçait 490 Mo. L'uptime vient de l'âge du processus QEMU : libvirt ne l'expose ni dans dominfo, ni dans domstats, ni par l'agent, mais ce processus est né avec le domaine. Les colonnes ont été resserrées pour que la ligne tienne en 80 caractères avec le nom entier de la VM. --- EN --- "RAM" showed the allocation. It now shows the use, in the same column: "4.7G/32G". On a hypervisor, knowing that a 32 GB VM only occupies 4.7 decides whether there is room for the next one — the allocation alone does not say. The formula is virsh dommemstat's "available - usable", calibrated against the "free" of two VMs: 1186 against 1216 MB, and 4831 against 4838. And the collection period is set first, without which the balloon refreshes nothing — a VM using 4.8 GB reported 490 MB. Uptime comes from the age of the QEMU process: libvirt exposes it neither in dominfo, nor domstats, nor through the agent, but that process was born with the domain. Columns were tightened so the line fits in 80 characters with the VM's full name. Assisted-by: Claude Opus 5
184 lines
7 KiB
Python
184 lines
7 KiB
Python
#!/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()
|