[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"], "ssh_key": form["ssh_key"],
"timezone": form.get("timezone", ""), "timezone": form.get("timezone", ""),
"desktop": form.get("desktop", ""), "desktop": form.get("desktop", ""),
"python_provider": form.get("python_provider", ""),
"install": form["install"], "install": form["install"],
"add_ssh_config": form["add_ssh_config"], "add_ssh_config": form["add_ssh_config"],
"parallelism": form["parallelism"], "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 desktop_disk = ctx.get("desktop_disk_gb") or 0
# [(clé, libellé)] — la liste vient de todo.py, source unique. # [(clé, libellé)] — la liste vient de todo.py, source unique.
desktops = list(ctx.get("desktops") or []) 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 {} defaults = ctx.get("defaults") or {}
result = {"spec": None} result = {"spec": None}
@ -508,6 +511,18 @@ def run_deploy_form(ctx, run_app: bool = True):
value=defaults.get("add_ssh_config", True), value=defaults.get("add_ssh_config", True),
id="f_sshcfg", 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") yield Static(t("Parallelism"), classes="grouptitle")
# Cochée, la case donne une exécution PAR installation : # Cochée, la case donne une exécution PAR installation :
# le plafond du nombre de CPU ne s'applique plus. Décochée, # 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 grow += desktop_disk
self.rows = plan_rows(self.vms, domains, grow) self.rows = plan_rows(self.vms, domains, grow)
self._render_plan() 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): def _desktop(self):
"""« » pour un serveur, sinon la clé de la saveur choisie.""" """« » 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 ctx.get("timezone")
or "", or "",
"desktop": self._desktop(), "desktop": self._desktop(),
"python_provider": self._python_provider(),
"install": install, "install": install,
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value, "add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
# Une exécution par installation : le nombre de VM retenues # Une exécution par installation : le nombre de VM retenues

View file

@ -4512,8 +4512,51 @@ class TODO:
'(vncpasswd puis vncserver :1)"; fi; ' '(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( 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 """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 — 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` : `final_cmd` par défaut : install_os + install_odoo_18. `prod` :
installe dans /opt/erplibre (au lieu de ~/git/erplibre) + service 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: if not branch:
# Bureau seul : ni clone ni make, mais on garde le prologue — # Bureau seul : ni clone ni make, mais on garde le prologue —
# attente de cloud-init et coupure des mises à jour automatiques, # 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 || " "for t in curl git make; do command -v $t >/dev/null 2>&1 || "
'{ echo "Outil manquant apres installation: $t ' '{ echo "Outil manquant apres installation: $t '
'(reseau de la VM ?)"; exit 1; }; done; ' '(reseau de la VM ?)"; exit 1; }; done; '
+ self._qemu_mise_remote_cmd(python_provider)
# Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur # Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur
# pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev. # pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev.
+ ( + (
@ -4665,6 +4711,7 @@ class TODO:
final_cmd=None, final_cmd=None,
prod=False, prod=False,
desktop=False, desktop=False,
python_provider="",
): ):
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre """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. le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
@ -4677,7 +4724,7 @@ class TODO:
) )
remote = self._qemu_erplibre_remote_cmd( remote = self._qemu_erplibre_remote_cmd(
branch, final_cmd, prod, desktop branch, final_cmd, prod, desktop, python_provider
) )
try: try:
mod = self._qemu_import_module() mod = self._qemu_import_module()
@ -4742,6 +4789,7 @@ class TODO:
final_cmd=None, final_cmd=None,
prod=False, prod=False,
desktop=False, desktop=False,
python_provider="",
): ):
"""Clone ERPLibre (branche donnée) dans la VM puis exécute la commande """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 ; d'install du profil choisi (streamé). `ip` : IP déjà résolue ;
@ -4763,7 +4811,7 @@ class TODO:
) )
return return
remote = self._qemu_erplibre_remote_cmd( remote = self._qemu_erplibre_remote_cmd(
branch, final_cmd, prod, desktop branch, final_cmd, prod, desktop, python_provider
) )
ssh_opts = ( ssh_opts = (
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
@ -5145,6 +5193,9 @@ class TODO:
print( print(
f" {t('VM type:')} {t('Graphical (server + desktop):')} {label}" 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')}") print(f" {t('SSH key:')} {spec.get('ssh_key') or t('none')}")
cfg = ( cfg = (
t("one entry per VM") if spec["add_ssh_config"] else t("untouched") 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, "disk_presets": self._QEMU_DISK_PRESETS,
"extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB, "extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB,
"desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB, "desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB,
"mise_arches": self.QEMU_MISE_ARCHES,
"desktops": [ "desktops": [
(k, v["label"]) for k, v in self._QEMU_DESKTOP.items() (k, v["label"]) for k, v in self._QEMU_DESKTOP.items()
], ],
@ -5762,6 +5814,32 @@ class TODO:
return "" return ""
return flavours[index] if 0 <= index < len(flavours) else "" 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): def _qemu_host_timezone(self):
"""Fuseau de l'hôte. Défini une seule fois, dans deploy_qemu.py, qui """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 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() timezone = self._qemu_ask_timezone()
locale = self._qemu_ask_locale() locale = self._qemu_ask_locale()
desktop = self._qemu_ask_desktop() 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. # 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
install = None install = None
@ -5898,6 +5979,7 @@ class TODO:
"timezone": timezone, "timezone": timezone,
"locale": locale, "locale": locale,
"desktop": desktop, "desktop": desktop,
"python_provider": python_provider,
"install": install, "install": install,
"add_ssh_config": add_ssh_config, "add_ssh_config": add_ssh_config,
"parallelism": parallelism, "parallelism": parallelism,
@ -5966,6 +6048,7 @@ class TODO:
install = spec.get("install") install = spec.get("install")
install_branch = install["branch"] if install else None install_branch = install["branch"] if install else None
desktop = spec.get("desktop") or "" desktop = spec.get("desktop") or ""
python_provider = spec.get("python_provider") or ""
ssh_key = spec.get("ssh_key") ssh_key = spec.get("ssh_key")
add_ssh_config = spec["add_ssh_config"] add_ssh_config = spec["add_ssh_config"]
parallelism = spec["parallelism"] parallelism = spec["parallelism"]
@ -6040,6 +6123,7 @@ class TODO:
install["cmd"] if install else None, install["cmd"] if install else None,
install["prod"] if install else False, install["prod"] if install else False,
desktop=desktop, desktop=desktop,
python_provider=python_provider,
) )
else: else:
print( print(
@ -6055,6 +6139,7 @@ class TODO:
install["cmd"], install["cmd"],
install["prod"], install["prod"],
desktop=desktop, desktop=desktop,
python_provider=python_provider,
) )
# Sommaire TOTAL (déploiement + résolution IP + ssh_config + install # Sommaire TOTAL (déploiement + résolution IP + ssh_config + install

View file

@ -2023,6 +2023,34 @@ TRANSLATIONS = {
"fr": "Choix (numéro, vide = rouvrir) : ", "fr": "Choix (numéro, vide = rouvrir) : ",
"en": "Choice (number, blank = reopen): ", "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:": { "VM type:": {
"fr": "Type de VM :", "fr": "Type de VM :",
"en": "VM type:", "en": "VM type:",