[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
This commit is contained in:
Mathieu Benoit 2026-08-24 05:11:58 -04:00
parent f2b118a110
commit 07df33b3ff
6 changed files with 449 additions and 16 deletions

View file

@ -406,6 +406,69 @@ class ProxmoxMenuMixin:
)
actives = sum(1 for v in vms if v["status"] == "running")
print(f"\n {len(vms)} VM, {actives} {t('running')}")
# Le pendant du sous-menu de QEMU/KVM : la liste est le bon endroit
# pour agir sur ce qu'on vient de lire.
print(f"\n [1] {t('Change the state of one or more VMs')}")
if input(t("Choice (blank = back): ")).strip() == "1":
self._pve_change_state(vms)
def _pve_change_state(self, vms=None):
"""Démarre ou éteint des VM de l'hôte, avec double validation.
« shutdown » et non « stop » : on demande à l'invité de s'arrêter, ce
qui laisse Odoo fermer ses connexions PostgreSQL. « stop » coupe le
courant — il est offert en second, nommé pour ce qu'il est.
"""
from script.proxmox import proxmox_deploy as pve
vms = vms if vms is not None else self._pve_vms()
if not vms:
print(f"\n{t('No VM on this Proxmox host.')}")
return
print(f"\n{t('Available VMs:')}")
for i, vm in enumerate(vms, 1):
print(
f" [{i}] {vm['vmid']:<7} {vm['name'][:34]:<34} {vm['status']}"
)
print(f" [all] {t('select all')}")
brut = input(t("Selection (numbers, or 'all'): ")).strip().lower()
if not brut:
print(t("Nothing selected."))
return
noms = [vm["name"] for vm in vms]
if brut in ("all", "*"):
choisies = list(vms)
else:
retenus = set(self._parse_index_selection(brut, noms))
choisies = [vm for vm in vms if vm["name"] in retenus]
if not choisies:
print(t("Nothing selected."))
return
print(
f"\n [1] {t('start')} [2] {t('shutdown (clean)')}"
f" [3] {t('stop (pulls the plug)')}"
)
geste = input(t("Choice: ")).strip()
verbe = {"1": "start", "2": "shutdown", "3": "stop"}.get(geste)
if not verbe:
print(t("Cancelled."))
return
print(
f"\n {verbe} : "
+ ", ".join(f"{vm['name']} ({vm['vmid']})" for vm in choisies)
)
if not self._is_yes(input(t("Confirm? (y/N): "))):
print(t("Cancelled."))
return
for vm in choisies:
code, _o = self._pve_show(f"qm {verbe} {vm['vmid']}", timeout=300)
marque = "✓" if code == 0 else "✗"
print(f" {marque} {vm['name']}")
# L'état APRÈS : c'est la seule preuve que le geste a porté.
_c, out = self._pve_show("qm list", quiet=True)
for vm in pve.parse_qm_list(out):
if any(vm["vmid"] == c["vmid"] for c in choisies):
print(f" {vm['name']:<34} {vm['status']}")
def _pve_vm_ip(self):
"""Adresse d'une VM, par l'agent invité.
@ -1073,8 +1136,27 @@ class ProxmoxMenuMixin:
# Rien à installer ? La commande distante regarde alors la VM
# ARRIVER (cloud-init, puis relevé système) : c'est ce que le
# tableau de bord montre.
#
# La carte des hôtes suit : sans elle, les colonnes vivantes
# (état, durée, écrit/s, RAM, disque) restaient VIDES pour une VM
# posée sur un Proxmox distant — elles viennent de virsh, qui ne
# connaît pas cet hôte.
cartes_pve = {
vm["name"]: {
"target": host["target"],
"sudo": host.get("sudo") or "",
"jump": host.get("jump") or "",
"vmid": vm.get("vmid"),
}
for vm in joignables
if vm.get("vmid")
}
self._qemu_install_erplibre_monitored(
noms, branche, {n: n for n in noms}, finale
noms,
branche,
{n: n for n in noms},
finale,
pve=cartes_pve,
)
return
# Sans suivi mais avec quelque chose à installer : en série, sortie à
@ -1384,6 +1466,11 @@ class ProxmoxMenuMixin:
{"section": t("Host")},
{"prompt_description": t("Change the Proxmox host")},
]
# Même extension que le menu QEMU/KVM : ce que todo.json ajoute
# s'affiche à la suite et se lance par son numéro.
supplement = self.config_file.get_config("proxmox_from_makefile")
if supplement:
choices.extend(supplement)
help_info = self.fill_help_info(choices)
while True:
hote = self._pve_host(ask=False)
@ -1432,7 +1519,18 @@ class ProxmoxMenuMixin:
self._pve_forget_host()
self._pve_pick_host()
else:
print(t("Command not found !"))
introuvable = True
try:
numero = int(status)
# Les sections ne comptent pas dans la numérotation.
reelles = [c for c in choices if not c.get("section")]
if 0 < numero <= len(reelles):
introuvable = False
self.execute_from_configuration(reelles[numero - 1])
except ValueError:
pass
if introuvable:
print(t("Command not found !"))
def _pve_fetch_image(self):
"""Télécharge une image cloud SUR l'hôte Proxmox.

