[IMP] script todo: RAM utilisée et uptime dans les infos avancées d'une VM
« 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
2026-08-21 21:17:30 -04:00
|
|
|
#!/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 contextlib
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
import io
|
[IMP] script todo: RAM utilisée et uptime dans les infos avancées d'une VM
« 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
2026-08-21 21:17:30 -04:00
|
|
|
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()
|