erplibre/test/test_qemu_monitor_pve.py

756 lines
30 KiB
Python
Raw Normal View History

[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-24 05:11:58 -04:00
#!/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), {})
[FIX] suivi : pas de poubelle avant d'en être sûr, et mise sur Proxmox Rapporté : une VM Arch à peine déployée sur Proxmox s'affichait 🗑 dès le premier tour. « Effacée » est un état TERMINAL — la ligne gèle et ne revient jamais — et il se déduisait d'UN relevé manquant. Or l'hôte peut être occupé, la VM en train de naître, le relevé en cache d'avant sa création. On distingue désormais « l'hôte n'a pas répondu » (on ne sait rien) de « l'hôte a répondu sans elle » (on compte, trois fois), et la case part de « - » plutôt que d'un sablier qui affirmerait qu'on attend quelque chose. L'écran Proxmox n'offrait pas le choix de l'interpréteur Python : il envoyait donc toujours « automatique », et comme mise n'est jamais installé d'office, c'était pyenv — qui COMPILE Python depuis le tar.xz. Le choix existe maintenant des deux côtés, avec le même garde-fou : rien n'est imposé quand aucune architecture retenue n'est servie par mise. --- EN --- Reported: an Arch VM barely deployed on Proxmox showed 🗑 on the very first pass. "Deleted" is a TERMINAL state — the row freezes and never comes back — and it was inferred from ONE missing reading. Yet the host may be busy, the VM may be starting, the reading may be cached from before it existed. We now tell "the host did not answer" (we know nothing) from "the host answered without it" (count, three times), and the cell starts at "-" rather than an hourglass claiming we await something. The Proxmox screen offered no Python interpreter choice: it therefore always sent "automatic", and since mise is never installed by default, that meant pyenv — which COMPILES Python from the tar.xz. The choice now exists on both sides, with the same guard: nothing is imposed when no selected architecture is served by mise. Assisted-by: Claude Opus 5
2026-08-24 07:53:54 -04:00
class TestPasDePoubelleTropTot(unittest.TestCase):
"""« Effacée » est un état TERMINAL : la ligne gèle sur 🗑 pour de bon.
Rapporté sur une VM Arch déployée sur Proxmox : poubelle dès le premier
tour, alors que la VM venait de naître. Un relevé manquant ne prouve
rien — l'hôte peut être occupé, la VM en train de démarrer, le relevé en
cache d'avant sa création.
"""
def setUp(self):
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
def _vm(self):
return {
"name": "vm-a",
"pve": {
"target": "hote",
"sudo": "sudo ",
"vmid": 105,
"addr": "10.10.10.155",
},
}
def test_a_silent_host_is_not_a_deletion(self):
# (relevés, succès) : la nuance est tout le correctif.
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(255, "timeout")
):
stats, ok = mon.read_pvestats_detail([self._vm()], now=1.0)
self.assertEqual(stats, {})
self.assertFalse(ok)
def test_a_host_that_answers_says_so(self):
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(0, "[]")
):
stats, ok = mon.read_pvestats_detail([self._vm()], now=1.0)
self.assertEqual(stats, {})
self.assertTrue(ok, "l'hôte a répondu : la VM est vraiment absente")
def test_the_cache_keeps_the_verdict_too(self):
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(0, "[]")
) as appel:
mon.read_pvestats_detail([self._vm()], now=100.0)
_stats, ok = mon.read_pvestats_detail([self._vm()], now=100.5)
self.assertEqual(appel.call_count, 1)
self.assertTrue(ok, "le cache rendait « échec » à chaque tour suivant")
def test_it_takes_several_absences_to_conclude(self):
# Trois, pas une : le nombre est explicite, pas enfoui.
self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2)
def test_the_short_reader_still_returns_only_stats(self):
# `read_pvestats` reste la forme courte pour la boucle des colonnes.
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(0, "[]")
):
self.assertEqual(mon.read_pvestats([self._vm()], now=7.0), {})
[FIX] proxmox : six défauts trouvés par un audit, pas à l'usage Trois autres chemins menaient au 🗑 sur un seul incident, et « effacée » gèle la ligne pour de bon. Un « virsh list » en échec condamnait TOUT le parc local. Un statut Proxmox hors des trois attendus — prelaunch, suspended, internal-error — passait pour une disparition. Et le code de sortie de la suite distante est celui de son DERNIER maillon : un pvesh en panne se lisait « l'hôte a répondu sans elle ». Ce qui prouve une réponse, c'est désormais une liste de ressources analysable. Le plan annonçait « 25G » quand « qm resize » recevait 20 : la marge d'ERPLibre se perdait en route, la VM naissait trop petite. « Changer l'état » choisissait par NOM, or seul le VMID est unique sur un hôte — cocher une VM en éteignait deux homonymes. L'entrée 13 volait son alias à une VM locale du même nom. Enfin le déploiement par QUESTIONS avait vieilli seul : il partage maintenant l'épilogue de l'écran, donc le guide, l'alias protégé, les colonnes vivantes et le sommaire. --- EN --- Three more paths led to 🗑 on a single incident, and "deleted" freezes the row for good. One failing "virsh list" condemned the WHOLE local fleet. A Proxmox status outside the three expected ones — prelaunch, suspended, internal-error — passed for a disappearance. And a remote pipeline's exit code is its LAST link's: a broken pvesh read as "the host answered without it". Proof of an answer is now a parsable resource list. The plan announced "25G" while "qm resize" got 20: ERPLibre's margin was lost on the way and the VM was born too small. "Change state" selected by NAME, yet only the VMID is unique on a host — ticking one VM shut down two namesakes. Menu entry 13 stole its alias from a local VM of the same name. Finally the QUESTION-driven deployment had aged alone: it now shares the screen's epilogue — guide, protected alias, live columns and summary. Assisted-by: Claude Opus 5
2026-08-24 14:15:03 -04:00
class TestLesAutresCheminsVersLaPoubelle(unittest.TestCase):
"""Trouvés par un audit, pas à l'usage : trois autres façons d'arriver au
🗑 sur un seul incident. « Effacée » gèle la ligne pour de bon, donc
chacune valait un correctif."""
def test_a_broken_pvesh_is_not_an_answer(self):
# La commande est une SUITE : son code de sortie est celui du DERNIER
# maillon. Un pvesh en panne rendait « l'hôte a répondu, la VM n'y est
# plus » — et trois tours plus tard, la poubelle.
self.assertFalse(
mon._resources_parsable("permission denied\n---ERPLIBRE-DU---\n")
)
self.assertTrue(mon._resources_parsable("[]\n---ERPLIBRE-DU---\n"))
def test_a_failing_host_never_counts_as_an_answer(self):
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
vm = {"name": "vm-a", "pve": {"target": "h", "sudo": "", "vmid": 1}}
with mock.patch(
"script.proxmox.proxmox_deploy.run",
return_value=(0, "sudo: a password is required\n"),
):
_stats, ok = mon.read_pvestats_detail([vm], now=10.0)
self.assertFalse(ok, "code 0 ne prouve pas que pvesh a parlé")
def test_an_unknown_proxmox_status_is_not_a_deletion(self):
# Proxmox en a d'autres que les trois attendus : « prelaunch »,
# « suspended », « internal-error », « hibernated ».
for etat in ("prelaunch", "suspended", "internal-error", "hibernated"):
self.assertIsNone(
mon.PVE_ETATS.get(etat),
"l'état n'est pas dans la table : le repli doit être « la VM"
" existe », pas « gone »",
)
def test_the_absence_counter_is_explicit(self):
self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2)
[FIX] suivi : un relevé Proxmox jeté, et deux lignes qui montraient une autre machine Sur trois VM d'un même Proxmox, une seule avait ses colonnes vides — et les deux autres montraient les chiffres d'une AUTRE machine. Deux fautes, dont une était le miroir d'un correctif précédent. Le code de sortie de la suite distante est celui de son DERNIER maillon, la sonde Odoo. Tant qu'Odoo n'écoute pas — c'est-à-dire pendant TOUTE l'installation, précisément quand on regarde — la boucle finit en échec et le relevé, parfait, était jeté. On avait corrigé l'erreur inverse, un code 0 pris pour une réponse ; exiger 0 était la même faute retournée. Seule une liste de ressources analysable prouve une réponse. Pendant ce temps, « virsh domstats » indexe par NOM, et un nom se partage : les deux VM qui avaient un homonyme LOCAL affichaient ses chiffres. Mesuré — 1,5 Gio de RAM sur 12 et 58 Gio de disque sur 65, quand la vraie tournait avec 3 Gio et 25. Les relevés locaux d'une VM qui vit ailleurs sont donc retirés AVANT d'ajouter ceux de l'hôte : un hôte muet laisse la colonne VIDE, ce qui est vrai. Une colonne vide se remarque ; une colonne juste et fausse, non. L'alias enfin. Prendre le nom court quand il se trouvait libre donnait un parc incohérent : sur ce même déploiement, deux VM ont reçu « hôte+vm » — leurs noms étaient pris par des domaines locaux — et la troisième son nom court. Une convention qui dépend de ce qui traîne dans le fichier n'est pas une convention. Le nom chaîné est systématique. --- EN --- Of three VMs on one Proxmox, only one had empty columns — and the other two showed ANOTHER machine's figures. Two defects, one the mirror of an earlier fix. A remote pipeline's exit code is its LAST link's, the Odoo probe. While Odoo is not listening — that is, during the WHOLE install, exactly when you are watching — the loop ends in failure and the reading, perfectly good, was thrown away. We had fixed the opposite error, a 0 taken for an answer; demanding 0 was the same mistake reversed. Only a parsable resource list proves an answer. Meanwhile "virsh domstats" indexes by NAME, and a name is shared: the two VMs with a LOCAL namesake displayed its figures. Measured — 1.5 GiB of RAM out of 12 and 58 GiB of disk out of 65, while the real one ran on 3 GiB and 25. Local readings for a VM that lives elsewhere are therefore dropped BEFORE the host's are added: a silent host leaves the column EMPTY, which is true. An empty column gets noticed; a plausible wrong one does not. The alias, finally. Taking the short name while it happened to be free gave an inconsistent fleet: in that same deployment two VMs got "host+vm" — their names were held by local domains — and the third its short name. A convention that depends on what happens to sit in the file is not a convention. The chained name is now systematic. Assisted-by: Claude Opus 5
2026-08-25 00:30:54 -04:00
class TestTroisVmSurUnProxmox(unittest.TestCase):
"""Rapporté à l'usage : sur trois VM d'un même Proxmox, une seule avait
ses colonnes vides — et les deux autres montraient les chiffres d'une
AUTRE machine.
Deux fautes, dont une était le miroir d'un correctif précédent."""
def test_a_reading_stands_even_when_odoo_is_not_up_yet(self):
# Le code de sortie de la suite distante est celui de son DERNIER
# maillon : la sonde Odoo. Tant qu'Odoo n'écoute pas — c'est-à-dire
# pendant TOUTE l'installation, précisément quand on regarde — la
# boucle finit en échec et le relevé, parfait, était jeté.
#
# On avait corrigé l'erreur inverse (code 0 pris pour une réponse) ;
# exiger 0 était la même faute, retournée.
sortie = (
'[{"vmid":101,"name":"vm-a","status":"running","maxmem":1024,'
'"mem":512,"maxdisk":2048,"diskwrite":10}]\n'
"---ERPLIBRE-DU---\n---ERPLIBRE-ODOO---\n"
)
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
vm = {"name": "vm-a", "pve": {"target": "h", "sudo": "", "vmid": 101}}
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(1, sortie)
):
stats, ok = mon.read_pvestats_detail([vm], now=10.0)
self.assertTrue(
ok, "un code non nul ne réfute pas une réponse lisible"
)
self.assertIn("vm-a", stats)
[FIX] proxmox : l'ancienne entrée ssh s'en va avec la convention Le nom chaîné devient systématique, mais les entrées écrites AVANT portent le nom court — et rien ne les retirerait : elles ne déclarent pas le nom qu'on écrit maintenant. Deux blocs mèneraient à la même machine, exactement ce qu'on venait d'enlever. Le ProxyJump tranche : un bloc qui rebondit par CET hôte est le nôtre, on le retire. Celui d'une VM locale homonyme n'en a pas, et on n'y touche jamais ; celui d'un autre hôte Proxmox non plus. Le drapeau Odoo gagne son test au passage. Il tombait pour la même raison que les colonnes vides — la sonde est le dernier maillon de la suite distante, et un parc où une seule VM n'a pas d'Odoo, un hyperviseur imbriqué par exemple, finit en échec. Vérifié sur les trois VM : l'hôte rend bien « ODOO » pour les deux qui écoutent, et le navigateur répondait 303 pendant que la colonne disait « — ». --- EN --- The chained name becomes systematic, but entries written BEFORE carry the short one — and nothing would retire them: they do not declare the name we now write. Two blocks would lead to the same machine, exactly what we had just removed. The ProxyJump decides: a block hopping through THIS host is ours, so it goes. A local namesake's has none, and is never touched; another Proxmox host's neither. The Odoo flag gains its test along the way. It failed for the same reason as the empty columns — the probe is the remote pipeline's last link, and a fleet where a single VM has no Odoo, a nested hypervisor for instance, ends in failure. Verified on all three VMs: the host does return "ODOO" for the two that listen, and the browser answered 303 while the column said "—". Assisted-by: Claude Opus 5
2026-08-25 00:43:10 -04:00
def test_the_odoo_flag_survives_a_partly_closed_fleet(self):
"""Le cas rapporté, et il est le cas NORMAL.
La sonde est le dernier maillon : elle boucle sur toutes les adresses
et son code est celui de la DERNIÈRE. Un parc où une seule VM n'a pas
d'Odoo — un hyperviseur imbriqué, par exemple — finit donc en échec,
et le relevé entier partait, drapeaux Odoo compris. Le navigateur, lui,
répondait 303."""
sortie = (
'[{"vmid":100,"name":"vm-a","status":"running","maxmem":1024,'
'"mem":512,"maxdisk":2048,"diskwrite":10},'
'{"vmid":102,"name":"pve-imbrique","status":"running",'
'"maxmem":1024,"mem":512,"maxdisk":2048,"diskwrite":10}]\n'
"---ERPLIBRE-DU---\n"
"---ERPLIBRE-ODOO---\nODOO 10.10.10.150\n"
)
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
vms = [
{
"name": "vm-a",
"pve": {
"target": "h",
"sudo": "",
"vmid": 100,
"addr": "10.10.10.150",
},
},
{
"name": "pve-imbrique",
"pve": {
"target": "h",
"sudo": "",
"vmid": 102,
"addr": "10.10.10.152",
},
},
]
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(1, sortie)
):
stats, ok = mon.read_pvestats_detail(vms, now=30.0)
self.assertTrue(ok)
self.assertTrue(stats["vm-a"]["odoo"], "la VM qui répond doit être 🟢")
self.assertFalse(
stats["pve-imbrique"]["odoo"], "un hyperviseur n'a pas d'Odoo"
)
[FIX] suivi : un relevé Proxmox jeté, et deux lignes qui montraient une autre machine Sur trois VM d'un même Proxmox, une seule avait ses colonnes vides — et les deux autres montraient les chiffres d'une AUTRE machine. Deux fautes, dont une était le miroir d'un correctif précédent. Le code de sortie de la suite distante est celui de son DERNIER maillon, la sonde Odoo. Tant qu'Odoo n'écoute pas — c'est-à-dire pendant TOUTE l'installation, précisément quand on regarde — la boucle finit en échec et le relevé, parfait, était jeté. On avait corrigé l'erreur inverse, un code 0 pris pour une réponse ; exiger 0 était la même faute retournée. Seule une liste de ressources analysable prouve une réponse. Pendant ce temps, « virsh domstats » indexe par NOM, et un nom se partage : les deux VM qui avaient un homonyme LOCAL affichaient ses chiffres. Mesuré — 1,5 Gio de RAM sur 12 et 58 Gio de disque sur 65, quand la vraie tournait avec 3 Gio et 25. Les relevés locaux d'une VM qui vit ailleurs sont donc retirés AVANT d'ajouter ceux de l'hôte : un hôte muet laisse la colonne VIDE, ce qui est vrai. Une colonne vide se remarque ; une colonne juste et fausse, non. L'alias enfin. Prendre le nom court quand il se trouvait libre donnait un parc incohérent : sur ce même déploiement, deux VM ont reçu « hôte+vm » — leurs noms étaient pris par des domaines locaux — et la troisième son nom court. Une convention qui dépend de ce qui traîne dans le fichier n'est pas une convention. Le nom chaîné est systématique. --- EN --- Of three VMs on one Proxmox, only one had empty columns — and the other two showed ANOTHER machine's figures. Two defects, one the mirror of an earlier fix. A remote pipeline's exit code is its LAST link's, the Odoo probe. While Odoo is not listening — that is, during the WHOLE install, exactly when you are watching — the loop ends in failure and the reading, perfectly good, was thrown away. We had fixed the opposite error, a 0 taken for an answer; demanding 0 was the same mistake reversed. Only a parsable resource list proves an answer. Meanwhile "virsh domstats" indexes by NAME, and a name is shared: the two VMs with a LOCAL namesake displayed its figures. Measured — 1.5 GiB of RAM out of 12 and 58 GiB of disk out of 65, while the real one ran on 3 GiB and 25. Local readings for a VM that lives elsewhere are therefore dropped BEFORE the host's are added: a silent host leaves the column EMPTY, which is true. An empty column gets noticed; a plausible wrong one does not. The alias, finally. Taking the short name while it happened to be free gave an inconsistent fleet: in that same deployment two VMs got "host+vm" — their names were held by local domains — and the third its short name. A convention that depends on what happens to sit in the file is not a convention. The chained name is now systematic. Assisted-by: Claude Opus 5
2026-08-25 00:30:54 -04:00
def test_a_broken_pvesh_is_still_refuted(self):
# L'autre sens tient toujours : sans liste analysable, pas de réponse.
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
vm = {"name": "vm-a", "pve": {"target": "h", "sudo": "", "vmid": 101}}
with mock.patch(
"script.proxmox.proxmox_deploy.run",
return_value=(0, "permission denied\n---ERPLIBRE-DU---\n"),
):
_stats, ok = mon.read_pvestats_detail([vm], now=20.0)
self.assertFalse(ok)
def test_a_remote_vm_never_borrows_a_local_namesake(self):
# « virsh domstats » indexe par NOM, et un nom se partage. Mesuré :
# « erplibre-ubuntu-2604 » sur Proxmox affichait 1,5 Gio sur 12 et
# 58 Gio de disque — ceux de la machine locale du même nom — quand la
# vraie tournait avec 3 Gio et 25.
locaux = {
"erplibre-ubuntu-2604": {
"ram_used": 1 << 30,
"ram_total": 12 << 30,
},
"erplibre-arch-latest": {"ram_used": 5, "ram_total": 9},
"vm-locale": {"ram_used": 7, "ram_total": 8},
}
vms = [
{"name": "erplibre-ubuntu-2604", "pve": {"vmid": 100}},
{"name": "vm-locale"},
]
reste = mon.drop_local_twins(dict(locaux), vms)
self.assertNotIn("erplibre-ubuntu-2604", reste)
# Une VM locale garde les siens, et une VM étrangère au manifeste
# n'est pas touchée.
self.assertIn("vm-locale", reste)
self.assertIn("erplibre-arch-latest", reste)
def test_a_silent_host_leaves_the_column_empty(self):
# Vide, c'est vrai. Une colonne vide se remarque ; une colonne juste
# et fausse, non — c'est ce qui a fait remonter le défaut.
stats = {"vm-a": {"ram_used": 1, "ram_total": 2}}
mon.drop_local_twins(stats, [{"name": "vm-a", "pve": {"vmid": 1}}])
self.assertEqual(stats, {})
[FIX] suivi : effacer depuis un suivi rouvert vérifie d'abord l'identité Le tableau de bord se rouvre sur un manifeste passé — c'est fait pour, les installations partent détachées. Mais un nom de domaine se réemploie et un VMID libéré est RÉATTRIBUÉ : effacer « le 101 » d'un run de mars, c'est effacer ce qui porte le 101 aujourd'hui, et « erplibre-ubuntu-2604 » de mars n'est pas celui d'aujourd'hui. Même famille que tout le reste — on jugeait sur le nom, avec ici la pire conséquence. La commande porte donc son garde, et non l'écran : elle protège ainsi tous ses appelants, et la vérification se fait SUR la machine, à l'instant d'effacer. Sur Proxmox, le VMID doit encore porter ce nom. En local, l'UUID du domaine — relevé au lancement, seul instant où l'on sait que ce nom désigne bien cette machine-là. Un manifeste écrit avant ce correctif n'en a pas : il retombe sur la protection d'avant plutôt que de bloquer. Le garde du VMID est une fonction à part, exécutable telle quelle. Il traverse deux « shlex.quote » avant d'atteindre un dash, et un garde qu'on ne sait pas éprouver s'OUVRE le jour où il casse. Vérifié sur erplibre-proxmox-9 sans rien détruire : le VMID 100 refusé sous un nom périmé, accepté sous le sien. --- EN --- The dashboard reopens on a past manifest — by design, since installs run detached. But a domain name gets reused and a freed VMID is REASSIGNED: deleting "the 101" from a March run deletes whatever holds 101 today, and March's "erplibre-ubuntu-2604" is not today's. Same family as the rest — we judged by name, here with the worst consequence. The command carries its guard, not the screen: that protects every caller, and the check happens ON the machine, at the moment of deletion. On Proxmox the VMID must still bear that name. Locally, the domain's UUID — recorded at launch, the only moment we know that name means that machine. A manifest written before this fix has none: it falls back to the previous protection rather than blocking. The VMID guard is its own function, runnable as is. It crosses two "shlex.quote" layers before reaching a dash, and a guard you cannot exercise OPENS the day it breaks. Verified on erplibre-proxmox-9 without destroying anything: VMID 100 refused under a stale name, accepted under its own. Assisted-by: Claude Opus 5
2026-08-24 23:17:37 -04:00
class TestEffacerDepuisUnSuiviRouvert(unittest.TestCase):
"""Le suivi se ROUVRE sur un manifeste passé — c'est fait pour.
Mais un nom de domaine se réemploie, et un VMID libéré est RÉATTRIBUÉ.
Effacer « le 101 » d'un run de mars, c'est effacer ce qui porte le 101
aujourd'hui, et « erplibre-ubuntu-2604 » de mars n'est pas celui
d'aujourd'hui. Même famille que tout le reste : on jugeait sur le nom.
La commande porte donc son garde. Dans la commande et non dans l'écran :
elle protège ainsi tous ses appelants, et la vérification se fait SUR la
machine, à l'instant d'effacer."""
def test_a_proxmox_delete_checks_the_vmid_still_bears_the_name(self):
cmd = mon.delete_vm_cmd_pve(
{"target": "pve9", "vmid": 101}, name="vm-a"
)
self.assertIn("qm config 101", cmd)
self.assertIn("exit 1", cmd)
self.assertIn("qm destroy 101", cmd)
# Le garde vient AVANT la destruction, sinon il ne garde rien.
self.assertLess(cmd.index("qm config 101"), cmd.index("qm destroy"))
def test_a_local_delete_checks_the_uuid(self):
cmd = mon.delete_vm_cmd("vm-a", True, "5d55d05a-1e77")
self.assertIn("virsh domuuid vm-a", cmd)
self.assertIn("5d55d05a-1e77", cmd)
self.assertLess(cmd.index("domuuid"), cmd.index("virsh destroy vm-a"))
def test_an_old_manifest_without_identity_still_deletes(self):
# Un manifeste écrit avant ce correctif n'a pas d'UUID. Refuser toute
# suppression y serait une régression : on retombe sur la protection
# d'avant, la confirmation à deux mains.
cmd = mon.delete_vm_cmd("vm-a", True)
self.assertNotIn("domuuid", cmd)
self.assertIn("virsh undefine vm-a", cmd)
sans_nom = mon.delete_vm_cmd_pve({"target": "pve9", "vmid": 101})
self.assertNotIn("qm config", sans_nom)
self.assertIn("qm destroy 101", sans_nom)
def test_the_guard_is_shell_correct(self):
"""Le garde est EXÉCUTÉ, « qm » bouchonné par une fonction shell.
Il traverse ensuite deux « shlex.quote » avant d'atteindre un dash :
chaque niveau est une occasion de le casser, et un garde cassé
s'OUVRE au lieu de se fermer. Éprouvé aussi sur le vrai hôte, où il a
refusé un nom périmé et laissé passer le bon."""
import subprocess
garde = mon.pve_identity_guard(101, "vm-a")
for vu, attendu in (("vm-a", 0), ("autre-vm", 1)):
res = subprocess.run(
[
"sh",
"-c",
f"qm() {{ echo 'name: {vu}'; }}; {garde} exit 0",
],
capture_output=True,
text=True,
)
self.assertEqual(res.returncode, attendu, f"{vu} : {res.stdout}")
self.assertIn("vm-a", garde)
[FIX] proxmox : quatre écrans qui parlaient d'une machine locale La confirmation de suppression promettait à TOUTE VM « son disque qcow2 EFFACÉ », puis nommait /var/lib/libvirt/images/<nom>.qcow2. Sur une VM Proxmox ce fichier n'existe pas — au mieux, au pire c'est celui d'une autre VM du même nom. C'est la peur exacte qui avait fait remonter le nettoyage. Elle nomme désormais l'hôte, le VMID et « qm destroy ». « Console de l'hyperviseur » lisait le port par « virsh vncdisplay ». Un Proxmox n'a pas de libvirt : l'échec se lisait « écran fermé » et on conseillait « sudo virsh edit » sur une machine sans ce binaire. Ce n'est pas un écran fermé, c'est la mauvaise question — Proxmox sert le sien par un ticket. Les deux vrais chemins sont nommés : la console série, l'interface web par tunnel. La colonne Odoo était un 🟢 acquis pour toujours : « Odoo ne redescend pas en cours d'install » est faux — le service redémarre au moins une fois, et il lui arrive de mourir. Elle est relue, gratuitement sur Proxmox, toutes les trente secondes ailleurs. Un hôte muet reste distinct d'un Odoo tombé. Enfin « Versions principales » (F7) manquait à l'écran Proxmox, qui affiche pourtant le même catalogue. Les trois gestes du catalogue vivent maintenant dans le socle du plan, où ils ne peuvent plus diverger. --- EN --- The delete confirmation promised EVERY VM "its qcow2 disk ERASED", then named /var/lib/libvirt/images/<name>.qcow2. On a Proxmox VM that file does not exist — at best; at worst it is another VM's, of the same name. That is the very fear that surfaced the cleanup report. It now names the host, the VMID and "qm destroy". "Hypervisor console" read the port through "virsh vncdisplay". Proxmox has no libvirt: the failure read as "screen closed" and we advised "sudo virsh edit" on a machine without that binary. It is not a closed screen, it is the wrong question — Proxmox serves its own by ticket. Both real paths are named: the serial console, the web interface through a tunnel. The Odoo column was a 🟢 acquired forever: "Odoo does not go back down during the install" is false — the service restarts at least once, and it does die. It is re-read, free on Proxmox, every thirty seconds elsewhere. A silent host stays distinct from a dead Odoo. Finally "Main versions" (F7) was missing from the Proxmox screen, which shows the same catalog. The catalog's three gestures now live in the plan foundation, where they can no longer diverge. Assisted-by: Claude Opus 5
2026-08-24 23:07:55 -04:00
class TestCeQueLaConfirmationPromet(unittest.TestCase):
"""La confirmation de suppression annonçait un fichier qcow2 local à
TOUTE VM, Proxmox comprise.
Sur une VM Proxmox ce fichier n'existe pas : son disque vit dans un
stockage que seul l'hôte connaît. La ligne désignait donc un chemin local
— au mieux inexistant, au pire celui d'une autre VM du même nom. C'est
exactement la peur qui avait fait remonter le nettoyage : « le nettoyage
risque d'effacer des VM en production »."""
def test_a_proxmox_vm_never_shows_a_local_path(self):
lignes = mon.delete_lines(
{"name": "vm-a", "pve": {"target": "pve9", "vmid": 101}}
)
texte = " ".join(lignes)
self.assertNotIn("/var/lib/libvirt", texte)
self.assertNotIn("qcow2", texte)
def test_it_names_the_host_and_the_vmid(self):
# Le nom ne suffit pas : deux VM peuvent le porter, seul le VMID est
# unique — et il faut savoir SUR QUELLE machine ça se passe.
texte = " ".join(
mon.delete_lines(
{"name": "vm-a", "pve": {"target": "pve9", "vmid": 101}}
)
)
self.assertIn("pve9", texte)
self.assertIn("101", texte)
self.assertIn("qm destroy 101", texte)
def test_a_local_vm_still_names_its_file(self):
# La voie libvirt ne régresse pas : là, le fichier EST ce qu'on efface.
texte = " ".join(mon.delete_lines({"name": "vm-a"}))
self.assertIn("/var/lib/libvirt/images/vm-a.qcow2", texte)
class TestQuandLeVertRedescend(unittest.TestCase):
"""Le 🟢 était acquis pour toujours.
« Odoo ne redescend pas en cours d'install » : c'était faux. Le service
redémarre au moins une fois — systemd l'active à la fin du make — et il
lui arrive de mourir. La VM restait verte en servant plus rien, et c'est
précisément le moment où on veut le savoir."""
def _sonde(self, reponse):
return lambda _ip, _port: reponse
def test_a_local_vm_that_stopped_answering_goes_back(self):
etat, sonde = mon.odoo_reading(
{"ip": "10.0.0.1"},
{},
deja_vert=True,
dernier=0.0,
maintenant=mon.ODOO_RECHECK + 1,
sonde=self._sonde(False),
)
self.assertIs(etat, False)
self.assertTrue(sonde)
def test_a_green_local_vm_is_not_probed_every_tick(self):
# La sonde est un connect() TCP par VM : à chaque tour (2 s) c'est
# cher pour une réponse qui ne bouge presque jamais.
etat, sonde = mon.odoo_reading(
{"ip": "10.0.0.1"},
{},
deja_vert=True,
dernier=100.0,
maintenant=101.0,
sonde=self._sonde(False),
)
self.assertIsNone(etat, "rien de neuf ne doit être affirmé")
self.assertFalse(sonde)
def test_a_red_local_vm_is_probed_every_tick(self):
_etat, sonde = mon.odoo_reading(
{"ip": "10.0.0.1"},
{},
deja_vert=False,
dernier=100.0,
maintenant=101.0,
sonde=self._sonde(True),
)
self.assertTrue(sonde)
def test_a_proxmox_vm_is_read_every_tick_for_free(self):
# Le port est testé DEPUIS L'HÔTE, avec les statistiques : d'ici, une
# adresse de pont interne ne répond jamais.
def lu(odoo):
return mon.odoo_reading(
{"pve": {"vmid": 1}},
{"odoo": odoo},
deja_vert=True,
dernier=0.0,
maintenant=1.0,
sonde=self._sonde(True),
)[0]
self.assertIs(lu(True), True)
self.assertIs(lu(False), False)
def test_a_silent_host_is_not_a_dead_odoo(self):
# Sans relevé, on ne sait RIEN : l'appelant garde le dernier état.
etat, _s = mon.odoo_reading(
{"pve": {"vmid": 1}},
{},
deja_vert=True,
dernier=0.0,
maintenant=1.0,
sonde=self._sonde(True),
)
self.assertIsNone(etat)
[FIX] proxmox : l'installation partait sur la mauvaise machine Rapporté, et c'est le plus grave de la série. Une VM déployée sur Proxmox sous le nom « erplibre-ubuntu-2604 » — nom déjà porté par un domaine LOCAL — a vu son installation d'ERPLibre + Odoo partir sur la VM locale. Deux causes enchaînées : l'entrée ~/.ssh/config volait l'alias de la locale, et le lanceur détaché ré-résout l'adresse par virsh à chaque tour, qui a répondu avec le domaine homonyme. Le journal l'écrivait — « → 192.168.123.118 » — sans que rien n'alerte. Une VM distante n'est plus ré-résolue : son alias est la seule vérité, puisqu'il porte le rebond. Et son alias suit la convention des VM imbriquées, « hôte+vm », le nom court n'étant ajouté que s'il est libre — l'écran le dit. S'y ajoute le sommaire final qui manquait, à l'image de QEMU/KVM : ce qui existe, son adresse, sa commande ssh, son journal. --- EN --- Reported, and the worst of the series. A VM deployed on Proxmox under the name "erplibre-ubuntu-2604" — a name already held by a LOCAL domain — had its ERPLibre + Odoo install land on the local VM. Two chained causes: the ~/.ssh/config entry stole the local one's alias, and the detached launcher re-resolves the address through virsh on every pass, which answered with the homonymous domain. The log said so — "→ 192.168.123.118" — with nothing to raise an alarm. A remote VM is no longer re-resolved: its alias is the only truth, since it carries the jump. And its alias follows the nested-VM convention, "host+vm", the short name being added only when free — the screen says so. Plus the final summary that was missing, mirroring QEMU/KVM: what exists, its address, its ssh command, its log. Assisted-by: Claude Opus 5
2026-08-24 06:37:56 -04:00
class TestLaBonneMachine(unittest.TestCase):
"""Le pire défaut de la série : l'installation partie AILLEURS.
Vécu le 24 août 2026. Une VM déployée sur Proxmox sous le nom
« erplibre-ubuntu-2604 » — nom déjà porté par un domaine LOCAL. Le
lanceur détaché ré-résout l'adresse de la VM à chaque tour par virsh, qui
a répondu avec le domaine local : ERPLibre + Odoo se sont installés sur la
MAUVAISE machine, et le journal l'affichait sans que rien n'alerte
(« → 192.168.123.118 »).
Pour une VM distante, l'alias ~/.ssh/config est la seule vérité : il
porte le rebond par l'hôte Proxmox.
"""
def _wrapper(self, **kw):
"""Le script du lanceur, capturé sans rien exécuter."""
vus = {}
vrai = mon.subprocess.Popen
class FauxPopen:
def __init__(self, argv, *a, **k):
vus["argv"] = argv
mon.subprocess.Popen = FauxPopen
try:
mon._launch_one("cible", "echo bonjour", "/dev/null", "vm-a", **kw)
finally:
mon.subprocess.Popen = vrai
return vus["argv"][-1]
def test_a_local_vm_still_gets_its_address_refreshed(self):
# Le bail change en cours de route (cloud-init renomme l'hôte) : la
# ré-résolution est indispensable pour une VM LOCALE.
script = self._wrapper(pve=False)
self.assertIn("virsh", script)
def test_a_proxmox_vm_is_never_re_resolved(self):
# C'est le correctif : aucun appel à virsh, donc aucun risque de
# tomber sur un domaine local homonyme.
script = self._wrapper(pve=True)
self.assertNotIn("virsh", script)
def test_the_target_stays_the_alias(self):
script = self._wrapper(pve=True)
self.assertIn("ip=cible", script)
[FIX] proxmox : viser la bonne machine, et dire la vérité sur le disque « s » ouvrait encore la VM locale homonyme : la vue de progression n'avait que le NOM de la VM, et l'entrée ~/.ssh/config n'existe pas encore à ce moment. Le déploiement lui passe maintenant « ssh -J <hôte> user@<ip> », qui ne dépend de rien. Deux voisines du même défaut, jamais rapportées mais aussi graves : la console ouvrait « virsh console <nom> » — celle de la VM LOCALE — et la pause suspendait la locale. Les deux passent par le VMID sur l'hôte. La colonne Disque annonçait « 6.0G/6.0G » sur une VM qui n'avait écrit que 1,2 Go : « du -sb » rend la taille APPARENTE, et un disque raw creux la donne entière. « du -sB1 » compte les blocs. Enfin l'écran de déploiement dit ce qui l'attend : « Quitter (q) pour lancer l'installation d'ERPLibre » — on attendait devant une fenêtre terminée sans le savoir. --- EN --- "s" still opened the homonymous local VM: the progress view only had the VM's NAME, and the ~/.ssh/config entry does not exist yet at that point. The deployment now hands it "ssh -J <host> user@<ip>", which depends on nothing. Two neighbours of the same defect, never reported but just as serious: the console opened "virsh console <name>" — the LOCAL VM's — and pause suspended the local one. Both now go through the VMID on the host. The Disk column claimed "6.0G/6.0G" on a VM that had written 1.2 GB: "du -sb" returns the APPARENT size, and a sparse raw disk gives it in full. "du -sB1" counts blocks. Finally the deployment screen says what awaits it: "Quit (q) to start the ERPLibre install" — one waited before a finished window without knowing. Assisted-by: Claude Opus 5
2026-08-24 06:58:02 -04:00
class TestLeDisque(unittest.TestCase):
"""La colonne Disque annonçait un disque PLEIN qui ne l'était pas.
Rapporté : « 6.0G/6.0G » sur une VM dont l'invité disait « 845M utilisés
sur 5.8G ». La mesure venait de « du -sb », qui rend la taille APPARENTE :
un disque raw creux la donne entière. « du -sB1 » compte les blocs
réellement occupés — 1,2 Go, ce qui correspond.
"""
def test_the_command_counts_real_blocks(self):
self.assertIn("du -sB1", mon.PVE_STATS_CMD)
self.assertNotIn("du -sb", mon.PVE_STATS_CMD)
def test_the_measure_is_read_per_vmid(self):
texte = (
'[{"vmid":101,"name":"vm-a","disk":0,"maxdisk":6442450944,'
'"mem":1,"maxmem":2,"diskwrite":0,"status":"running","uptime":1}]\n'
"---ERPLIBRE-DU---\n"
"1268518912\t/var/lib/vz/images/101/\n"
"4294967296\t/var/lib/vz/images/999/\n"
)
rec = mon.parse_pvestats(texte)["vm-a"]
self.assertEqual(rec["disk_used"], 1268518912)
self.assertEqual(rec["disk_total"], 6442450944)
self.assertEqual(
mon.fmt_pair(rec["disk_used"], rec["disk_total"]), "1.2G/6.0G"
)
class TestOuVaLaCommande(unittest.TestCase):
"""Chaque action doit viser la BONNE machine.
Une VM distante et un domaine local peuvent porter le même nom : « s »
ouvrait la locale, la console ouvrait la console de la locale, et la pause
suspendait la locale. Le VMID et le rebond sont les seules désignations
qui ne trompent pas.
"""
LOCALE = {"name": "vm-a", "ip": "192.168.123.118"}
DISTANTE = {
"name": "vm-a",
"ip": "pve1+vm-a",
"pve": {
"target": "erplibre@pve1",
"sudo": "sudo ",
"jump": "",
"vmid": 101,
"addr": "10.10.10.151",
},
}
def test_ssh_to_a_local_vm_uses_its_address(self):
self.assertIn(
"erplibre@192.168.123.118", mon.vm_ssh_prefix(self.LOCALE)
)
self.assertNotIn("-J", mon.vm_ssh_prefix(self.LOCALE))
def test_ssh_to_a_remote_vm_goes_through_the_jump(self):
cmd = mon.vm_ssh_prefix(self.DISTANTE)
self.assertIn("-J", cmd)
self.assertIn("erplibre@pve1", cmd)
self.assertIn("erplibre@10.10.10.151", cmd)
def test_without_an_address_it_falls_back_to_the_alias(self):
vm = {"name": "vm-a", "ip": "pve1+vm-a", "pve": {"target": "pve1"}}
self.assertIn("erplibre@pve1+vm-a", mon.vm_ssh_prefix(vm))
def test_the_console_of_a_remote_vm_is_qm_terminal(self):
cmd = mon.pve_host_cmd(
self.DISTANTE["pve"], "qm terminal 101", tty=True
)
self.assertIn("ssh -t", cmd)
self.assertIn("qm terminal 101", cmd)
self.assertIn("sudo", cmd)
# Et surtout PAS virsh, qui viserait le domaine local homonyme.
self.assertNotIn("virsh", cmd)
def test_a_host_without_sudo_is_not_wrapped(self):
cmd = mon.pve_host_cmd({"target": "root@pve1", "sudo": ""}, "qm list")
self.assertNotIn("sh -c", cmd)
[ADD] proxmox : la colonne Odoo, le web et la suppression par l'hôte La colonne Odoo testait le port 8069 depuis le poste : une VM sur pont interne n'y répond jamais, elle restait « — » quel que soit l'état d'Odoo. Le test part maintenant DE L'HÔTE, glissé dans l'appel des statistiques déjà payé — aucun aller-retour de plus. Éprouvé sur une VM d'essai servant sur 8069 : 🟢, et « — » sur la voisine qui n'a rien. Deux dernières actions visaient encore la mauvaise machine. La touche « w » ouvrait une page morte : l'adresse d'un pont interne n'est pas routable d'ici, elle passe donc par un tunnel local le temps de la visite — tenu par son PID, car « pkill -f <motif> » tuait le shell qui l'avait lancé, le motif figurant dans sa propre ligne de commande. Et la SUPPRESSION appelait « virsh undefine <nom> », qui aurait effacé le domaine local homonyme : elle passe par « qm destroy <vmid> » sur l'hôte. --- EN --- The Odoo column probed port 8069 from the workstation: a VM on an internal bridge never answers there, so it stayed "—" whatever Odoo was doing. The probe now runs FROM THE HOST, folded into the stats call already paid for — no extra round trip. Proven on a test VM serving on 8069: 🟢, and "—" on the neighbour that serves nothing. Two last actions still aimed at the wrong machine. Key "w" opened a dead page: an internal-bridge address is not routable from here, so it now goes through a local tunnel for the length of the visit — held by its PID, since "pkill -f <pattern>" killed the very shell that had launched it, the pattern being in its own command line. And DELETION called "virsh undefine <name>", which would have erased the homonymous local domain: it now goes through "qm destroy <vmid>" on the host. Assisted-by: Claude Opus 5
2026-08-24 07:16:50 -04:00
class TestLaColonneOdoo(unittest.TestCase):
"""Le port 8069 se teste DEPUIS L'HÔTE, dans l'appel déjà payé.
Sondé depuis le poste, il ne répond jamais pour une VM sur pont interne :
la colonne restait « — » quel que soit l'état d'Odoo. Un aller-retour ssh
de plus par tour aurait coûté une seconde ; celui des statistiques est
déjà là.
"""
def test_the_probe_rides_along_the_stats_call(self):
cmd = mon.pve_stats_cmd(["10.10.10.150", "10.10.10.151"])
self.assertIn("pvesh get /cluster/resources", cmd)
self.assertIn("/dev/tcp/$a/8069", cmd)
self.assertIn("10.10.10.151", cmd)
def test_without_addresses_nothing_is_added(self):
self.assertEqual(mon.pve_stats_cmd([]), mon.PVE_STATS_CMD)
def test_the_answer_is_read_per_address(self):
sortie = (
"[]\n---ERPLIBRE-DU---\n---ERPLIBRE-ODOO---\n"
"ODOO 10.10.10.151\n"
)
self.assertEqual(mon.parse_odoo_probe(sortie), {"10.10.10.151"})
def test_a_silent_port_yields_nothing(self):
self.assertEqual(
mon.parse_odoo_probe("[]\n---ERPLIBRE-DU---\n"), set()
)
self.assertEqual(mon.parse_odoo_probe(""), set())
def test_the_du_block_is_not_mistaken_for_a_probe(self):
# Les deux blocs se suivent : le lecteur doit prendre le bon.
sortie = (
"[]\n---ERPLIBRE-DU---\n1268518912\t/var/lib/vz/images/101/\n"
"---ERPLIBRE-ODOO---\nODOO 10.10.10.151\n"
)
self.assertEqual(mon.parse_odoo_probe(sortie), {"10.10.10.151"})
class TestLeWebEtLaSuppression(unittest.TestCase):
"""Les deux dernières actions qui visaient la mauvaise machine."""
INFO = {
"target": "erplibre-proxmox-9",
"sudo": "sudo ",
"jump": "",
"vmid": 101,
"addr": "10.10.10.151",
}
def test_the_web_view_tunnels_through_the_host(self):
argv = mon.web_tunnel_argv(self.INFO)
self.assertEqual(argv[-1], "erplibre-proxmox-9")
self.assertIn("-L", argv)
self.assertIn("18069:10.10.10.151:8069", argv)
# Pas de « -f » : le tunnel se referme par son PID, et « pkill -f »
# tuait le shell qui l'avait lancé.
self.assertNotIn("-f", argv)
def test_a_local_vm_needs_no_tunnel(self):
self.assertIsNone(mon.web_tunnel_argv(None))
self.assertIsNone(mon.web_tunnel_argv({"target": "pve1"}))
def test_the_jump_of_the_host_is_chained(self):
argv = mon.web_tunnel_argv(dict(self.INFO, jump="rebond"))
self.assertIn("-J", argv)
self.assertIn("rebond", argv)
def test_deleting_a_remote_vm_uses_its_vmid(self):
cmd = mon.delete_vm_cmd_pve(self.INFO)
self.assertIn("qm destroy 101", cmd)
self.assertIn("--purge", cmd)
# « virsh undefine <nom> » aurait effacé le domaine LOCAL homonyme.
self.assertNotIn("virsh", cmd)
def test_deleting_a_local_vm_is_unchanged(self):
cmd = mon.delete_vm_cmd("vm-a", True)
self.assertIn("virsh undefine", cmd)
self.assertIn("/var/lib/libvirt/images/vm-a.qcow2", cmd)
[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-24 05:11:58 -04:00
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)