View file

@ -270,6 +270,7 @@ class QemuDeployMixin:
python_provider="",
app_store="deb",
vm_tools=(),
pve=None,
):
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre
le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
@ -327,6 +328,11 @@ class QemuDeployMixin:
"version": v,
"arch": a,
}
# {nom: {target, sudo, vmid}} quand la VM vit sur un hôte
# Proxmox : le suivi lui demandera son état, virsh ne le
# connaît pas.
if (pve or {}).get(name):
entry["pve"] = pve[name]
# Les outils imposent une commande PAR VM même quand tout le
# reste est commun : ils dépendent de l'architecture de la
# machine et de sa saveur de bureau, que seule cette boucle

View file

@ -291,17 +291,19 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
vm["name"],
installs=bool(branch),
)
entries.append(
{
"name": vm["name"],
"ip": vm["ip"],
"distro": vm.get("distro"),
"version": vm.get("version"),
"arch": vm.get("arch"),
"log": log_path,
"ssh": f"ssh erplibre@{vm['ip']}",
}
)
entree = {
"name": vm["name"],
"ip": vm["ip"],
"distro": vm.get("distro"),
"version": vm.get("version"),
"arch": vm.get("arch"),
"log": log_path,
"ssh": f"ssh erplibre@{vm['ip']}",
}
# Une VM posée sur un hôte Proxmox : c'est LUI qui connaît son état.
if vm.get("pve"):
entree["pve"] = vm["pve"]
entries.append(entree)
manifest = {
"branch": branch,
"started": time.time(),
@ -1293,6 +1295,105 @@ def read_domstats() -> str:
return res.stdout if res.returncode == 0 else ""
# UN appel par HÔTE : « /cluster/resources » rend l'état, la mémoire, le
# disque et le cumul écrit de TOUTES ses VM d'un coup. Le « du » qui suit
# donne la taille RÉELLEMENT occupée : sur un stockage en fichiers, Proxmox
# rapporte « disk: 0 » — il ne la calcule pas.
PVE_STATS_CMD = (
"pvesh get /cluster/resources --type vm --output-format json;"
" echo '---ERPLIBRE-DU---';"
" du -sb /var/lib/vz/images/*/ 2>/dev/null || true"
)
# Une VM distante se relève moins souvent qu'une locale : chaque tour coûte
# une poignée de main ssh (mesuré 1 s), quand « virsh domstats » coûte 0,03 s
# pour tout le parc. Cinq secondes suffisent à voir une installation avancer.
PVE_STATS_INTERVAL = 5.0
# Proxmox dit « running » / « stopped » ; le suivi raisonne en états libvirt.
# Une VM absente de la réponse de l'hôte a vraiment disparu.
PVE_ETATS = {"running": "running", "stopped": "shut off", "paused": "paused"}
_PVE_CACHE = {"at": 0.0, "stats": {}}
def parse_pvestats(text: str) -> dict:
"""Sortie de PVE_STATS_CMD -> {nom: relevé}, même forme que domstats.
Même forme exprès : les colonnes, le débit d'écriture et la RAM se
calculent alors sans savoir d'où vient la mesure. Une VM sur un hôte
Proxmox distant n'avait aucune de ces colonnes — elles viennent de virsh,
qui ne sait rien de cet hôte.
"""
brut, _, tailles = (text or "").partition("---ERPLIBRE-DU---")
try:
ressources = json.loads(brut.strip() or "[]")
except ValueError:
return {}
# {vmid: octets} depuis « du -sb /var/lib/vz/images/<vmid>/ ».
occupe = {}
for ligne in tailles.splitlines():
parts = ligne.split()
if len(parts) == 2 and parts[0].isdigit():
vmid = parts[1].rstrip("/").rsplit("/", 1)[-1]
if vmid.isdigit():
occupe[int(vmid)] = int(parts[0])
out = {}
maintenant = time.time()
for r in ressources if isinstance(ressources, list) else ():
nom = r.get("name")
if not nom:
continue
total = int(r.get("maxdisk") or 0)
utilise = int(r.get("disk") or 0) or occupe.get(
int(r.get("vmid") or 0), 0
)
out[nom] = {
"ram_used": int(r.get("mem") or 0),
"ram_total": int(r.get("maxmem") or 0),
# Le relevé vient d'être fait : il n'est pas périmé, et c'est ce
# que `ram_pair` vérifie avant d'afficher quoi que ce soit.
"ram_at": maintenant,
"wr_bytes": int(r.get("diskwrite") or 0),
"disk_used": utilise,
"disk_total": total,
"state": r.get("status") or "",
"uptime": int(r.get("uptime") or 0),
}
return out
def read_pvestats(vms, now=None) -> dict:
"""{nom: relevé} des VM posées sur un hôte Proxmox, ou {}.
Les VM concernées sont celles dont le manifeste porte un bloc « pve »
(adresse de l'hôte, sudo, vmid). Un appel par hôte, mis en cache
PVE_STATS_INTERVAL secondes.
"""
hotes = {}
for vm in vms or ():
info = vm.get("pve") or {}
if info.get("target"):
hotes[(info["target"], info.get("sudo") or "")] = info
if not hotes:
return {}
maintenant = now if now is not None else time.time()
if maintenant - _PVE_CACHE["at"] < PVE_STATS_INTERVAL:
return dict(_PVE_CACHE["stats"])
try:
from script.proxmox import proxmox_deploy as pve
except ImportError: # pragma: no cover - le module est dans le dépôt
return {}
stats = {}
for (target, sudo), info in hotes.items():
code, sortie = pve.run(
{"target": target, "sudo": sudo, "jump": info.get("jump", "")},
PVE_STATS_CMD,
30,
)
if code == 0:
stats.update(parse_pvestats(sortie))
_PVE_CACHE.update({"at": maintenant, "stats": stats})
return dict(stats)
def arm_balloon(names) -> None:
"""Arme la période de collecte du ballon (5 s) sur chaque VM.
@ -1931,6 +2032,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# 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())
# Les VM d'un hôte Proxmox distant : virsh ne les voit pas, leurs
# colonnes restaient vides. Même forme de relevé, donc la suite ne
# change pas d'un iota.
stats.update(read_pvestats(vms))
now_s = time.time()
for name, rec in stats.items():
self._wrate.add(name, rec["wr_bytes"], now_s)
@ -2191,13 +2296,28 @@ def run_monitor(manifest_path: str, run_app: bool = True):
states = await asyncio.to_thread(virsh_domstates)
except Exception:
return
# Une VM posée sur un hôte Proxmox est ABSENTE de « virsh list » :
# elle passait donc pour EFFACÉE, ce qui éteignait du même coup
# ses colonnes vivantes. Son état vient de l'hôte.
distants = await asyncio.to_thread(read_pvestats, vms)
for vm in vms:
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
nom = vm["name"]
if vm.get("pve"):
releve = distants.get(nom)
self._domstate[nom] = PVE_ETATS.get(
(releve or {}).get("state"), "gone"
)
continue
self._domstate[nom] = states.get(nom, "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.
# Les VM distantes en sont exclues : « virsh dommemstat » ne les
# atteint pas, et l'hôte donne déjà leur mémoire.
vivantes = [
vm["name"] for vm in vms if states.get(vm["name"]) == "running"
vm["name"]
for vm in vms
if not vm.get("pve") and states.get(vm["name"]) == "running"
]
if vivantes:
await asyncio.to_thread(arm_balloon, vivantes)
@ -2210,7 +2330,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# appel virsh par VM — celui-ci est déjà le relevé lent.
changed = False
for vm in vms:
if self._domstate.get(vm["name"]) == "gone":
if self._domstate.get(vm["name"]) == "gone" or vm.get("pve"):
# Une VM distante n'a pas de bail chez nous : son adresse
# est celle que cloud-init a posée, et virsh ne la voit
# pas. La chercher rendrait « gone » à chaque tour.
continue
ip = await asyncio.to_thread(virsh_ip, vm["name"])
if ip and ip != vm.get("ip"):

View file

@ -3982,6 +3982,29 @@ TRANSLATIONS = {
"fr": "Fichiers orphelins :",
"en": "Orphan files:",
},
# « models » manquait alors que ses trois voisines de la même ligne
# étaient traduites : le rapport de qualité de migration sortait
# « 42 modules · 812 models · 1 204 vues ».
"models": {
"fr": "modèles",
"en": "models",
},
"shutdown (clean)": {
"fr": "éteindre (proprement)",
"en": "shutdown (clean)",
},
"stop (pulls the plug)": {
"fr": "arrêter (coupe le courant)",
"en": "stop (pulls the plug)",
},
"Choice (blank = back): ": {
"fr": "Choix (vide = retour) : ",
"en": "Choice (blank = back): ",
},
"Confirm? (y/N): ": {
"fr": "Confirmer ? (o/N) : ",
"en": "Confirm? (y/N): ",
},
"Creating the bridge on the host…": {
"fr": "Création du pont sur l'hôte…",
"en": "Creating the bridge on the host…",

View file

@ -0,0 +1,148 @@
#!/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)

View file

@ -230,6 +230,41 @@ class MenuCoherence:
self.assertEqual(set(self.EXPECTED) - keys, set())
class TestLaParitéProxmox(unittest.TestCase):
"""Deux manques signalés par l'audit du découpage, comblés.
Le menu Proxmox n'offrait pas de changer l'état d'une VM (QEMU/KVM l'a
dans « Lister les VM »), et n'acceptait pas les commandes ajoutées par
todo.json — deux capacités que son vis-à-vis avait.
"""
@classmethod
def setUpClass(cls):
cls.src = (TODO_DIR / "proxmox_menu.py").read_text(encoding="utf-8")
def test_the_list_offers_to_change_the_state(self):
self.assertIn("_pve_change_state", self.src)
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO as CLASSE
self.assertTrue(callable(CLASSE._pve_change_state))
def test_a_clean_shutdown_comes_before_pulling_the_plug(self):
# « shutdown » laisse Odoo fermer ses connexions PostgreSQL ; « stop »
# coupe le courant. L'ordre des choix est la seule chose qui le dit.
self.assertLess(
self.src.index("shutdown (clean)"),
self.src.index("stop (pulls the plug)"),
)
def test_the_menu_reads_its_extra_commands_from_todo_json(self):
self.assertIn('get_config("proxmox_from_makefile")', self.src)
# Et le dispatch sait les lancer, sections non comptées.
self.assertIn("execute_from_configuration", self.src)
class TestLArbreDesMenus(unittest.TestCase):
"""L'écran de télémétrie lit le CODE, pas la classe assemblée.