Les trois manques restants, demandés. Les colonnes vivantes du suivi — écrit
par seconde, RAM, disque — venaient de virsh, qui ne connaît pas les VM d'un
hôte Proxmox : elles restaient vides, et le relevé d'état les déclarait même
« effacées », ce qui éteignait le reste. L'hôte sait tout cela en un appel
(« pvesh get /cluster/resources »), et le relevé prend la forme de celui de
virsh pour que rien en aval ne distingue la source. Un appel par hôte, mis en
cache cinq secondes : une poignée de main ssh coûte 1 s, virsh 0,03 s.
« Lister les VM » propose maintenant d'en changer l'état, comme QEMU/KVM —
éteindre proprement avant de couper le courant, l'ordre le dit. Et le menu
accepte les commandes ajoutées par todo.json. Enfin « models » manquait aux
traductions : le rapport de migration sortait « 812 models » en français.
--- EN ---
The three remaining gaps, as asked. The dashboard's live columns — written per
second, RAM, disk — came from virsh, which knows nothing of a Proxmox host's
VMs: they stayed empty, and the state probe even declared them "gone", which
switched off the rest. The host knows all of it in one call ("pvesh get
/cluster/resources"), and the reading takes virsh's own shape so nothing
downstream tells the sources apart. One call per host, cached five seconds: an
ssh handshake costs 1 s, virsh 0.03 s.
"List VMs" now offers to change their state, like QEMU/KVM — clean shutdown
before pulling the plug, the order says so. And the menu accepts the commands
todo.json adds. Finally "models" was missing from the translations: the
migration report printed "812 models" in French.
Assisted-by: Claude Opus 5
148 lines
5.8 KiB
Python
148 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Les colonnes vivantes d'une VM posée sur un hôte Proxmox distant.
|
|
|
|
Elles venaient toutes de virsh : état, durée, écrit/s, RAM, disque. Or virsh
|
|
ne connaît pas les VM d'un hôte Proxmox — elles restaient donc VIDES, et le
|
|
relevé d'état les déclarait même « effacées », ce qui éteignait le reste.
|
|
|
|
L'hôte sait tout cela : « pvesh get /cluster/resources --type vm » rend
|
|
l'état, la mémoire, le disque et le cumul écrit de TOUTES ses VM en un appel.
|
|
Le relevé prend la forme exacte de celui de virsh, pour que le calcul du
|
|
débit, de la RAM et des colonnes ne sache pas d'où vient la mesure.
|
|
"""
|
|
|
|
import sys
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo import qemu_install_monitor as mon # noqa: E402
|
|
|
|
# Sortie RÉELLE relevée sur l'hôte d'essai (une VM, stockage en fichiers :
|
|
# Proxmox y rapporte « disk: 0 », d'où le « du » qui suit).
|
|
REPONSE = (
|
|
'[{"cpu":0.01,"disk":0,"diskread":32633856,"diskwrite":328233472,'
|
|
'"id":"qemu/100","maxcpu":1,"maxdisk":4294967296,"maxmem":536870912,'
|
|
'"mem":385351680,"name":"pve-suivi","netin":190,"netout":0,'
|
|
'"node":"erplibre-proxmox-9","status":"running","template":0,'
|
|
'"type":"qemu","uptime":127,"vmid":100}]\n'
|
|
"---ERPLIBRE-DU---\n"
|
|
"4294971392\t/var/lib/vz/images/100/\n"
|
|
)
|
|
|
|
|
|
class TestLaLecture(unittest.TestCase):
|
|
def setUp(self):
|
|
self.releves = mon.parse_pvestats(REPONSE)
|
|
|
|
def test_the_vm_is_keyed_by_its_name(self):
|
|
# Le suivi raisonne en NOMS : c'est ce que porte le manifeste.
|
|
self.assertEqual(list(self.releves), ["pve-suivi"])
|
|
|
|
def test_the_shape_matches_the_virsh_one(self):
|
|
# Même forme exprès : `ram_pair`, `WriteWindow` et les colonnes
|
|
# fonctionnent alors sans distinguer la source.
|
|
attendus = {
|
|
"ram_used",
|
|
"ram_total",
|
|
"ram_at",
|
|
"wr_bytes",
|
|
"disk_used",
|
|
"disk_total",
|
|
}
|
|
self.assertTrue(attendus <= set(self.releves["pve-suivi"]))
|
|
|
|
def test_the_measures_are_the_ones_the_host_gave(self):
|
|
rec = self.releves["pve-suivi"]
|
|
self.assertEqual(rec["ram_used"], 385351680)
|
|
self.assertEqual(rec["ram_total"], 536870912)
|
|
self.assertEqual(rec["wr_bytes"], 328233472)
|
|
self.assertEqual(rec["state"], "running")
|
|
self.assertEqual(rec["uptime"], 127)
|
|
|
|
def test_a_zero_disk_falls_back_to_the_real_size(self):
|
|
# Sur un stockage en fichiers, Proxmox NE CALCULE PAS la taille
|
|
# occupée et rapporte 0 : la colonne aurait affiché « 0/4G ».
|
|
self.assertEqual(self.releves["pve-suivi"]["disk_used"], 4294971392)
|
|
self.assertEqual(self.releves["pve-suivi"]["disk_total"], 4294967296)
|
|
|
|
def test_the_reading_is_fresh_so_the_ram_is_shown(self):
|
|
# `ram_pair` refuse un relevé périmé : sans horodatage, la RAM d'une
|
|
# VM distante ne s'afficherait jamais.
|
|
rec = self.releves["pve-suivi"]
|
|
self.assertNotEqual(mon.ram_pair(rec, rec["ram_at"]), "-")
|
|
|
|
def test_garbage_yields_nothing_rather_than_raising(self):
|
|
for brut in ("", "pas du json", "{}", "---ERPLIBRE-DU---"):
|
|
self.assertEqual(mon.parse_pvestats(brut), {})
|
|
|
|
|
|
class TestLAppel(unittest.TestCase):
|
|
"""Un appel par HÔTE, mis en cache : chaque tour coûte une poignée de
|
|
main ssh (1 s mesuré) quand virsh coûte 0,03 s pour tout le parc."""
|
|
|
|
def setUp(self):
|
|
mon._PVE_CACHE.update({"at": 0.0, "stats": {}})
|
|
|
|
def _vms(self, n=2):
|
|
return [
|
|
{
|
|
"name": f"vm-{i}",
|
|
"pve": {
|
|
"target": "erplibre@pve1",
|
|
"sudo": "sudo ",
|
|
"vmid": 100 + i,
|
|
},
|
|
}
|
|
for i in range(n)
|
|
]
|
|
|
|
def test_two_vms_of_the_same_host_cost_one_call(self):
|
|
with mock.patch(
|
|
"script.proxmox.proxmox_deploy.run", return_value=(0, REPONSE)
|
|
) as appel:
|
|
mon.read_pvestats(self._vms(2), now=1000.0)
|
|
self.assertEqual(appel.call_count, 1)
|
|
|
|
def test_a_local_only_plan_calls_nothing(self):
|
|
with mock.patch("script.proxmox.proxmox_deploy.run") as appel:
|
|
self.assertEqual(mon.read_pvestats([{"name": "locale"}]), {})
|
|
appel.assert_not_called()
|
|
|
|
def test_the_cache_spares_the_next_tick(self):
|
|
with mock.patch(
|
|
"script.proxmox.proxmox_deploy.run", return_value=(0, REPONSE)
|
|
) as appel:
|
|
mon.read_pvestats(self._vms(1), now=1000.0)
|
|
mon.read_pvestats(self._vms(1), now=1000.0 + 1)
|
|
self.assertEqual(appel.call_count, 1)
|
|
# Passé l'intervalle, on redemande.
|
|
mon.read_pvestats(
|
|
self._vms(1), now=1000.0 + mon.PVE_STATS_INTERVAL + 0.1
|
|
)
|
|
self.assertEqual(appel.call_count, 2)
|
|
|
|
def test_a_failing_host_yields_nothing_rather_than_wrong_numbers(self):
|
|
with mock.patch(
|
|
"script.proxmox.proxmox_deploy.run", return_value=(255, "timeout")
|
|
):
|
|
self.assertEqual(mon.read_pvestats(self._vms(1), now=1.0), {})
|
|
|
|
|
|
class TestLEtat(unittest.TestCase):
|
|
"""Une VM absente de « virsh list » passait pour EFFACÉE."""
|
|
|
|
def test_proxmox_states_map_to_libvirt_ones(self):
|
|
self.assertEqual(mon.PVE_ETATS.get("running"), "running")
|
|
self.assertEqual(mon.PVE_ETATS.get("stopped"), "shut off")
|
|
|
|
def test_an_unknown_state_is_really_gone(self):
|
|
# Absente de la réponse de l'hôte : la VM a vraiment disparu.
|
|
self.assertIsNone(mon.PVE_ETATS.get(None))
|
|
self.assertIsNone(mon.PVE_ETATS.get("n'importe quoi"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|