[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:
parent
f2b118a110
commit
07df33b3ff
6 changed files with 449 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
148
test/test_qemu_monitor_pve.py
Normal file
148
test/test_qemu_monitor_pve.py
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue