[ADD] suivi : les statistiques de chaque VM (écriture, RAM, disque)

Le tableau disait la durée et la taille du disque. Il ne disait pas si une VM
TRAVAILLAIT : une installation figée et une qui compile s'y ressemblaient.

Trois chiffres par VM, d'un seul appel « virsh domstats » pour tout le parc
(0,03 s) : ce qu'elle écrit, sa RAM occupée/totale, son disque occupé/total.
Le débit est une moyenne sur DIX secondes — le disque d'une installation
travaille par rafales, et l'instantané n'y montrait que des 0 et des pics.

Le ballon mémoire est réarmé au tour lent : sans période de collecte, libvirt
rend le dernier rapport du pilote, vieux d'une demi-heure. Et cinq caractères
récupérés sur trois colonnes trop larges font tenir la ligne en 150 colonnes.

--- EN ---

The table showed elapsed time and disk size. It did not show whether a VM was
WORKING: a stalled install and a compiling one looked alike.

Three numbers per VM, from a single "virsh domstats" call for the whole fleet
(0.03 s): bytes written, RAM used/total, disk used/total. The write figure is
a TEN-second average — an install's disk works in bursts, and the snapshot
showed only zeros and spikes.

The memory balloon is re-armed on the slow tick: with no collection period,
libvirt hands back the driver's last report, half an hour old. And five
characters reclaimed from three oversized columns keep the row inside 150.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-22 06:13:45 -04:00
parent b352f6fbf1
commit 6a24687fe0
3 changed files with 723 additions and 11 deletions

View file

