[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
This commit is contained in:
Mathieu Benoit 2026-08-24 07:16:50 -04:00
parent 4c2adb7c56
commit f8bd1c30df
2 changed files with 201 additions and 6 deletions

View file

@ -1328,6 +1328,36 @@ PVE_ETATS = {"running": "running", "stopped": "shut off", "paused": "paused"}
_PVE_CACHE = {"at": 0.0, "stats": {}}
def pve_stats_cmd(adresses=()) -> str:
"""PVE_STATS_CMD, plus un test du port 8069 pour les adresses données.
Dans le MÊME appel : la colonne Odoo teste ce port depuis le poste, et une
VM sur pont interne n'y répond jamais — elle restait « — » quel que soit
l'état d'Odoo. Depuis l'hôte, elle répond. Un aller-retour ssh de plus par
tour aurait coûté une seconde ; celui-ci est déjà payé.
"""
if not adresses:
return PVE_STATS_CMD
liste = " ".join(shlex.quote(a) for a in adresses)
return (
PVE_STATS_CMD
+ "; echo '---ERPLIBRE-ODOO---'; for a in "
+ liste
+ '; do timeout 2 bash -c "echo > /dev/tcp/$a/8069" 2>/dev/null'
+ ' && echo "ODOO $a"; done'
)
def parse_odoo_probe(text: str) -> set:
"""Adresses dont le port 8069 a répondu, d'après pve_stats_cmd."""
_, _, bloc = (text or "").partition("---ERPLIBRE-ODOO---")
return {
ligne.split()[1]
for ligne in bloc.splitlines()
if ligne.startswith("ODOO ") and len(ligne.split()) == 2
}
def parse_pvestats(text: str) -> dict:
"""Sortie de PVE_STATS_CMD -> {nom: relevé}, même forme que domstats.
@ -1395,19 +1425,59 @@ def read_pvestats(vms, now=None) -> dict:
from script.proxmox import proxmox_deploy as pve
except ImportError: # pragma: no cover - le module est dans le dépôt
return {}
# {nom: adresse interne} — ce qui permet de tester Odoo depuis l'hôte.
adresses = {
vm["name"]: (vm.get("pve") or {}).get("addr")
for vm in vms or ()
if (vm.get("pve") or {}).get("addr")
}
stats = {}
for (target, sudo), info in hotes.items():
siennes = [
a
for nom, a in adresses.items()
if (
(
next((v for v in vms if v["name"] == nom), {}).get("pve")
or {}
).get("target")
== target
)
]
code, sortie = pve.run(
{"target": target, "sudo": sudo, "jump": info.get("jump", "")},
PVE_STATS_CMD,
30,
pve_stats_cmd(siennes),
40,
)
if code == 0:
stats.update(parse_pvestats(sortie))
releves = parse_pvestats(sortie)
ouverts = parse_odoo_probe(sortie)
for nom, rec in releves.items():
rec["odoo"] = adresses.get(nom) in ouverts
stats.update(releves)
_PVE_CACHE.update({"at": maintenant, "stats": stats})
return dict(stats)
def web_tunnel_argv(info, port=18069, cible_port=8069):
"""argv d'un tunnel local vers le port web d'une VM distante, ou None.
Une VM sur pont interne n'est pas routable d'ici : un navigateur ne peut
pas l'atteindre, et la touche « w » ouvrait une page morte. Le tunnel
passe par l'hôte, dure le temps de la visite, et se referme par son PID —
« pkill -f <motif> » tuait le shell qui l'avait lancé, le motif figurant
dans sa propre ligne de commande.
"""
info = info or {}
if not (info.get("addr") and info.get("target")):
return None
argv = ["ssh", "-N", "-o", "ExitOnForwardFailure=yes"]
if info.get("jump"):
argv += ["-J", info["jump"]]
argv += ["-L", f"{port}:{info['addr']}:{cible_port}", info["target"]]
return argv
def vm_ssh_prefix(vm) -> str:
"""« ssh … » pour entrer dans CETTE VM, adresse comprise.
@ -1590,6 +1660,19 @@ def restart_odoo_cmd() -> str:
)
def delete_vm_cmd_pve(info, purge: bool = True) -> str:
"""Efface une VM sur son hôte PROXMOX, par son VMID.
« virsh undefine <nom> » y aurait effacé le domaine LOCAL homonyme — le
même piège que partout ailleurs, avec la pire conséquence."""
vmid = int((info or {}).get("vmid") or 0)
suite = (
f"qm stop {vmid} --skiplock 1 || true; "
f"qm destroy {vmid}{' --purge 1 --destroy-unreferenced-disks 1' if purge else ''}"
)
return pve_host_cmd(info, suite)
def delete_vm_cmd(name: str, with_disks: bool) -> str:
"""Efface la VM sur l'HÔTE. Même séquence que « TODO._qemu_delete_vm » :
arrêt, retrait de la définition (nvram si UEFI, repli sinon), puis les
@ -2124,7 +2207,14 @@ def run_monitor(manifest_path: str, run_app: bool = True):
name not in self._odoo_up
and self._domstate.get(name) != "gone"
):
if _port_open(vm.get("ip"), 8069):
releve = (self._vmstats or {}).get(name) or {}
if vm.get("pve"):
# Le port a été testé DEPUIS L'HÔTE, dans l'appel des
# statistiques : d'ici, une adresse de pont interne ne
# répond jamais.
if releve.get("odoo"):
odoo[name] = True
elif _port_open(vm.get("ip"), 8069):
odoo[name] = True
return disks, status, self._collect_tele(), errors, odoo, wr, ram
@ -2487,10 +2577,26 @@ def run_monitor(manifest_path: str, run_app: bool = True):
browser = self._choose_browser()
if not browser:
return
url = f"http://{vm['ip']}:8069"
port = 18069
argv_tunnel = web_tunnel_argv(vm.get("pve"), port)
url = (
f"http://127.0.0.1:{port}"
if argv_tunnel
else f"http://{vm['ip']}:8069"
)
with self.suspend():
proc = None
if argv_tunnel:
print("→ " + " ".join(shlex.quote(a) for a in argv_tunnel))
try:
proc = subprocess.Popen(argv_tunnel)
time.sleep(2)
except (OSError, subprocess.SubprocessError) as exc:
print(f" ⚠ {exc}")
print(f"→ {browser} {url}")
rc = os.system(f"{browser} {shlex.quote(url)}")
if proc:
proc.terminate()
# Diagnostic : sinon le navigateur « clignote » et revient au
# TUI sans qu'on voie l'erreur (souvent Odoo pas démarré).
print(f"\n[{browser}] terminé (code {rc}).")
@ -2794,9 +2900,16 @@ def run_monitor(manifest_path: str, run_app: bool = True):
def confirmed(yes):
if not yes:
return
info = vm.get("pve")
cmd = (
delete_vm_cmd_pve(info)
if info
else delete_vm_cmd(vm["name"], True)
)
with self.suspend():
print(f"\n=== Suppression — {vm['name']} ===")
os.system(delete_vm_cmd(vm["name"], True) + " || true")
print(f"→ {cmd}\n")
os.system(cmd + " || true")
input("\nEntrée pour revenir au suivi… ")
self.push_screen(

View file

@ -260,6 +260,88 @@ class TestOuVaLaCommande(unittest.TestCase):
self.assertNotIn("sh -c", cmd)
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)
class TestLEtat(unittest.TestCase):
"""Une VM absente de « virsh list » passait pour EFFACÉE."""