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.