@ -1059,6 +1059,245 @@ def virsh_domstates() -> dict:
return states
# Fenêtre du débit d'écriture. Dix secondes : le disque d'une VM en
# installation travaille par rafales — « apt » décompresse, « poetry » compile,
# puis plus rien pendant trois secondes. Un instantané de deux secondes affiche
# donc tantôt 0, tantôt 200 Mo/s, et ne dit rien. Sur dix secondes, le chiffre
# devient un débit qu'on peut comparer d'une VM à l'autre.
WRITE_WINDOW = 10.0
# Âge maximal d'un relevé du ballon mémoire. Au-delà, la valeur est TAISÉE
# plutôt que montrée : virtio-balloon ne publie ses compteurs que si une
# période de collecte est armée, et une valeur figée depuis une demi-heure
# ferait croire à une VM au repos alors qu'elle compile.
BALLOON_MAX_AGE = 30.0
# Les compteurs du ballon sont en kibioctets ; ceux des blocs, en octets.
_KIB = 1024
def parse_domstats(text: str) -> dict:
"""Sortie de « virsh domstats » -> {nom: relevé}.
Un relevé porte : ram_used / ram_total (octets), ram_at (horodatage du
dernier rapport du ballon), wr_bytes (cumul écrit depuis le démarrage du
processus QEMU), disk_used / disk_total (octets).
Le seed ISO est ÉCARTÉ des disques : monté en lecture seule, il n'est
jamais écrit, et sa capacité (quelques mégaoctets) l'aurait fait passer
pour le disque système sur une VM dont le qcow2 n'est pas encore alloué.
"""
out = {}
nom = None
brut = {}
def clore():
if nom is None:
return
rec = {
"ram_used": 0,
"ram_total": 0,
"ram_at": 0,
"wr_bytes": 0,
"disk_used": 0,
"disk_total": 0,
}
dispo = brut.get("balloon.available")
util = brut.get("balloon.usable")
if dispo:
rec["ram_total"] = dispo * _KIB
if util is not None:
rec["ram_used"] = max(0, (dispo - util)) * _KIB
rec["ram_at"] = brut.get("balloon.last-update") or 0
# Disques : on parcourt les index déclarés par block.count.
meilleur = 0
for i in range(int(brut.get("block.count") or 0)):
chemin = brut.get(f"block.{i}.path.str") or ""
if chemin.lower().endswith(".iso"):
continue
rec["wr_bytes"] += brut.get(f"block.{i}.wr.bytes") or 0
cap = brut.get(f"block.{i}.capacity") or 0
if cap >= meilleur:
meilleur = cap
rec["disk_total"] = cap
rec["disk_used"] = brut.get(f"block.{i}.allocation") or 0
out[nom] = rec
for ligne in (text or "").splitlines():
ligne = ligne.strip()
if ligne.startswith("Domain:"):
clore()
nom = ligne.split("'")[1] if "'" in ligne else None
brut = {}
continue
if nom is None or "=" not in ligne:
continue
cle, _, val = ligne.partition("=")
try:
brut[cle] = int(val)
except ValueError:
# Les valeurs non numériques (chemins, noms de device) sont
# gardées à part : « block.0.path » en est une, et c'est elle qui
# démasque le seed ISO.
brut[f"{cle}.str"] = val
clore()
return out
class WriteWindow:
"""Débit d'écriture moyen par VM, sur une fenêtre glissante."""
def __init__(self, window=WRITE_WINDOW):
self.window = window
self._hist = {}
def add(self, name, wr_bytes, now):
hist = self._hist.setdefault(name, [])
# Un compteur qui RECULE veut dire que le domaine a redémarré : le
# processus QEMU est neuf, ses compteurs repartent de zéro. Sans ce
# garde, le débit affiché serait négatif, puis énorme au relevé
# suivant. Une installation redémarre la VM : le cas est la règle.
if hist and wr_bytes < hist[-1][1]:
hist.clear()
hist.append((now, wr_bytes))
limite = now - self.window
while len(hist) > 2 and hist[1][0] < limite:
hist.pop(0)
def rate(self, name):
"""Octets/s, ou None tant que la fenêtre n'a pas de quoi conclure."""
hist = self._hist.get(name) or []
if len(hist) < 2:
return None
span = hist[-1][0] - hist[0][0]
if span < 1.0:
return None
return max(0.0, (hist[-1][1] - hist[0][1]) / span)
def total(self, name):
"""Écrit depuis le premier relevé de la fenêtre (octets)."""
hist = self._hist.get(name) or []
return hist[-1][1] if hist else None
def fmt_rate(bps) -> str:
"""Octets/s -> « 12.3M/s ». « - » tant qu'on ne sait pas."""
return "-" if bps is None else f"{_fmt_size(int(bps))}/s"
def _fmt_tight(nbytes) -> str:
"""Comme _fmt_size, mais sans décimale au-delà de dix unités.
« 63G » plutôt que « 62.6G » : dans une colonne de tableau, ces deux
caractères décident si « Disque » reste visible ou sort de l'écran, et la
décimale n'apprend rien à côté d'un total de 65 Go.
"""
if nbytes is None:
return "-"
for unit, div in (("T", 1 << 40), ("G", 1 << 30), ("M", 1 << 20)):
if nbytes >= div:
val = nbytes / div
return f"{val:.0f}{unit}" if val >= 10 else f"{val:.1f}{unit}"
return f"{max(0, int(nbytes)) // 1024}K"
def fmt_pair(used, total) -> str:
"""« 1.1G/12G », « 63G/65G ». « - » si le total manque : « ?/12G »
n'informe pas."""
if not total:
return "-"
return f"{_fmt_tight(used)}/{_fmt_tight(total)}"
def fmt_pct(used, total) -> str:
return f" ({int(used / total * 100)}%)" if total else ""
def ram_pair(rec, now, max_age=BALLOON_MAX_AGE) -> str:
"""RAM utilisée/totale de la VM, ou « - » si le relevé est PÉRIMÉ."""
if not rec or not rec.get("ram_total"):
return "-"
at = rec.get("ram_at") or 0
if at and now - at > max_age:
return "-"
return fmt_pair(rec.get("ram_used"), rec["ram_total"])
def vm_stats_line(name, rec, bps, now, ecrit=None) -> str:
"""Section statistiques d'UNE VM, en une ligne dense.
Le tableau porte les mêmes chiffres en colonnes, pour tout le parc d'un
coup d'œil ; cette ligne les détaille pour la VM sélectionnée — celle dont
le journal et la commande SSH sont déjà affichés.
"""
if not rec:
return f" 📊 {name} · {t('no statistics yet')}"
bits = [f"✍ {fmt_rate(bps)} ({t('10s average')})"]
if ecrit:
bits.append(f"{t('total written')} {_fmt_size(ecrit)}")
ram = ram_pair(rec, now)
if ram != "-":
bits.append(
f"🧠 RAM {ram}{fmt_pct(rec['ram_used'], rec['ram_total'])}"
)
if rec.get("disk_total"):
bits.append(
f"💾 {t('disk')} "
f"{fmt_pair(rec['disk_used'], rec['disk_total'])}"
f"{fmt_pct(rec['disk_used'], rec['disk_total'])}"
)
return f" 📊 {name} · " + " · ".join(bits)
def read_domstats() -> str:
"""Sortie brute de « virsh domstats --balloon --block » (tout le parc).
UN appel pour toutes les VM — 0,03 s mesuré sur deux domaines. Le suivi
relève toutes les deux secondes : une commande par VM y coûterait N
processus à chaque tour."""
try:
res = subprocess.run(
["sudo", "virsh", "domstats", "--balloon", "--block"],
capture_output=True,
text=True,
timeout=15,
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
)
except (OSError, subprocess.SubprocessError):
return ""
return res.stdout if res.returncode == 0 else ""
def arm_balloon(names) -> None:
"""Arme la période de collecte du ballon (5 s) sur chaque VM.
Sans elle, « balloon.available » et « balloon.usable » restent FIGÉS sur le
dernier rapport du pilote : mesuré sur une VM fraîche, 388 Mo annoncés
contre 1,1 Go réellement occupés, avec un horodatage vieux d'une
demi-heure. La période se perd quand le domaine redémarre — ce qu'une
installation fait — donc on la réarme à intervalle lent.
"""
for name in names or ():
try:
subprocess.run(
[
"sudo",
"virsh",
"dommemstat",
name,
"--period",
"5",
"--live",
],
capture_output=True,
text=True,
timeout=10,
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
)
except (OSError, subprocess.SubprocessError):
continue
# --------------------------------------------------------------------------- #
# Dashboard Textual
# --------------------------------------------------------------------------- #
@ -1068,13 +1307,26 @@ def virsh_domstates() -> dict:
# que ce qui était affiché au départ.
COL_DEFAULT_WIDTHS = {
"seq": 3,
"vm": 22,
"arch": 7,
# 21 : un caractère de moins que l'ancien 22, et c'est lui qui fait tenir
# la ligne entière sur un terminal de 150 colonnes. « + » l'élargit, et
# c'est déjà la colonne visée par défaut.
"vm": 21,
# 5 : « amd64 », « s390x », « arm64 » font cinq caractères, et l'en-tête
# « Arch » quatre. Les deux de plus ne servaient rien.
"arch": 5,
"err": 4,
"state": 8,
"odoo": 6,
"elapsed": 7,
"disk": 8,
# 4 : une icône (🟢) ou un tiret, sous un en-tête de quatre lettres.
"odoo": 4,
# 6 : « 125:30 » est le pire cas d'une installation de deux heures.
"elapsed": 6,
# Section statistiques de la VM : ce qu'elle écrit, sa RAM, son disque.
# « 12.3M/s » tient en 7 et « 1.1G/12G » en 9 : au-delà, « Disque » sortait
# de l'écran sur un terminal de 150 colonnes, moitié prise par le journal.
"wr": 7,
# 10 et non 9 : sur une VM de 128 Go, « 1001M/128G » fait dix caractères.
"ram": 10,
"disk": 9,
}
@ -1369,7 +1621,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
que devinable, et « max-width » laisse la table suivre l'élargissement
des colonnes sans manger tout l'écran. */
DataTable {
width: auto; max-width: 60%; height: 1fr;
width: auto; max-width: 66%; height: 1fr;
overflow-x: auto; overflow-y: auto;
scrollbar-size-horizontal: 1; scrollbar-size-vertical: 1;
border: solid $accent;
@ -1444,6 +1696,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
)
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
self._domstate = {}
# Statistiques par VM : dernier relevé libvirt, et la fenêtre
# glissante qui en tire un débit d'écriture.
self._vmstats = {}
self._wrate = WriteWindow()
# Erreurs détectées dans le log à la complétion : {nom: (err, warn)}.
self._errcount = {}
# Sommaire de stats déplié (clic) ou non.
@ -1498,6 +1754,13 @@ def run_monitor(manifest_path: str, run_app: bool = True):
table.add_column(
"Durée", key="elapsed", width=COL_DEFAULT_WIDTHS["elapsed"]
)
# Statistiques de la VM. « Écrit/s » est une MOYENNE sur dix
# secondes : le disque d'une installation travaille par rafales,
# et l'instantané n'y montrait que des 0 et des pics.
table.add_column(
"Écrit/s", key="wr", width=COL_DEFAULT_WIDTHS["wr"]
)
table.add_column("RAM", key="ram", width=COL_DEFAULT_WIDTHS["ram"])
table.add_column(
"Disque", key="disk", width=COL_DEFAULT_WIDTHS["disk"]
)
@ -1511,6 +1774,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
"—",
"--:--",
"-",
"-",
"-",
key=vm["name"],
)
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
@ -1525,6 +1790,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# Sommaire de stats en CHIFFRES (cliquable -> détail).
yield Static("", id="stats")
yield Static("", id="statsdetail")
# Section statistiques de la VM SÉLECTIONNÉE : les mêmes chiffres
# que ses colonnes, mais détaillés (pourcentages, cumul écrit).
# Une ligne par VM du parc aurait chassé le pied de page dès cinq
# machines ; la sélection suit déjà le journal et la barre SSH.
yield Static("", id="vmstats")
yield Static("", id="sshbar")
yield Footer()
@ -1536,6 +1806,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
)
self.sub_title = f"0/{len(vms)} {t('completed')}"
self._refresh_ssh()
self._refresh_vmstats()
self._load_selected_log(reset=True)
# Table toutes les 2 s (30 lectures de fin de log), suivi du log
# sélectionné toutes les 1 s (une seule lecture incrémentale).
@ -1551,6 +1822,25 @@ def run_monitor(manifest_path: str, run_app: bool = True):
def _vm_by_name(self, name):
return next((v for v in vms if v["name"] == name), None)
def _refresh_vmstats(self):
"""Ligne de statistiques de la VM sélectionnée."""
name = self._selected
if not name:
return
try:
bar = self.query_one("#vmstats", Static)
except Exception:
return
bar.update(
vm_stats_line(
name,
self._vmstats.get(name),
self._wrate.rate(name),
time.time(),
self._wrate.total(name),
)
)
def _refresh_ssh(self):
vm = self._vm_by_name(self._selected)
bar = self.query_one("#sshbar", Static)
@ -1612,9 +1902,27 @@ def run_monitor(manifest_path: str, run_app: bool = True):
"""(THREAD) statut + taille disque de chaque VM + télémétrie. AUCUNE
mise à jour d'UI ici : uniquement des I/O bloquantes déportées."""
disks, status, errors, odoo = {}, {}, {}, {}
# UN appel virsh pour tout le parc, dans ce thread : le débit se
# 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())
now_s = time.time()
for name, rec in stats.items():
self._wrate.add(name, rec["wr_bytes"], now_s)
self._vmstats = stats
wr, ram = {}, {}
for vm in vms:
name = vm["name"]
disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm)))
rec = stats.get(name)
wr[name] = fmt_rate(self._wrate.rate(name))
ram[name] = ram_pair(rec, now_s)
if rec and rec.get("disk_total"):
disks[name] = fmt_pair(rec["disk_used"], rec["disk_total"])
else:
# Domaine pas encore défini (conversion de l'image, tout
# début de l'installation) : le qcow2 existe déjà, et
# st_blocks dit ce qu'il occupe. Sans total à annoncer.
disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm)))
if (
name not in self._final
and self._domstate.get(name) != "gone"
@ -1634,16 +1942,22 @@ def run_monitor(manifest_path: str, run_app: bool = True):
):
if _port_open(vm.get("ip"), 8069):
odoo[name] = True
return disks, status, self._collect_tele(), errors, odoo
return disks, status, self._collect_tele(), errors, odoo, wr, ram
async def _tick_table(self):
# I/O (lectures de logs, stat disque, /proc) DÉPORTÉES en thread ->
# la boucle d'événements Textual reste fluide même sous forte
# charge ou disque lent. Les mises à jour d'UI restent sur la boucle.
try:
disks, status, tele, errors, odoo = await asyncio.to_thread(
self._collect_table
)
(
disks,
status,
tele,
errors,
odoo,
wr,
ram,
) = await asyncio.to_thread(self._collect_table)
except Exception:
return
self._errcount.update(errors)
@ -1655,6 +1969,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
for vm in vms:
name = vm["name"]
self._set_cell(table, name, "disk", disks.get(name, "-"))
self._set_cell(table, name, "wr", wr.get(name, "-"))
self._set_cell(table, name, "ram", ram.get(name, "-"))
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
self._set_cell(
table,
@ -1743,6 +2059,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
if tele:
self.query_one("#telemetry", Static).update(tele)
self._update_stats()
# Les chiffres de la VM sélectionnée viennent d'être relevés :
# sa section les redit ici, détaillés.
self._refresh_vmstats()
except Exception:
pass
@ -1849,6 +2168,14 @@ def run_monitor(manifest_path: str, run_app: bool = True):
return
for vm in vms:
self._domstate[vm["name"]] = states.get(vm["name"], "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.
vivantes = [
vm["name"] for vm in vms if states.get(vm["name"]) == "running"
]
if vivantes:
await asyncio.to_thread(arm_balloon, vivantes)
# L'adresse est relue au même rythme. Le processus détaché suivait
# déjà la VM quand son bail changeait, mais les VUES gardaient celle
@ -1889,6 +2216,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
return
self._selected = name
self._refresh_ssh()
self._refresh_vmstats()
self._load_selected_log(reset=True)
def action_follow(self) -> None:

View file

@ -3103,6 +3103,18 @@ TRANSLATIONS = {
"fr": "Nœud de rendu",
"en": "Render node",
},
"no statistics yet": {
"fr": "pas encore de statistiques",
"en": "no statistics yet",
},
"10s average": {
"fr": "moyenne 10 s",
"en": "10s average",
},
"total written": {
"fr": "cumul écrit",
"en": "total written",
},
"Screens": {
"fr": "Écrans",
"en": "Screens",

View file

@ -0,0 +1,372 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Statistiques par VM du suivi d'installation : écriture, RAM, disque.
Trois chiffres par VM, tirés d'UN appel « virsh domstats » pour tout le parc :
ce qu'elle écrit (moyenne sur dix secondes), sa RAM occupée/totale, son disque
occupé/total.
Ce que ces tests gardent, appris en les construisant contre des VM réelles :
- Le seed ISO doit être ÉCARTÉ des disques. Monté en lecture seule, il n'est
jamais écrit, et sa capacité minuscule l'aurait fait passer pour le disque
système d'une VM dont le qcow2 n'est pas encore alloué.
- Un compteur d'écriture qui RECULE veut dire que le domaine a redémarré —
ce qu'une installation fait. Sans garde, le débit devient négatif.
- Le ballon mémoire ne publie ses compteurs que si une période de collecte est
armée. Un relevé vieux d'une demi-heure doit être TAISÉ, pas affiché :
mesuré, il annonçait 388 Mo pour une VM qui occupait 1,1 Go.
- La ligne du tableau doit tenir : trois colonnes de plus, et « Disque »
sortait de l'écran sur un terminal de 150 colonnes.
"""
import json
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from script.todo import qemu_install_monitor as mon
from script.todo.qemu_install_monitor import (
BALLOON_MAX_AGE,
COL_DEFAULT_WIDTHS,
WRITE_WINDOW,
WriteWindow,
_fmt_tight,
fmt_pair,
fmt_rate,
parse_domstats,
ram_pair,
vm_stats_line,
)
# Sortie RÉELLE de « virsh domstats --balloon --block » sur deux VM du parc :
# une VM de migration en travail, et son seed ISO en second périphérique.
DOMSTATS = """Domain: 'erplibre-ubuntu-2404-MIGRATION'
balloon.current=12582912
balloon.maximum=12582912
balloon.swap_in=0
balloon.unused=936072
balloon.available=12242352
balloon.usable=11064740
balloon.last-update=1787392761
balloon.rss=12623896
block.count=2
block.0.name=vda
block.0.path=/var/lib/libvirt/images/erplibre-ubuntu-2404.qcow2
block.0.rd.bytes=9664722944
block.0.wr.reqs=2969857
block.0.wr.bytes=165796498432
block.0.allocation=67263266816
block.0.capacity=69793218560
block.0.physical=67265261568
block.1.name=vdb
block.1.path=/var/lib/libvirt/images/iso/erplibre-ubuntu-2404-seed.iso
block.1.wr.bytes=4096
block.1.allocation=380928
block.1.capacity=380928
Domain: 'erplibre-ubuntu-2604'
balloon.current=12582912
balloon.available=12237092
balloon.usable=11054184
balloon.last-update=1787392782
block.count=1
block.0.name=vda
block.0.path=/var/lib/libvirt/images/erplibre-ubuntu-2604.qcow2
block.0.wr.bytes=48117251072
block.0.allocation=43900928000
block.0.capacity=69793218560
"""
MIGRATION = "erplibre-ubuntu-2404-MIGRATION"
class TestLecture(unittest.TestCase):
def test_it_reads_both_domains(self):
st = parse_domstats(DOMSTATS)
self.assertEqual({MIGRATION, "erplibre-ubuntu-2604"}, set(st))
def test_ram_is_available_minus_usable(self):
"""Calibrée en son temps contre le « free » de deux invités : c'est
« available - usable » qui suit ce qu'ils occupent, pas « unused »,
qui compte le cache."""
rec = parse_domstats(DOMSTATS)[MIGRATION]
self.assertEqual(12242352 * 1024, rec["ram_total"])
self.assertEqual((12242352 - 11064740) * 1024, rec["ram_used"])
def test_the_seed_iso_is_left_out_of_the_disk(self):
"""Sa capacité (372 Ko) l'emporterait sur un qcow2 pas encore alloué,
et le tableau annoncerait un disque plein à 100 %."""
rec = parse_domstats(DOMSTATS)[MIGRATION]
self.assertEqual(69793218560, rec["disk_total"])
self.assertEqual(67263266816, rec["disk_used"])
def test_the_seed_iso_writes_are_left_out_too(self):
"""4096 octets écrits sur un ISO en lecture seule : anecdotique ici,
mais compter un périphérique qu'on n'affiche pas rend un débit qu'on
ne peut relier à rien."""
rec = parse_domstats(DOMSTATS)[MIGRATION]
self.assertEqual(165796498432, rec["wr_bytes"])
def test_a_single_disk_vm_is_read_too(self):
rec = parse_domstats(DOMSTATS)["erplibre-ubuntu-2604"]
self.assertEqual(48117251072, rec["wr_bytes"])
self.assertEqual(69793218560, rec["disk_total"])
def test_empty_or_broken_output_gives_nothing(self):
for texte in ("", "erreur: pas de connexion", None):
self.assertEqual({}, parse_domstats(texte))
def test_a_domain_without_balloon_stats_is_not_invented(self):
"""Une VM éteinte, ou dont le pilote n'a rien publié : pas de RAM
plutôt qu'un zéro qui passerait pour une mesure."""
rec = parse_domstats("Domain: 'x'\n block.count=0\n")["x"]
self.assertEqual(0, rec["ram_total"])
self.assertEqual("-", ram_pair(rec, time.time()))
class TestFenetreEcriture(unittest.TestCase):
def test_two_samples_ten_seconds_apart_give_the_rate(self):
w = WriteWindow(10.0)
w.add("vm", 0, 1000.0)
w.add("vm", 10 << 20, 1010.0)
self.assertAlmostEqual(1 << 20, w.rate("vm"), delta=1024)
def test_one_sample_is_not_a_rate(self):
"""Au premier tour, la colonne doit dire « - » et non « 0 » : un débit
nul et un débit inconnu ne se ressemblent pas."""
w = WriteWindow()
w.add("vm", 12345, 1000.0)
self.assertIsNone(w.rate("vm"))
self.assertEqual("-", fmt_rate(w.rate("vm")))
def test_samples_too_close_together_are_not_a_rate(self):
"""Deux relevés à 200 ms d'intervalle donnent un chiffre absurde ;
mieux vaut attendre le tour suivant."""
w = WriteWindow()
w.add("vm", 0, 1000.0)
w.add("vm", 1 << 20, 1000.2)
self.assertIsNone(w.rate("vm"))
def test_the_window_keeps_about_ten_seconds(self):
"""Sinon la moyenne porterait sur toute la durée de l'installation, et
ne montrerait plus ce qui se passe MAINTENANT."""
w = WriteWindow(10.0)
# Une minute de relevés toutes les deux secondes, débit constant.
for i in range(31):
w.add("vm", i * (2 << 20), 1000.0 + 2 * i)
span = w._hist["vm"][-1][0] - w._hist["vm"][0][0]
self.assertLessEqual(span, 12.0)
self.assertGreaterEqual(span, 10.0)
self.assertAlmostEqual(1 << 20, w.rate("vm"), delta=1024)
def test_a_restart_resets_the_window_instead_of_going_negative(self):
"""Le compteur appartient au processus QEMU : un redémarrage du domaine
le remet à zéro, et une installation redémarre la VM."""
w = WriteWindow(10.0)
w.add("vm", 500 << 20, 1000.0)
w.add("vm", 501 << 20, 1002.0)
w.add("vm", 1 << 20, 1004.0) # domaine redémarré
self.assertIsNone(w.rate("vm"))
w.add("vm", 3 << 20, 1014.0)
self.assertIsNotNone(w.rate("vm"))
self.assertGreater(w.rate("vm"), 0)
def test_vms_do_not_mix(self):
w = WriteWindow(10.0)
w.add("a", 0, 1000.0)
w.add("b", 0, 1000.0)
w.add("a", 10 << 20, 1010.0)
w.add("b", 1 << 20, 1010.0)
self.assertAlmostEqual(1 << 20, w.rate("a"), delta=1024)
self.assertAlmostEqual(1 << 20, w.rate("b") * 10, delta=10240)
def test_the_window_is_the_ten_seconds_announced(self):
"""La colonne dit « moyenne 10 s » : la constante doit le tenir."""
self.assertEqual(10.0, WRITE_WINDOW)
class TestFraicheurRam(unittest.TestCase):
def test_a_stale_balloon_report_is_hidden(self):
rec = parse_domstats(DOMSTATS)[MIGRATION]
vieux = rec["ram_at"] + BALLOON_MAX_AGE + 5
self.assertEqual("-", ram_pair(rec, vieux))
def test_a_fresh_report_is_shown(self):
rec = parse_domstats(DOMSTATS)[MIGRATION]
self.assertEqual("1.1G/12G", ram_pair(rec, rec["ram_at"] + 2))
def test_a_report_without_a_date_is_still_shown(self):
"""libvirt joint toujours « last-update » quand le ballon a parlé ;
sans elle on ne sait pas juger, et taire la valeur perdrait la seule
information disponible."""
rec = dict(parse_domstats(DOMSTATS)[MIGRATION], ram_at=0)
self.assertNotEqual("-", ram_pair(rec, time.time()))
class TestFormats(unittest.TestCase):
def test_the_decimal_goes_away_above_ten_units(self):
"""« 63G » plutôt que « 62.6G » : ces deux caractères décident si
« Disque » reste visible."""
self.assertEqual("63G", _fmt_tight(67263266816))
self.assertEqual("1.1G", _fmt_tight(1130479616))
self.assertEqual("12G", _fmt_tight(12536168448))
def test_a_pair_without_a_total_says_nothing(self):
self.assertEqual("-", fmt_pair(1024, 0))
self.assertEqual("-", fmt_pair(None, None))
def test_the_rate_carries_its_unit(self):
self.assertEqual("1.0M/s", fmt_rate(1 << 20))
self.assertEqual("-", fmt_rate(None))
def test_the_pairs_fit_the_columns(self):
"""Une valeur tronquée dans un tableau est un piège : on lit « 1001M/1 »
et on croit une VM à 1 Go."""
for used, total, cle in (
(1001 << 20, 128 << 30, "ram"),
(12 << 30, 128 << 30, "ram"),
(999 << 30, 1 << 40, "disk"),
(67263266816, 69793218560, "disk"),
):
self.assertLessEqual(
len(fmt_pair(used, total)), COL_DEFAULT_WIDTHS[cle]
)
for bps in (0, 1 << 10, 12.3 * (1 << 20), 2 << 30):
self.assertLessEqual(len(fmt_rate(bps)), COL_DEFAULT_WIDTHS["wr"])
class TestSection(unittest.TestCase):
def test_it_names_the_three_measures(self):
rec = parse_domstats(DOMSTATS)[MIGRATION]
ligne = vm_stats_line(
MIGRATION, rec, 1 << 20, rec["ram_at"] + 1, rec["wr_bytes"]
)
self.assertIn(MIGRATION, ligne)
self.assertIn("1.0M/s", ligne)
self.assertIn("1.1G/12G", ligne)
self.assertIn("63G/65G", ligne)
def test_it_shows_percentages(self):
"""« 63G/65G » ne dit pas d'un coup d'œil que le disque est à 96 %."""
rec = parse_domstats(DOMSTATS)[MIGRATION]
ligne = vm_stats_line(MIGRATION, rec, 0, rec["ram_at"] + 1)
self.assertIn("(96%)", ligne)
self.assertIn("(9%)", ligne)
def test_a_vm_without_stats_says_so(self):
ligne = vm_stats_line("vm-x", None, None, time.time())
self.assertIn("vm-x", ligne)
self.assertNotIn("RAM", ligne)
class TestColonnes(unittest.TestCase):
def test_the_three_columns_exist(self):
for cle in ("wr", "ram", "disk"):
self.assertIn(cle, COL_DEFAULT_WIDTHS)
def test_the_row_fits_a_150_column_terminal(self):
"""La table est bornée à 66 % de l'écran, et le journal prend le reste.
Trois colonnes de plus, et « Disque » sortait du cadre : le total est
donc surveillé, padding de DataTable compris (2 par colonne)."""
besoin = sum(COL_DEFAULT_WIDTHS.values()) + 2 * len(COL_DEFAULT_WIDTHS)
self.assertLessEqual(besoin, int(150 * 0.66))
class TestEcranMonte(unittest.IsolatedAsyncioTestCase):
"""Le suivi monté pour de vrai : les colonnes portent-elles les chiffres ?
Aucun appel à libvirt : « read_domstats » rend la sortie enregistrée
ci-dessus. Un test qui lance sudo ne tournerait ni en CI ni sur un poste
sans le parc.
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
base = Path(self.tmp.name)
for nom in (MIGRATION, "erplibre-ubuntu-2604"):
(base / f"{nom}.log").write_text("===> install\nligne\n")
manifeste = {
"branch": "develop",
"started": time.time() - 300,
"vms": [
{
"name": nom,
"ip": "127.0.0.9",
"arch": "amd64",
"log": str(base / f"{nom}.log"),
"ssh": f"ssh erplibre@{nom}",
"disk": str(base / f"{nom}.qcow2"),
}
for nom in (MIGRATION, "erplibre-ubuntu-2604")
],
}
self.manifest = base / "session.json"
self.manifest.write_text(json.dumps(manifeste))
# Le relevé du ballon doit paraître FRAIS, sinon la colonne RAM se
# taira — ce qui est le comportement voulu, mais pas ce qu'on teste.
self.stats = DOMSTATS.replace(
"balloon.last-update=1787392761",
f"balloon.last-update={int(time.time())}",
).replace(
"balloon.last-update=1787392782",
f"balloon.last-update={int(time.time())}",
)
async def _monte(self, taille=(150, 24)):
from textual.widgets import DataTable, Static
with mock.patch.object(
mon, "read_domstats", lambda: self.stats
), mock.patch.object(
mon, "arm_balloon", lambda names: None
), mock.patch.object(
mon,
"virsh_domstates",
lambda: {MIGRATION: "running", "erplibre-ubuntu-2604": "running"},
), mock.patch.object(
mon, "_port_open", lambda ip, port=8069, timeout=0.5: False
):
app = mon.run_monitor(str(self.manifest), run_app=False)
async with app.run_test(size=taille) as pilot:
await pilot.pause()
# Le tick du tableau tourne toutes les deux secondes : on
# l'appelle plutôt que d'attendre, et on antidate un premier
# relevé pour que la fenêtre ait de quoi conclure.
app._wrate.add(MIGRATION, 100 << 20, time.time() - 10)
await app._tick_table()
await pilot.pause()
table = app.query_one("#vms", DataTable)
cellules = {
cle: str(table.get_cell(MIGRATION, cle))
for cle in ("wr", "ram", "disk")
}
section = str(app.query_one("#vmstats", Static).content)
besoin = table.virtual_size.width
visible = table.size.width
return cellules, section, besoin, visible
async def test_the_columns_carry_the_numbers(self):
cellules, _section, _b, _v = await self._monte()
self.assertEqual("1.1G/12G", cellules["ram"])
self.assertEqual("63G/65G", cellules["disk"])
self.assertTrue(cellules["wr"].endswith("/s"), cellules["wr"])
async def test_the_selected_vm_has_its_statistics_section(self):
_c, section, _b, _v = await self._monte()
self.assertIn(MIGRATION, section)
self.assertIn("1.1G/12G", section)
self.assertIn("63G/65G", section)
async def test_nothing_scrolls_out_of_a_150_column_terminal(self):
"""Le vrai garde-fou de la largeur : mesuré sur la table montée, pas
calculé à la main."""
_c, _s, besoin, visible = await self._monte((150, 24))
self.assertGreaterEqual(visible, besoin)
if __name__ == "__main__":
unittest.main(verbosity=1)