[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
This commit is contained in:
Mathieu Benoit 2026-08-24 06:58:02 -04:00
parent 07a9f66626
commit 4c2adb7c56
6 changed files with 306 additions and 26 deletions

View file

@ -591,7 +591,9 @@ def res_row_widgets(index, vm, presets, labels=None, null=None):
return widgets
def run_deploy_progress(jobs, parallelism, run_app: bool = True):
def run_deploy_progress(
jobs, parallelism, run_app: bool = True, ssh_cmds=None, suite=""
):
"""Déploie `jobs` = [(id, nom, argv)] en parallèle, un bloc repliable par
VM. Renvoie [(nom, rc, sortie, durée)]. `run_app=False` renvoie l'app.
@ -680,9 +682,14 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
self.run_job(jid, name, parts)
def _refresh_summary(self):
# CE QUI SUIT, dit ici : l'installation d'ERPLibre ne démarre
# qu'au moment où l'on quitte cet écran, et rien ne le disait —
# on attendait devant une fenêtre pourtant terminée.
fini = self._done >= len(jobs)
apres = f"\n → {suite}" if (fini and suite) else ""
self.query_one("#summary", Static).update(
f" {self._done}/{len(jobs)} — "
f"{fmt_dur(time.time() - self._t0)}"
f"{fmt_dur(time.time() - self._t0)}{apres}"
)
# `thread=True` : subprocess.run est bloquant ; le faire dans un
@ -765,10 +772,11 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
def action_ssh(self) -> None:
"""Entre dans la VM créée, sans quitter l'écran.
Par son NOM et non par son adresse : c'est l'entrée
~/.ssh/config que le déploiement vient d'écrire qui sait comment
l'atteindre — et pour une VM posée sur un hôte Proxmox, elle
porte le rebond, seul chemin vers son réseau interne.
La commande vient de l'APPELANT quand il sait joindre la VM : sur
Proxmox, l'entrée ~/.ssh/config n'est écrite qu'après le
déploiement, et le nom de la VM peut désigner une machine LOCALE
homonyme — « s » ouvrait alors la mauvaise. Le déploiement passe
donc « ssh -J <hôte> user@<ip> », qui ne dépend de rien.
« suspend() » rend le terminal : ssh a besoin du clavier, et
Textual le tient encore.
@ -780,8 +788,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
return
# La dernière créée : c'est celle qu'on regarde.
nom = self._reussies[-1]
cmd = (ssh_cmds or {}).get(nom) or f"ssh {shlex_quote(nom)}"
with self.suspend():
os.system(f"ssh {shlex_quote(nom)} || true")
print(f"\n→ {cmd}\n")
os.system(f"{cmd} || true")
# -- presse-papiers (OSC 52 : traverse SSH) --------------------- #
def _copy(self, text, what):

View file

@ -967,7 +967,33 @@ class ProxmoxMenuMixin:
# sortie ; celle-ci les écrit, ce qui vaut mieux qu'un défilement.
session = self._pve_log_dir()
print(f"\n {t('Log:')} {session}")
resultats = run_deploy_progress(travaux, spec.get("parallelism") or 1)
# Comment joindre chaque VM SANS dépendre de ~/.ssh/config, qui n'est
# écrit qu'après : par le rebond de l'hôte, explicitement. C'est ce que
# « s » utilise dans la vue de progression — sans quoi il partait sur
# le nom de la VM, donc sur une locale homonyme (rapporté).
cibles_ssh = {}
for vm in spec["vms"]:
ip = pve.ip_from_ipconfig(vm.get("ipconfig") or "")
if ip:
compte = (spec.get("user") or "erplibre") + "@" + ip
cibles_ssh[vm["name"]] = (
f"ssh -J {shlex.quote(host['target'])} "
f"{shlex.quote(compte)}"
)
# Ce qui attend derrière cet écran : sans le dire, on reste devant
# une fenêtre « terminée » sans savoir que l'installation d'ERPLibre
# démarre en la quittant.
suite = ""
if spec.get("install"):
suite = t("Quit (q) to start the ERPLibre install")
elif spec.get("monitor", True):
suite = t("Quit (q) to follow the VM starting up")
resultats = run_deploy_progress(
travaux,
spec.get("parallelism") or 1,
ssh_cmds=cibles_ssh,
suite=suite,
)
reussies = [nom for nom, code, _o, _d in resultats if code == 0]
for nom, code, sortie, duree in resultats:
chemin = self._pve_write_log(
@ -1215,6 +1241,10 @@ class ProxmoxMenuMixin:
"sudo": host.get("sudo") or "",
"jump": host.get("jump") or "",
"vmid": vm.get("vmid"),
# L'adresse INTERNE : elle n'est pas routable d'ici, mais
# elle l'est depuis l'hôte. Avec le rebond, le tableau de
# bord entre dans la VM sans dépendre de ~/.ssh/config.
"addr": vm.get("adresse") or "",
}
for vm in joignables
if vm.get("vmid")

View file

@ -1309,10 +1309,14 @@ def read_domstats() -> str:
# 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.
#
# « -sB1 » et NON « -sb » : le second rend la taille APPARENTE, et un disque
# raw creux de 6 Go la donne entière. La colonne affichait donc « 6.0G/6.0G »,
# un disque plein, quand l'invité n'avait écrit que 1,2 Go — rapporté.
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"
" du -sB1 /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
@ -1337,7 +1341,7 @@ def parse_pvestats(text: str) -> dict:
ressources = json.loads(brut.strip() or "[]")
except ValueError:
return {}
# {vmid: octets} depuis « du -sb /var/lib/vz/images/<vmid>/ ».
# {vmid: octets} depuis « du -sB1 /var/lib/vz/images/<vmid>/ ».
occupe = {}
for ligne in tailles.splitlines():
parts = ligne.split()
@ -1404,6 +1408,46 @@ def read_pvestats(vms, now=None) -> dict:
return dict(stats)
def vm_ssh_prefix(vm) -> str:
"""« ssh … » pour entrer dans CETTE VM, adresse comprise.
Une VM d'un hôte Proxmox vit derrière lui : son adresse n'est pas
routable d'ici, et seul le rebond y mène. On le construit explicitement
plutôt que de compter sur un alias ~/.ssh/config, qui peut ne pas exister
— ou, pire, désigner une VM LOCALE homonyme. C'est ce qui a fait ouvrir
la mauvaise machine avec « s ».
"""
info = (vm or {}).get("pve") or {}
adresse = info.get("addr")
if info.get("target") and adresse:
saut = f"-J {shlex.quote(info['jump'])} " if info.get("jump") else ""
return (
f"ssh {SSH_OPTS} {saut}-J {shlex.quote(info['target'])} "
f"erplibre@{adresse}"
)
return f"ssh {SSH_OPTS} erplibre@{(vm or {}).get('ip')}"
def pve_host_cmd(info, remote, tty=False) -> str:
"""Commande shell qui exécute `remote` SUR l'hôte Proxmox d'une VM.
Chaque action du tableau de bord qui parlait à libvirt par le NOM frappait
la mauvaise machine dès qu'un domaine local portait le même : la console
ouvrait celle de la VM locale, la pause suspendait la locale. L'hôte est
la seule autorité pour une VM distante, et le VMID son seul identifiant.
"""
sudo = (info or {}).get("sudo") or ""
cible = (info or {}).get("target") or ""
prefixe = f"{sudo}sh -c {shlex.quote(remote)}" if sudo else remote
saut = (
f"-J {shlex.quote(info['jump'])} " if (info or {}).get("jump") else ""
)
return (
f"ssh {'-t ' if tty else ''}{saut}{shlex.quote(cible)} "
f"{shlex.quote(prefixe)}"
)
def arm_balloon(names) -> None:
"""Arme la période de collecte du ballon (5 s) sur chaque VM.
@ -2384,8 +2428,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
vm = self._vm_by_name(self._selected)
if not vm:
return
cmd = vm_ssh_prefix(vm)
with self.suspend():
os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true")
print(f"\n→ {cmd}\n")
os.system(f"{cmd} || true")
def action_console(self) -> None:
"""Console série de la VM, sans quitter le suivi.
@ -2402,18 +2448,33 @@ def run_monitor(manifest_path: str, run_app: bool = True):
vm = self._vm_by_name(self._selected)
if not vm:
return
name = shlex.quote(vm["name"])
info = vm.get("pve")
if info:
# VM d'un hôte Proxmox : sa console est « qm terminal », sur
# l'hôte. « virsh console <nom> » ouvrait celle du domaine
# LOCAL homonyme — la mauvaise machine, sans le dire.
cmd = pve_host_cmd(
info, f"qm terminal {int(info.get('vmid') or 0)}", tty=True
)
titre = (
f"qm terminal {info.get('vmid')} @ {info.get('target')}"
)
sortie = "Ctrl+O"
else:
cmd = f"sudo virsh console {shlex.quote(vm['name'])}"
titre = f"virsh console {vm['name']}"
sortie = "Ctrl+]"
with self.suspend():
# La console n'affiche que ce qui arrive APRÈS l'attachement :
# sur une VM déjà démarrée l'écran reste noir tant qu'on n'a
# rien envoyé. On le dit, plutôt que de laisser croire à un gel.
print(f"\n→ virsh console {vm['name']}")
print(f"\n→ {titre}")
print(
" Écran vide ? Appuyez sur Entrée : la console ne montre"
" que la sortie qui suit l'attachement."
)
print(" Ctrl+] puis Entrée pour revenir au suivi.\n")
os.system(f"sudo virsh console {name} || true")
print(f" {sortie} puis Entrée pour revenir au suivi.\n")
os.system(f"{cmd} || true")
def action_web(self) -> None:
"""Ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI
@ -2552,15 +2613,30 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# -- pause / reprise de tout le parc -------------------------------- #
@staticmethod
def _virsh_bulk(action, names):
for n in names:
def _virsh_bulk(action, cibles):
"""Suspend/reprend chaque VM, chacune par SON hyperviseur.
`cibles` : [(nom, info_pve|None)]. Une VM distante se suspend par
son VMID sur son hôte — « virsh suspend <nom> » aurait mis en
pause le domaine LOCAL homonyme."""
for nom, info in cibles:
try:
subprocess.run(
["sudo", "virsh", action, n],
capture_output=True,
text=True,
timeout=30,
)
if info:
vmid = int(info.get("vmid") or 0)
subprocess.run(
pve_host_cmd(info, f"qm {action} {vmid}"),
shell=True,
capture_output=True,
text=True,
timeout=60,
)
else:
subprocess.run(
["sudo", "virsh", action, nom],
capture_output=True,
text=True,
timeout=30,
)
except (OSError, subprocess.SubprocessError):
pass
@ -2701,8 +2777,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
with self.suspend():
print(f"\n=== {title} — {vm['name']} ===")
os.system(
f"ssh {SSH_OPTS} erplibre@{vm['ip']} "
f"{shlex.quote(cmd)} || true"
f"{vm_ssh_prefix(vm)} " f"{shlex.quote(cmd)} || true"
)
input("\nEntrée pour revenir au suivi… ")
@ -2751,7 +2826,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
async def _bulk_worker(self, action):
want = "running" if action == "suspend" else "paused"
targets = [
vm["name"]
(vm["name"], vm.get("pve"))
for vm in vms
if self._domstate.get(vm["name"]) == want
]

View file

@ -3986,6 +3986,14 @@ TRANSLATIONS = {
"fr": "Fichiers orphelins :",
"en": "Orphan files:",
},
"Quit (q) to start the ERPLibre install": {
"fr": "Quitter (q) pour lancer l'installation d'ERPLibre",
"en": "Quit (q) to start the ERPLibre install",
},
"Quit (q) to follow the VM starting up": {
"fr": "Quitter (q) pour suivre la mise en route de la VM",
"en": "Quit (q) to follow the VM starting up",
},
"A local VM already bears this name:": {
"fr": "Une VM locale porte déjà ce nom :",
"en": "A local VM already bears this name:",

View file

@ -178,6 +178,88 @@ class TestLaBonneMachine(unittest.TestCase):
self.assertIn("ip=cible", script)
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)
class TestLEtat(unittest.TestCase):
"""Une VM absente de « virsh list » passait pour EFFACÉE."""

View file

@ -142,5 +142,80 @@ class TestLaToucheSsh(unittest.TestCase):
self.assertEqual(vu["reussies"], [])
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestCeQuiSuit(unittest.TestCase):
"""Rapporté : on attendait devant une fenêtre « terminée » sans savoir
que l'installation d'ERPLibre démarre en la quittant."""
def _sommaire(self, suite, attendre=3.0):
# Un travail qui DURE : sinon il finit avant le premier relevé, et le
# test ne prouve rien de l'avant/après.
jobs = [("1", "vm-a", ["bash", "-c", "sleep 2; echo ok"])]
vu = {}
async def scenario():
from textual.widgets import Static
app = run_deploy_progress(jobs, 1, run_app=False, suite=suite)
async with app.run_test(size=(140, 30)) as pilote:
await pilote.pause()
vu["pendant"] = str(app.query_one("#summary", Static).render())
await asyncio.sleep(attendre)
await pilote.pause()
vu["apres"] = str(app.query_one("#summary", Static).render())
asyncio.run(scenario())
return vu
def test_it_says_what_follows_once_everything_is_done(self):
vu = self._sommaire("Quitter (q) pour lancer l'installation")
self.assertNotIn("Quitter", vu["pendant"])
self.assertIn("Quitter", vu["apres"])
def test_nothing_is_promised_when_nothing_follows(self):
vu = self._sommaire("")
self.assertNotIn("→", vu["apres"])
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestLaCibleSsh(unittest.TestCase):
"""« s » utilisait le NOM de la VM. Sur Proxmox, l'entrée ~/.ssh/config
n'existe pas encore à ce moment — et ce nom peut désigner une machine
LOCALE homonyme, qui s'ouvrait alors à sa place."""
def _lance(self, ssh_cmds):
import contextlib
import os
jobs = [("1", "erplibre-ubuntu-2604", ["bash", "-c", "echo ok"])]
vu = []
async def scenario():
app = run_deploy_progress(
jobs, 1, run_app=False, ssh_cmds=ssh_cmds
)
async with app.run_test(size=(140, 30)) as pilote:
await pilote.pause()
await asyncio.sleep(1.2)
await pilote.pause()
vrai = os.system
os.system = vu.append
app.suspend = lambda: contextlib.nullcontext()
try:
app.action_ssh()
finally:
os.system = vrai
asyncio.run(scenario())
return vu[0] if vu else ""
def test_the_given_command_wins(self):
cible = "ssh -J erplibre-proxmox-9 erplibre@10.10.10.151"
self.assertIn(cible, self._lance({"erplibre-ubuntu-2604": cible}))
def test_without_one_it_falls_back_to_the_name(self):
self.assertIn("ssh erplibre-ubuntu-2604", self._lance(None))
if __name__ == "__main__":
unittest.main(verbosity=2)