From 2011bfecfe97bcd06cef09e020924e5e42896cb7 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 07:53:54 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20suivi=20:=20pas=20de=20poubelle=20avant?= =?UTF-8?q?=20d'en=20=C3=AAtre=20s=C3=BBr,=20et=20mise=20sur=20Proxmox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rapporté : une VM Arch à peine déployée sur Proxmox s'affichait 🗑 dès le premier tour. « Effacée » est un état TERMINAL — la ligne gèle et ne revient jamais — et il se déduisait d'UN relevé manquant. Or l'hôte peut être occupé, la VM en train de naître, le relevé en cache d'avant sa création. On distingue désormais « l'hôte n'a pas répondu » (on ne sait rien) de « l'hôte a répondu sans elle » (on compte, trois fois), et la case part de « - » plutôt que d'un sablier qui affirmerait qu'on attend quelque chose. L'écran Proxmox n'offrait pas le choix de l'interpréteur Python : il envoyait donc toujours « automatique », et comme mise n'est jamais installé d'office, c'était pyenv — qui COMPILE Python depuis le tar.xz. Le choix existe maintenant des deux côtés, avec le même garde-fou : rien n'est imposé quand aucune architecture retenue n'est servie par mise. --- EN --- Reported: an Arch VM barely deployed on Proxmox showed 🗑 on the very first pass. "Deleted" is a TERMINAL state — the row freezes and never comes back — and it was inferred from ONE missing reading. Yet the host may be busy, the VM may be starting, the reading may be cached from before it existed. We now tell "the host did not answer" (we know nothing) from "the host answered without it" (count, three times), and the cell starts at "-" rather than an hourglass claiming we await something. The Proxmox screen offered no Python interpreter choice: it therefore always sent "automatic", and since mise is never installed by default, that meant pyenv — which COMPILES Python from the tar.xz. The choice now exists on both sides, with the same guard: nothing is imposed when no selected architecture is served by mise. Assisted-by: Claude Opus 5 --- script/todo/proxmox_deploy_form.py | 67 ++++++++++++++++++++++ script/todo/proxmox_menu.py | 7 +++ script/todo/qemu_install_monitor.py | 86 ++++++++++++++++++++++++----- test/test_proxmox_form.py | 52 +++++++++++++++++ test/test_qemu_monitor_pve.py | 61 ++++++++++++++++++++ 5 files changed, 258 insertions(+), 15 deletions(-) diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index c2bbc5d..fbacb0f 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -99,6 +99,7 @@ def build_spec(vms, existants, form): "start": form["start"], "add_ssh_config": form["add_ssh_config"], "install": form["install"], + "python_provider": form.get("python_provider") or "", "monitor": form["monitor"], "parallelism": form["parallelism"], } @@ -322,6 +323,22 @@ def run_proxmox_form(ctx, run_app: bool = True): allow_blank=False, id="f_branch", ) + # Le même choix qu'en QEMU/KVM : mise pose un CPython + # PRÉCOMPILÉ, pyenv le COMPILE. Sans cette section, + # l'écran envoyait toujours « automatique » — et comme + # mise n'est jamais installé d'office, c'était pyenv, donc + # une compilation de Python. Rapporté sur une VM Arch. + yield Static( + t("Python interpreter:"), + id="t_python", + classes="grouptitle", + ) + with RadioSet(id="f_python"): + yield RadioButton( + t("mise (precompiled, faster)"), value=True + ) + yield RadioButton(t("pyenv (compiles from source)")) + yield Static("", id="miswarn") # Hors de la section « Installation » : le suivi regarde la # VM ARRIVER, même quand rien ne s'installe. Rangé dedans, # il se serait grisé avec elle. @@ -472,6 +489,7 @@ def run_proxmox_form(ctx, run_app: bool = True): ), ) self._render_plan() + self._render_mise() def _vmid_start(self): brut = self.query_one("#f_vmid", Input).value.strip() @@ -637,6 +655,7 @@ def run_proxmox_form(ctx, run_app: bool = True): Le suivi n'en fait pas partie — il regarde la VM arriver même quand rien ne s'installe.""" installe = self.query_one("#f_install", Checkbox).value + self._render_mise() for cible in ("#f_profile_install", "#f_branch"): try: self.query_one(cible).disabled = not installe @@ -748,6 +767,53 @@ def run_proxmox_form(ctx, run_app: bool = True): # ---------------------------------------------------------------- # # Les actions # ---------------------------------------------------------------- # + def _mise_usable(self): + """Au moins une VM retenue tourne sur une architecture servie par + mise. Sinon le choix ne veut rien dire.""" + servies = ctx.get("mise_arches") or () + return any(vm["arch"] in servies for vm in self.vms) + + def _python_provider(self): + """« mise », « pyenv », ou rien — c'est-à-dire « automatique ». + + Rien, et surtout pas « pyenv », quand mise n'est servi par aucune + architecture retenue : « mise indisponible » ne veut pas dire + « l'utilisateur exige pyenv », et un choix EXPLICITE écarte le + Python de la distribution.""" + if not self._mise_usable(): + return "" + try: + index = self.query_one("#f_python", RadioSet).pressed_index + except Exception: + return "" + return "pyenv" if index == 1 else "mise" + + def _render_mise(self): + """Grise le choix quand aucune VM retenue n'est servie par mise, ou + quand rien ne s'installe, et NOMME les architectures qui + retomberont sur pyenv.""" + servies = ctx.get("mise_arches") or () + try: + installe = self.query_one("#f_install", Checkbox).value + self.query_one("#f_python", RadioSet).disabled = not ( + self._mise_usable() and installe + ) + ecartees = sorted( + { + vm["arch"] + for vm in self.vms + if vm["arch"] not in servies + } + ) + self.query_one("#miswarn", Static).update( + f" ⚠ {t('mise has no binary for:')} " + f"{', '.join(ecartees)} — {t('those VMs use pyenv')}" + if ecartees + else "" + ) + except Exception: + pass + def _install(self): if not self.query_one("#f_install", Checkbox).value: return None @@ -777,6 +843,7 @@ def run_proxmox_form(ctx, run_app: bool = True): "install": self._install(), # Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans # ERPLibre se suit aussi (cloud-init, puis relevé système). + "python_provider": self._python_provider(), "monitor": self.query_one("#f_monitor", Checkbox).value, "parallelism": self.query_one("#f_par", Select).value, } diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 71f0a2f..9dd0c07 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -828,6 +828,12 @@ class ProxmoxMenuMixin: # La branche du dépôt : c'est elle qu'on déploie le plus souvent. "branch_current": self._qemu_repo_branch(), "install_profiles": self._qemu_install_profiles(), + # Les architectures pour lesquelles mise publie un CPython + # précompilé : sans ce choix, l'écran Proxmox envoyait toujours + # « automatique », donc pyenv, qui COMPILE Python (1 à 3 min sur + # une machine récente, bien plus sous émulation) — rapporté sur + # une VM Arch. + "mise_arches": self.QEMU_MISE_ARCHES, # Même règle qu'en QEMU/KVM : un système peut IMPOSER ce qu'on # installe dessus. Un Proxmox imbriqué recevait sinon ERPLibre et # Odoo 18, comme l'écran d'à côté avant correction. @@ -1254,6 +1260,7 @@ class ProxmoxMenuMixin: branche, {n: alias.get(n, n) for n in noms}, finale, + python_provider=spec.get("python_provider") or "", pve=cartes_pve, ) return resultat diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 4033a61..e32931a 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -1325,7 +1325,7 @@ 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": {}} +_PVE_CACHE = {"at": 0.0, "stats": {}, "ok": False} def pve_stats_cmd(adresses=()) -> str: @@ -1404,12 +1404,35 @@ def parse_pvestats(text: str) -> dict: return out +# Combien de relevés SUCCESSIFS sans la VM avant de la déclarer effacée. Un +# seul silence ne prouve rien : l'hôte peut être occupé, la VM en train de +# démarrer, le relevé en cache d'avant sa création. Or « effacée » est un état +# TERMINAL — la ligne gèle sur 🗑 et ne revient jamais. Vécu sur une VM Arch +# déployée sur Proxmox : poubelle dès le premier tour. +PVE_ABSENCES_AVANT_EFFACEE = 3 + + +def read_pvestats_detail(vms, now=None): + """(relevés, l'hôte a-t-il répondu ?). + + La nuance décide de tout : sans réponse, on ne sait RIEN — et ne rien + savoir n'est pas la même chose que savoir que la VM a disparu. + """ + stats, ok = _read_pvestats(vms, now) + return stats, ok + + def read_pvestats(vms, now=None) -> dict: - """{nom: relevé} des VM posées sur un hôte Proxmox, ou {}. + """{nom: relevé} des VM posées sur un hôte Proxmox, ou {}.""" + return _read_pvestats(vms, now)[0] + + +def _read_pvestats(vms, now=None): + """({nom: relevé}, succès). Un appel par hôte, mis en cache + PVE_STATS_INTERVAL secondes. 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. + (adresse de l'hôte, sudo, vmid). """ hotes = {} for vm in vms or (): @@ -1417,21 +1440,28 @@ def read_pvestats(vms, now=None) -> dict: if info.get("target"): hotes[(info["target"], info.get("sudo") or "")] = info if not hotes: - return {} + return {}, False maintenant = now if now is not None else time.time() - if maintenant - _PVE_CACHE["at"] < PVE_STATS_INTERVAL: - return dict(_PVE_CACHE["stats"]) + # « at > 0 » explicitement : sans lui, un tout PREMIER relevé pris moins de + # cinq secondes après l'époque tombait dans un cache vide et rendait + # « l'hôte n'a pas répondu » sans avoir rien demandé. Invisible en + # production, mais c'est la logique qui est fausse. + if ( + _PVE_CACHE["at"] > 0 + and maintenant - _PVE_CACHE["at"] < PVE_STATS_INTERVAL + ): + return dict(_PVE_CACHE["stats"]), bool(_PVE_CACHE.get("ok")) try: from script.proxmox import proxmox_deploy as pve except ImportError: # pragma: no cover - le module est dans le dépôt - return {} + return {}, False # {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 = {} + stats, ok = {}, False for (target, sudo), info in hotes.items(): siennes = [ a @@ -1450,13 +1480,14 @@ def read_pvestats(vms, now=None) -> dict: 40, ) if code == 0: + ok = True 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) + _PVE_CACHE.update({"at": maintenant, "stats": stats, "ok": ok}) + return dict(stats), ok def web_tunnel_argv(info, port=18069, cible_port=8069): @@ -1970,6 +2001,9 @@ def run_monitor(manifest_path: str, run_app: bool = True): # VM dont l'UI Odoo (:8069) répond déjà : une fois détectée « up », # on ne re-teste plus (Odoo ne redescend pas en cours d'install). self._odoo_up = set() + # Relevés SUCCESSIFS sans la VM, par nom : « effacée » est un état + # terminal, il se mérite. + self._pve_absences = {} # Debounce du changement de VM : la sélection défile vite au # clavier ; on ne recharge le log qu'une fois le curseur STABILISÉ. self._pending_sel = None @@ -2033,7 +2067,11 @@ def run_monitor(manifest_path: str, run_app: bool = True): vm["name"], vm.get("arch") or "?", "", - "⏳", + # « rien encore », comme ses voisines : « ⏳ » affirmerait + # qu'on attend quelque chose, alors qu'on ne sait rien du + # tout — le journal n'a pas encore parlé. Le sablier + # apparaît dès que l'attente est constatée. + "-", "—", "--:--", "-", @@ -2443,14 +2481,32 @@ def run_monitor(manifest_path: str, run_app: bool = True): # 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) + distants, hote_ok = await asyncio.to_thread( + read_pvestats_detail, vms + ) for vm in vms: nom = vm["name"] if vm.get("pve"): + if not hote_ok: + # L'hôte n'a pas répondu : on ne sait RIEN. Conclure + # « effacée » ici gelait la ligne sur 🗑 dès le premier + # tour, pour toujours — vécu sur une VM Arch à peine + # déployée. + continue releve = distants.get(nom) - self._domstate[nom] = PVE_ETATS.get( - (releve or {}).get("state"), "gone" + if releve: + self._pve_absences[nom] = 0 + self._domstate[nom] = PVE_ETATS.get( + releve.get("state"), "gone" + ) + continue + # L'hôte a répondu SANS elle : peut-être en cours de + # création, peut-être vraiment partie. On compte. + self._pve_absences[nom] = ( + self._pve_absences.get(nom, 0) + 1 ) + if self._pve_absences[nom] >= PVE_ABSENCES_AVANT_EFFACEE: + self._domstate[nom] = "gone" continue self._domstate[nom] = states.get(nom, "gone") # Réarmer la période du ballon sur les VM qui tournent : sans elle diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 2ac97a5..34f4f9b 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -467,6 +467,58 @@ class TestCreerUnPont(unittest.TestCase): self.assertIn("vmbr9", vu["choix_avant"]) +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLInterpretePython(unittest.TestCase): + """L'écran Proxmox n'offrait pas le choix, donc envoyait toujours + « automatique » — et comme mise n'est jamais installé d'office, c'était + pyenv, qui COMPILE Python. Rapporté sur une VM Arch : « il utilise le + tar.xz pour le compiler ».""" + + def _ecran(self, gestes=None, mise_arches=("amd64", "arm64")): + from script.todo.proxmox_deploy_form import run_proxmox_form + + ctx = contexte() + ctx["mise_arches"] = mise_arches + vu = {} + + async def scenario(): + from textual.widgets import SelectionList + + app = run_proxmox_form(ctx, run_app=False) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + liste.select(liste.get_option_at_index(0).value) + await pilote.pause() + await pilote.pause() + if gestes: + await gestes(app, pilote) + vu["choix"] = app._python_provider() + app.action_deploy() + vu["spec"] = app.result or {} + + asyncio.run(scenario()) + return vu + + def test_mise_is_offered_by_default(self): + # Un CPython précompilé plutôt qu'une compilation de trois minutes. + self.assertEqual(self._ecran()["choix"], "mise") + + def test_the_choice_reaches_the_spec(self): + async def gestes(app, pilote): + list(app.query("#f_python RadioButton"))[1].value = True + await pilote.pause() + + vu = self._ecran(gestes) + self.assertEqual(vu["choix"], "pyenv") + self.assertEqual(vu["spec"].get("python_provider"), "pyenv") + + def test_an_arch_mise_does_not_serve_yields_nothing(self): + # « mise indisponible » ne veut pas dire « l'utilisateur exige + # pyenv » : un choix explicite écarterait le Python de la distro. + self.assertEqual(self._ecran(mise_arches=("s390x",))["choix"], "") + + class TestLeSuivi(unittest.TestCase): """La case « Suivre l'installation » doit commander quelque chose. diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index 28a388f..cfb0202 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -131,6 +131,67 @@ class TestLAppel(unittest.TestCase): self.assertEqual(mon.read_pvestats(self._vms(1), now=1.0), {}) +class TestPasDePoubelleTropTot(unittest.TestCase): + """« Effacée » est un état TERMINAL : la ligne gèle sur 🗑 pour de bon. + + Rapporté sur une VM Arch déployée sur Proxmox : poubelle dès le premier + tour, alors que la VM venait de naître. Un relevé manquant ne prouve + rien — l'hôte peut être occupé, la VM en train de démarrer, le relevé en + cache d'avant sa création. + """ + + def setUp(self): + mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) + + def _vm(self): + return { + "name": "vm-a", + "pve": { + "target": "hote", + "sudo": "sudo ", + "vmid": 105, + "addr": "10.10.10.155", + }, + } + + def test_a_silent_host_is_not_a_deletion(self): + # (relevés, succès) : la nuance est tout le correctif. + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(255, "timeout") + ): + stats, ok = mon.read_pvestats_detail([self._vm()], now=1.0) + self.assertEqual(stats, {}) + self.assertFalse(ok) + + def test_a_host_that_answers_says_so(self): + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(0, "[]") + ): + stats, ok = mon.read_pvestats_detail([self._vm()], now=1.0) + self.assertEqual(stats, {}) + self.assertTrue(ok, "l'hôte a répondu : la VM est vraiment absente") + + def test_the_cache_keeps_the_verdict_too(self): + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(0, "[]") + ) as appel: + mon.read_pvestats_detail([self._vm()], now=100.0) + _stats, ok = mon.read_pvestats_detail([self._vm()], now=100.5) + self.assertEqual(appel.call_count, 1) + self.assertTrue(ok, "le cache rendait « échec » à chaque tour suivant") + + def test_it_takes_several_absences_to_conclude(self): + # Trois, pas une : le nombre est explicite, pas enfoui. + self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2) + + def test_the_short_reader_still_returns_only_stats(self): + # `read_pvestats` reste la forme courte pour la boucle des colonnes. + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(0, "[]") + ): + self.assertEqual(mon.read_pvestats([self._vm()], now=7.0), {}) + + class TestLaBonneMachine(unittest.TestCase): """Le pire défaut de la série : l'installation partie AILLEURS.