[ADD] tui qemu: choose mise or pyenv at deploy time

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
This commit is contained in:
Mathieu Benoit 2026-08-11 22:11:23 -04:00
parent 70d9ad456c
commit 7f6c18037d
3 changed files with 171 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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:",