erplibre/test/test_qemu_monitor_pve.py
Mathieu Benoit 07df33b3ff [ADD] proxmox : suivre une VM distante, changer son état, étendre le menu
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
2026-08-25 03:19:18 -04:00

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)