[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:
parent
4c2adb7c56
commit
f8bd1c30df
2 changed files with 201 additions and 6 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue