From 7f6c18037ddc8e5d47d416edb104a21f859cafd6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 11 Aug 2026 22:11:23 -0400 Subject: [PATCH] [ADD] tui qemu: choose mise or pyenv at deploy time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interpreter provider is now chosen when creating a VM, in both interfaces. "mise" installs it in the VM and lays down a precompiled CPython; "pyenv" keeps today's build-from-source. The question is only asked where it means something: mise publishes binaries for amd64 and arm64 only. An all-s390x fleet never sees it, a mixed fleet sees it along with the architectures that will fall back to pyenv -- said before deploying, not discovered in a log an hour later. mise goes into /usr/local/bin rather than ~/.local/bin: the remote command runs through "ssh host 'command'", where neither ~/.profile nor ~/.bashrc is read. Choosing "mise" exports "auto", not "mise", so a failed install can still fall back to pyenv. The unavailability notice was translated as "pyenv required", which said the opposite of what happens: pyenv is the fallback, not a demand. --- FR --- Le fournisseur d'interpréteur se choisit désormais à la création d'une VM, dans les deux interfaces. « mise » l'installe dans la VM et pose un CPython précompilé ; « pyenv » garde la compilation d'aujourd'hui. La question n'est posée que là où elle a un sens : mise ne publie de binaires que pour amd64 et arm64. Un parc tout s390x ne la voit jamais, un parc mixte la voit avec les architectures qui retomberont sur pyenv — dit avant de déployer, pas découvert dans un log une heure plus tard. mise va dans /usr/local/bin plutôt que ~/.local/bin : la commande distante passe par « ssh hôte 'commande' », où ni ~/.profile ni ~/.bashrc ne sont lus. Choisir « mise » exporte « auto », pas « mise », pour qu'une installation ratée puisse encore retomber sur pyenv. L'avis d'indisponibilité était traduit par « pyenv exigé », qui disait l'inverse de ce qui se passe : pyenv est le repli, pas une exigence. Assisted-by: Claude Opus 5 --- script/todo/qemu_deploy_form.py | 54 +++++++++++++++++++ script/todo/todo.py | 93 +++++++++++++++++++++++++++++++-- script/todo/todo_i18n.py | 28 ++++++++++ 3 files changed, 171 insertions(+), 4 deletions(-) diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 83658f3..edc53bc 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -178,6 +178,7 @@ def build_spec(vms, domains, form): "ssh_key": form["ssh_key"], "timezone": form.get("timezone", ""), "desktop": form.get("desktop", ""), + "python_provider": form.get("python_provider", ""), "install": form["install"], "add_ssh_config": form["add_ssh_config"], "parallelism": form["parallelism"], @@ -245,6 +246,8 @@ def run_deploy_form(ctx, run_app: bool = True): desktop_disk = ctx.get("desktop_disk_gb") or 0 # [(clé, libellé)] — la liste vient de todo.py, source unique. desktops = list(ctx.get("desktops") or []) + # Architectures pour lesquelles mise publie un binaire. + mise_arches = set(ctx.get("mise_arches") or ()) defaults = ctx.get("defaults") or {} result = {"spec": None} @@ -508,6 +511,18 @@ def run_deploy_form(ctx, run_app: bool = True): value=defaults.get("add_ssh_config", True), id="f_sshcfg", ) + # mise pose un CPython précompilé, pyenv le compile. + # Grisé quand AUCUNE des VM retenues n'est sur une + # architecture que mise sert. + yield Static( + t("Python interpreter:"), 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") yield Static(t("Parallelism"), classes="grouptitle") # Cochée, la case donne une exécution PAR installation : # le plafond du nombre de CPU ne s'applique plus. Décochée, @@ -600,6 +615,44 @@ def run_deploy_form(ctx, run_app: bool = True): grow += desktop_disk self.rows = plan_rows(self.vms, domains, grow) self._render_plan() + self._render_mise() + + def _render_mise(self): + """Grise le choix quand aucune VM retenue n'est servie par mise, + et nomme les architectures qui retomberont sur pyenv.""" + usable = self._mise_usable() + self.query_one("#f_python", RadioSet).disabled = not usable + skipped = sorted( + { + vm["arch"] + for vm in self.vms + if vm["arch"] not in mise_arches + } + ) + msg = "" + if skipped: + msg = ( + f" ⚠ {t('mise has no binary for:')} " + f"{', '.join(skipped)} — {t('those VMs use pyenv')}" + ) + self.query_one("#miswarn", Static).update(msg) + + 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 est indisponible » ne veut pas dire + « l'utilisateur exige pyenv » : la nuance décide de tout, puisqu'un + choix EXPLICITE écarte le Python de la distribution. Sur s390x, + renvoyer « pyenv » forçait la compilation de CPython — celle dont + gcc 15.2 ne revient pas.""" + if not self._mise_usable(): + return "" + index = self.query_one("#f_python", RadioSet).pressed_index + return "pyenv" if index == 1 else "mise" + + def _mise_usable(self): + return any(vm["arch"] in mise_arches for vm in self.vms) def _desktop(self): """« » pour un serveur, sinon la clé de la saveur choisie.""" @@ -783,6 +836,7 @@ def run_deploy_form(ctx, run_app: bool = True): or ctx.get("timezone") or "", "desktop": self._desktop(), + "python_provider": self._python_provider(), "install": install, "add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value, # Une exécution par installation : le nombre de VM retenues diff --git a/script/todo/todo.py b/script/todo/todo.py index 44c8d41..e1ecaeb 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -4512,8 +4512,51 @@ class TODO: '(vncpasswd puis vncserver :1)"; fi; ' ) + # mise ne publie de binaire que pour ces architectures : 46 assets à la + # v2026.8.4, aucun s390x — son propre script d'installation refuse cette + # plateforme. Ailleurs, le choix « mise » est sans objet et on reste sur + # pyenv, ce que le formulaire et l'invite disent avant de déployer. + QEMU_MISE_ARCHES = ("amd64", "arm64") + + def _qemu_mise_remote_cmd(self, python_provider): + """Pose mise DANS la VM et fixe EL_PYTHON_PROVIDER pour l'installation. + + mise s'installe par défaut dans ~/.local/bin, qui n'est PAS dans le + PATH d'un « ssh hôte 'commande' » : ni ~/.profile ni ~/.bashrc n'y sont + lus. On le pose donc dans /usr/local/bin, présent dans le PATH par + défaut — même raison que pour cargo et rustc. + + Sans mise utilisable, rien n'est écrit : lib_python_provider.sh + retombe alors sur pyenv toute seule.""" + if python_provider == "pyenv": + # Explicite : même si mise se trouvait déjà dans l'image, on ne + # l'utilise pas. Sans cela le mode « auto » du dépôt le prendrait. + return "export EL_PYTHON_PROVIDER=pyenv; " + if python_provider != "mise": + return "" + return ( + f'echo "== {t("Installing mise (precompiled Python)")} =="; ' + "if command -v mise >/dev/null 2>&1; then " + 'echo " mise: $(mise --version)"; ' + "else " + # La variable est passée À sudo, pas exportée avant : « sudo -E » + # dépend de env_reset dans sudoers et n'est pas garanti. + "curl -fsSL https://mise.run " + "| sudo MISE_INSTALL_PATH=/usr/local/bin/mise sh " + '|| echo " mise indisponible ici : pyenv prendra le relais"; ' + "fi; " + # « auto », et non « mise » : si l'installation ci-dessus a échoué, + # lib_python_provider.sh doit pouvoir retomber sur pyenv. + "export EL_PYTHON_PROVIDER=auto; " + ) + def _qemu_erplibre_remote_cmd( - self, branch, final_cmd=None, prod=False, desktop=False + self, + branch, + final_cmd=None, + prod=False, + desktop=False, + python_provider="", ): """Script exécuté DANS la VM. `branch` à None n'installe QUE le bureau — le choix graphique ne dépend pas d'ERPLibre, et une VM peut être @@ -4521,7 +4564,9 @@ class TODO: `final_cmd` par défaut : install_os + install_odoo_18. `prod` : installe dans /opt/erplibre (au lieu de ~/git/erplibre) + service - SELinux confiné. `desktop` : ajoute GNOME et son accès distant.""" + SELinux confiné. `desktop` : ajoute GNOME et son accès distant. + `python_provider` : « mise » pour un CPython précompilé, sinon le + comportement par défaut du dépôt (pyenv, qui compile).""" if not branch: # Bureau seul : ni clone ni make, mais on garde le prologue — # attente de cloud-init et coupure des mises à jour automatiques, @@ -4635,6 +4680,7 @@ class TODO: "for t in curl git make; do command -v $t >/dev/null 2>&1 || " '{ echo "Outil manquant apres installation: $t ' '(reseau de la VM ?)"; exit 1; }; done; ' + + self._qemu_mise_remote_cmd(python_provider) # Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur # pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev. + ( @@ -4665,6 +4711,7 @@ class TODO: final_cmd=None, prod=False, desktop=False, + python_provider="", ): """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre le dashboard Textual. Quitter le dashboard n'arrête pas les installs. @@ -4677,7 +4724,7 @@ class TODO: ) remote = self._qemu_erplibre_remote_cmd( - branch, final_cmd, prod, desktop + branch, final_cmd, prod, desktop, python_provider ) try: mod = self._qemu_import_module() @@ -4742,6 +4789,7 @@ class TODO: final_cmd=None, prod=False, desktop=False, + python_provider="", ): """Clone ERPLibre (branche donnée) dans la VM puis exécute la commande d'install du profil choisi (streamé). `ip` : IP déjà résolue ; @@ -4763,7 +4811,7 @@ class TODO: ) return remote = self._qemu_erplibre_remote_cmd( - branch, final_cmd, prod, desktop + branch, final_cmd, prod, desktop, python_provider ) ssh_opts = ( "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " @@ -5145,6 +5193,9 @@ class TODO: print( f" {t('VM type:')} {t('Graphical (server + desktop):')} {label}" ) + prov = spec.get("python_provider") + if prov: + print(f" {t('Python interpreter:')} {prov}") print(f" {t('SSH key:')} {spec.get('ssh_key') or t('none')}") cfg = ( t("one entry per VM") if spec["add_ssh_config"] else t("untouched") @@ -5472,6 +5523,7 @@ class TODO: "disk_presets": self._QEMU_DISK_PRESETS, "extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB, "desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB, + "mise_arches": self.QEMU_MISE_ARCHES, "desktops": [ (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() ], @@ -5762,6 +5814,32 @@ class TODO: return "" return flavours[index] if 0 <= index < len(flavours) else "" + def _qemu_ask_python_provider(self, arches): + """mise (CPython précompilé) ou pyenv (compilation). + + `arches` : les architectures du parc à déployer. mise ne publie pas de + binaire pour toutes — hors de QEMU_MISE_ARCHES la question n'a pas de + sens et on ne la pose pas.""" + usable = [a for a in arches if a in self.QEMU_MISE_ARCHES] + if not usable: + # Rien, pas « pyenv » : le mode automatique doit rester libre de + # préférer un Python de la distribution. Voir _python_provider() + # dans le formulaire, même raisonnement. + return "" + print(f"\n{t('Python interpreter:')}") + print(f" [1] {t('mise (precompiled, faster)')} *") + print(f" [2] {t('pyenv (compiles from source)')}") + skipped = [a for a in arches if a not in self.QEMU_MISE_ARCHES] + if skipped: + # Dit AVANT le déploiement plutôt que découvert dans un log. + print( + f" ⚠ {t('mise has no binary for:')} " + f"{', '.join(sorted(set(skipped)))} — " + f"{t('those VMs use pyenv')}" + ) + sel = input(t("Choice (number, blank = mise): ")).strip() + return "pyenv" if sel == "2" else "mise" + def _qemu_host_timezone(self): """Fuseau de l'hôte. Défini une seule fois, dans deploy_qemu.py, qui est aussi ce qui l'écrit dans le cloud-config : l'invite ne peut donc @@ -5821,6 +5899,9 @@ class TODO: timezone = self._qemu_ask_timezone() locale = self._qemu_ask_locale() desktop = self._qemu_ask_desktop() + python_provider = self._qemu_ask_python_provider( + [vm["arch"] for vm in vms] + ) # 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM. install = None @@ -5898,6 +5979,7 @@ class TODO: "timezone": timezone, "locale": locale, "desktop": desktop, + "python_provider": python_provider, "install": install, "add_ssh_config": add_ssh_config, "parallelism": parallelism, @@ -5966,6 +6048,7 @@ class TODO: install = spec.get("install") install_branch = install["branch"] if install else None desktop = spec.get("desktop") or "" + python_provider = spec.get("python_provider") or "" ssh_key = spec.get("ssh_key") add_ssh_config = spec["add_ssh_config"] parallelism = spec["parallelism"] @@ -6040,6 +6123,7 @@ class TODO: install["cmd"] if install else None, install["prod"] if install else False, desktop=desktop, + python_provider=python_provider, ) else: print( @@ -6055,6 +6139,7 @@ class TODO: install["cmd"], install["prod"], desktop=desktop, + python_provider=python_provider, ) # Sommaire TOTAL (déploiement + résolution IP + ssh_config + install diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8ad421b..43019ce 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2023,6 +2023,34 @@ TRANSLATIONS = { "fr": "Choix (numéro, vide = rouvrir) : ", "en": "Choice (number, blank = reopen): ", }, + "Python interpreter:": { + "fr": "Interpreteur Python :", + "en": "Python interpreter:", + }, + "mise (precompiled, faster)": { + "fr": "mise (precompile, plus rapide)", + "en": "mise (precompiled, faster)", + }, + "pyenv (compiles from source)": { + "fr": "pyenv (compile depuis les sources)", + "en": "pyenv (compiles from source)", + }, + "Choice (number, blank = mise): ": { + "fr": "Choix (numero, vide = mise) : ", + "en": "Choice (number, blank = mise): ", + }, + "mise has no binary for:": { + "fr": "mise ne publie pas de binaire pour :", + "en": "mise has no binary for:", + }, + "those VMs use pyenv": { + "fr": "ces VM utiliseront pyenv", + "en": "those VMs use pyenv", + }, + "Installing mise (precompiled Python)": { + "fr": "Installation de mise (Python precompile)", + "en": "Installing mise (precompiled Python)", + }, "VM type:": { "fr": "Type de VM :", "en": "VM type:",