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
901 lines
37 KiB
Python
901 lines
37 KiB
Python
#!/usr/bin/env python3
|
||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
"""Formulaire de déploiement sur un hôte Proxmox VE.
|
||
|
||
Même écran que pour QEMU/KVM — catalogue à gauche, plan à droite, totaux
|
||
dessous — parce que c'est le même travail : choisir des systèmes, régler des
|
||
ressources, vérifier avant de lancer. Tout ce qui est commun vient de
|
||
`deploy_form_lib` (logique pure, socle CSS, fabrique des ressources) et de
|
||
`deploy_form_plan` (surcharges, verrous, exemplaires, renommage). Ne reste
|
||
ici que ce que Proxmox a en propre :
|
||
|
||
* l'hôte, choisi AVANT d'ouvrir l'écran — il faut ssh et sudo, et une invite
|
||
de mot de passe pendant que Textual affiche casserait le terminal ;
|
||
* le stockage et le pont, LUS SUR L'HÔTE : « local-lvm » n'existe pas partout
|
||
et un pont inventé fait échouer « qm create » ;
|
||
* le VMID, et l'adresse qui s'en déduit sur un pont interne.
|
||
|
||
Le formulaire ne touche à rien : il rend une spec. C'est l'appelant
|
||
(`ProxmoxMenuMixin._pve_deploy`) qui exécute.
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
|
||
from script.todo.deploy_form_lib import (
|
||
CSS_BASE,
|
||
FREE,
|
||
RES_FIELDS,
|
||
SELECT_TO_FIELD,
|
||
branch_default,
|
||
branch_order,
|
||
build_vms,
|
||
disk_note,
|
||
entry_key,
|
||
gib,
|
||
plan_rows,
|
||
plan_totals,
|
||
res_row_widgets,
|
||
t,
|
||
)
|
||
from script.todo.deploy_form_plan import PlanMixin, preview_screen
|
||
|
||
# Aucun disque orphelin à craindre : les disques d'un Proxmox distant vivent
|
||
# dans un stockage que seul l'hôte connaît, jamais dans /var/lib/libvirt.
|
||
PAS_D_ORPHELIN = None
|
||
|
||
# Dernier choix du sélecteur de pont : il ne désigne pas un pont, il en crée
|
||
# un. Rapporté — l'écran refusait de déployer « aucun pont sur l'hôte » sans
|
||
# offrir le moindre moyen d'en avoir un.
|
||
CREER_PONT = "__creer_pont__"
|
||
|
||
|
||
def assign_vmids(rows, used, start, ipconfig):
|
||
"""Pose un VMID libre et son adresse sur chaque VM À CRÉER.
|
||
|
||
Proxmox refuse un VMID déjà pris, et il le dit APRÈS le téléchargement de
|
||
l'image : on choisit donc avant, d'après ce que l'hôte déclare. Les VM qui
|
||
existent déjà sont sautées — elles ont le leur.
|
||
|
||
`ipconfig(vmid)` rend la ligne cloud-init : « ip=dhcp » sur un pont qui
|
||
donne sur le LAN, une adresse fixe dérivée du VMID sur un pont interne.
|
||
"""
|
||
pris = {int(v) for v in used or () if str(v).isdigit()}
|
||
suivant = max(int(start or 0), 100)
|
||
for r in rows:
|
||
if r["state"] == "exists":
|
||
continue
|
||
while suivant in pris:
|
||
suivant += 1
|
||
pris.add(suivant)
|
||
r["vm"]["vmid"] = suivant
|
||
r["vm"]["ipconfig"] = ipconfig(suivant) if ipconfig else "ip=dhcp"
|
||
suivant += 1
|
||
return rows
|
||
|
||
|
||
def res_label(profile) -> str:
|
||
"""Comment le plan nomme le réglage commun choisi."""
|
||
return t("custom") if profile == "custom" else f"x{profile}"
|
||
|
||
|
||
def build_spec(vms, existants, form):
|
||
"""La spec que le déploiement exécutera. Une VM qui existe déjà n'y entre
|
||
pas : Proxmox refuserait le VMID, et on ne veut surtout pas l'écraser."""
|
||
connus = set(existants)
|
||
return {
|
||
"host": form["host"],
|
||
"storage": form["storage"],
|
||
"bridge": form["bridge"],
|
||
# Les résolveurs de l'hôte suivent la spec : une VM en adresse fixe
|
||
# n'a pas de DNS sans eux.
|
||
"nameservers": form.get("nameservers") or (),
|
||
"res_label": form["res_label"],
|
||
"vms": [vm for vm in vms if vm["name"] not in connus],
|
||
"existing": [vm["name"] for vm in vms if vm["name"] in connus],
|
||
"ssh_key": form["ssh_key"],
|
||
"user": form.get("user") or "erplibre",
|
||
"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"],
|
||
}
|
||
|
||
|
||
def run_proxmox_form(ctx, run_app: bool = True):
|
||
"""Formulaire Proxmox. Renvoie une spec, None si annulé, {} pour retomber
|
||
sur les invites textuelles. `run_app=False` rend l'instance sans la lancer
|
||
(tests headless)."""
|
||
from textual.app import App, ComposeResult
|
||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||
from textual.widgets import (
|
||
Button,
|
||
Checkbox,
|
||
Footer,
|
||
Header,
|
||
Input,
|
||
RadioButton,
|
||
RadioSet,
|
||
Select,
|
||
SelectionList,
|
||
Static,
|
||
)
|
||
|
||
SELECT_NULL = getattr(Select, "NULL", Select.BLANK)
|
||
arches = ctx["arches"]
|
||
catalog = ctx["catalog"]
|
||
noms_pris = ctx["names"]
|
||
vmids_pris = ctx["vmids"]
|
||
# Ordre et défaut partagés avec le formulaire QEMU/KVM : la liste vient
|
||
# de « git ls-remote », alphabétique, et commençait donc par une branche
|
||
# de dependabot — proposée par défaut. Rapporté.
|
||
branches = branch_order(
|
||
ctx.get("branches") or ["master"], ctx.get("branch_current")
|
||
)
|
||
profiles = ctx.get("install_profiles") or []
|
||
stockages = ctx.get("storages") or []
|
||
ponts = ctx.get("bridges") or []
|
||
# {système: (libellé, commande)} — ce qu'un système impose d'installer.
|
||
distro_profiles = ctx.get("distro_profiles") or {}
|
||
# Les commandes qui ne posent PAS ERPLibre : sa marge disque ne les suit
|
||
# pas. DÉDUITES des profils imposés — une seconde clé de contexte à tenir
|
||
# en accord avec la première aurait fini par en différer, et la marge
|
||
# serait revenue sans qu'on le voie. Jugé sur la commande effective de la
|
||
# rangée : un choix explicite compte donc autant que la règle du système.
|
||
no_erplibre = {
|
||
impose[1].strip() for impose in distro_profiles.values() if impose
|
||
}
|
||
|
||
def entry_label(e):
|
||
return f"{e['distro']} {e['version']} [{e['arch']}] {e['name']}"
|
||
|
||
class ProxmoxForm(PlanMixin, App):
|
||
TITLE = t("Deploy one or more ERPLibre VMs on Proxmox VE!")
|
||
BINDINGS = [
|
||
("f5", "deploy", t("Deploy")),
|
||
("f4", "clear_vm", t("Reset VM")),
|
||
("f3", "preview", t("Preview")),
|
||
("f6", "select_all", t("All")),
|
||
("f8", "select_none", t("None")),
|
||
("escape", "cancel", t("Cancel")),
|
||
]
|
||
# Le socle porte la mise en page et les modales ; ne reste ici que ce
|
||
# qui nomme les widgets propres à Proxmox.
|
||
CSS = (
|
||
CSS_BASE
|
||
+ """
|
||
SelectionList { height: 10; border: solid $panel; }
|
||
RadioSet { height: auto; layout: horizontal; }
|
||
.vmrow Select.vmbranch { width: 34; }
|
||
#hostline { height: 1; color: $accent; padding: 0 1; }
|
||
"""
|
||
)
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.arch = ctx.get("native") or arches[0]
|
||
self.profile = "1"
|
||
self.custom = {}
|
||
self.overrides = {}
|
||
self.locked = set()
|
||
self.copies = {}
|
||
self.rows = []
|
||
self.vms = []
|
||
self.result = None
|
||
# Génération des widgets de rangée : un message qui arrive d'un
|
||
# jeu périmé ne doit pas être pris pour une saisie.
|
||
self._gen = 0
|
||
self._shown_ids = ()
|
||
self._syncing = False
|
||
# La liste des ponts GRANDIT : l'écran sait en créer un.
|
||
self._ponts = list(ponts)
|
||
|
||
# ---------------------------------------------------------------- #
|
||
# L'écran
|
||
# ---------------------------------------------------------------- #
|
||
def compose(self) -> ComposeResult:
|
||
yield Header()
|
||
hote = ctx["host"].get("label") or ctx["host"]["target"]
|
||
yield Static(
|
||
f" {t('Proxmox host')} : {hote}"
|
||
f" {t('node')} : {ctx.get('node') or '?'}",
|
||
id="hostline",
|
||
)
|
||
with Horizontal(id="body"):
|
||
with VerticalScroll(id="fields"):
|
||
yield Static(t("Architecture"), classes="grouptitle")
|
||
with RadioSet(id="f_arch"):
|
||
for a in arches:
|
||
label = a if a != "all" else t("all archs")
|
||
yield RadioButton(label, value=a == self.arch)
|
||
yield Static(t("Catalog"), classes="grouptitle")
|
||
yield SelectionList(id="f_catalog")
|
||
yield Static(
|
||
t("Resources — applied to ALL VMs"),
|
||
classes="grouptitle",
|
||
)
|
||
with RadioSet(id="f_profile"):
|
||
for label in ("x1", "x2", "x3", "x4"):
|
||
yield RadioButton(label, value=label == "x1")
|
||
yield RadioButton(t("custom"))
|
||
# Les mêmes trois ressources qu'ailleurs, montées par la
|
||
# même fabrique : « libre… » révèle la saisie du dessous.
|
||
for champ, presets, etiquette in (
|
||
("vcpus", ctx["cpu_presets"], t("vCPU")),
|
||
("ram", ctx["ram_presets"], t("RAM: 2048 or 8G")),
|
||
("disk", ctx["disk_presets"], t("Disk")),
|
||
):
|
||
yield Select(
|
||
[
|
||
(
|
||
(
|
||
f"{v // 1024}G"
|
||
if champ == "ram"
|
||
else str(v)
|
||
),
|
||
v,
|
||
)
|
||
for v in presets
|
||
]
|
||
+ [(t("free value…"), FREE)],
|
||
prompt=etiquette,
|
||
id=RES_FIELDS[champ][0][1:],
|
||
disabled=True,
|
||
)
|
||
yield Input(
|
||
placeholder=etiquette,
|
||
id=RES_FIELDS[champ][1][1:],
|
||
classes="freeval",
|
||
disabled=True,
|
||
)
|
||
yield Static(t("Proxmox VE"), classes="grouptitle")
|
||
yield Select(
|
||
[(s, s) for s in stockages],
|
||
value=(
|
||
ctx.get("storage")
|
||
or (stockages[0] if stockages else SELECT_NULL)
|
||
),
|
||
prompt=t("Storage"),
|
||
allow_blank=not stockages,
|
||
id="f_storage",
|
||
)
|
||
yield Select(
|
||
self._choix_ponts(),
|
||
value=(
|
||
ctx.get("bridge")
|
||
or (ponts[0] if ponts else SELECT_NULL)
|
||
),
|
||
prompt=t("Bridge"),
|
||
allow_blank=not ponts,
|
||
id="f_bridge",
|
||
)
|
||
yield Static(f" {t('First VMID')}")
|
||
yield Input(
|
||
value=str(ctx.get("next_vmid") or 100),
|
||
placeholder="100",
|
||
id="f_vmid",
|
||
)
|
||
yield Static(t("Access"), classes="grouptitle")
|
||
yield Static(f" {t('SSH public key')}")
|
||
yield Input(
|
||
value=ctx.get("ssh_key") or "",
|
||
placeholder="~/.ssh/id_ed25519.pub",
|
||
id="f_key",
|
||
)
|
||
yield Checkbox(
|
||
t("Start the VM after creating it"),
|
||
value=True,
|
||
id="f_start",
|
||
)
|
||
yield Checkbox(
|
||
t("Add an entry to ~/.ssh/config"),
|
||
value=True,
|
||
id="f_sshcfg",
|
||
)
|
||
# La case commande TOUTE installation — ERPLibre, Odoo,
|
||
# mais aussi l'hyperviseur Proxmox VE d'une VM imbriquée.
|
||
# Nommée « ERPLibre », elle laissait croire qu'un système
|
||
# Proxmox s'installerait quand même.
|
||
yield Static(
|
||
t("Installation"),
|
||
id="t_install",
|
||
classes="grouptitle",
|
||
)
|
||
yield Checkbox(
|
||
t("Install software in the VM"),
|
||
value=True,
|
||
id="f_install",
|
||
)
|
||
yield Select(
|
||
[(lbl, i) for i, (lbl, _c) in enumerate(profiles)],
|
||
value=0 if profiles else SELECT_NULL,
|
||
allow_blank=not profiles,
|
||
id="f_profile_install",
|
||
)
|
||
yield Select(
|
||
[(b, b) for b in branches],
|
||
value=branch_default(
|
||
branches, ctx.get("branch_current")
|
||
),
|
||
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.
|
||
yield Static(
|
||
t("Monitoring and parallelism"),
|
||
classes="grouptitle",
|
||
)
|
||
yield Checkbox(
|
||
t("Follow the installation (dashboard)"),
|
||
value=True,
|
||
id="f_monitor",
|
||
)
|
||
yield Static(f" {t('Parallelism')}")
|
||
yield Select(
|
||
[(str(n), n) for n in (1, 2, 3, 4)],
|
||
value=1,
|
||
allow_blank=False,
|
||
id="f_par",
|
||
)
|
||
with Vertical(id="right"):
|
||
yield VerticalScroll(id="plan")
|
||
yield Static("", id="totals")
|
||
with Horizontal(id="actions"):
|
||
yield Button(t("Deploy"), variant="primary", id="go")
|
||
yield Button(t("Text prompts"), id="prompts")
|
||
yield Button(t("Cancel"), id="no")
|
||
yield Footer()
|
||
|
||
def _choix_ponts(self):
|
||
"""Les ponts de l'hôte, plus « en créer un ».
|
||
|
||
L'entrée de création reste offerte même quand des ponts existent :
|
||
sur un hôte qui n'en a qu'un, sur le LAN, on peut vouloir un
|
||
réseau interne pour un parc d'essai."""
|
||
choix = [(b, b) for b in self._ponts]
|
||
if ctx.get("make_bridge"):
|
||
nom, cidr = ctx.get("internal_bridge") or ("vmbr0", "")
|
||
choix.append(
|
||
(
|
||
f"➕ {t('create an internal')} {nom} ({cidr}) + NAT",
|
||
CREER_PONT,
|
||
)
|
||
)
|
||
return choix
|
||
|
||
def _creer_pont(self) -> None:
|
||
"""Crée le pont sur l'hôte, dans un FIL : l'appel dure des
|
||
secondes, et l'écran doit rester vivant pendant ce temps."""
|
||
self.notify(t("Creating the bridge on the host…"))
|
||
|
||
def travail():
|
||
nom, raison = ctx["make_bridge"]()
|
||
self.call_from_thread(self._pont_cree, nom, raison)
|
||
|
||
self.run_worker(travail, thread=True)
|
||
|
||
def _pont_cree(self, nom, raison) -> None:
|
||
"""Retour du fil. Le sélecteur est remis d'aplomb dans les DEUX
|
||
cas : laissé sur « créer », il ne désignerait aucun pont."""
|
||
selecteur = self.query_one("#f_bridge", Select)
|
||
if not nom:
|
||
self.notify(
|
||
f"{t('The bridge did not come up.')} {raison}",
|
||
severity="error",
|
||
timeout=12,
|
||
)
|
||
selecteur.value = (
|
||
self._ponts[0] if self._ponts else SELECT_NULL
|
||
)
|
||
return
|
||
if nom not in self._ponts:
|
||
self._ponts.append(nom)
|
||
self._syncing = True
|
||
selecteur.set_options(self._choix_ponts())
|
||
selecteur.value = nom
|
||
self._syncing = False
|
||
self.notify(f"✓ {nom}")
|
||
self._refresh_after()
|
||
|
||
def on_mount(self) -> None:
|
||
self._reload_catalog()
|
||
self._sync_install_deps()
|
||
|
||
# ---------------------------------------------------------------- #
|
||
# Le plan
|
||
# ---------------------------------------------------------------- #
|
||
def _entries(self):
|
||
return catalog.get(self.arch) or []
|
||
|
||
def _selected_entries(self):
|
||
choisis = set(self.query_one("#f_catalog", SelectionList).selected)
|
||
return [e for e in self._entries() if entry_key(e) in choisis]
|
||
|
||
def _presets(self):
|
||
return {
|
||
"vcpus": ctx["cpu_presets"],
|
||
"ram": ctx["ram_presets"],
|
||
"disk": ctx["disk_presets"],
|
||
}
|
||
|
||
def _reload_catalog(self) -> None:
|
||
liste = self.query_one("#f_catalog", SelectionList)
|
||
garde = set(liste.selected)
|
||
liste.clear_options()
|
||
for e in self._entries():
|
||
cle = entry_key(e)
|
||
liste.add_option((entry_label(e), cle, cle in garde))
|
||
self._recompute()
|
||
self._mount_rows()
|
||
|
||
def _recompute(self) -> None:
|
||
entries = self._plan_entries()
|
||
self.vms = build_vms(
|
||
entries,
|
||
self.profile,
|
||
ctx["base_vcpus"],
|
||
ctx["host_cpu"],
|
||
self.custom,
|
||
self.overrides,
|
||
)
|
||
# Ce qu'un système IMPOSE d'installer, posé sur le MODÈLE : le
|
||
# déploiement lit « install_cmd » VM par VM.
|
||
for vm in self.vms:
|
||
impose = distro_profiles.get(vm["distro"])
|
||
if impose and not vm.get("install_cmd"):
|
||
vm["install_label"], vm["install_cmd"] = impose
|
||
self.rows = plan_rows(
|
||
self.vms, noms_pris, orphelin=lambda _n: False
|
||
)
|
||
# Le supplément d'ERPLibre ne vaut que pour les VM qui l'auront
|
||
# vraiment : une VM Proxmox ne clonera pas le dépôt.
|
||
if self.query_one("#f_install", Checkbox).value:
|
||
commun = (self._install() or {}).get("cmd") or ""
|
||
for row in self.rows:
|
||
cmd_vm = row["vm"].get("install_cmd") or commun
|
||
if cmd_vm.strip() not in no_erplibre:
|
||
row["disk_gb"] += ctx.get("extra_disk_gb", 0)
|
||
for row, entry in zip(self.rows, entries):
|
||
cle = entry_key(entry)
|
||
row["custom"] = bool(self.overrides.get(cle))
|
||
row["locked"] = cle in self.locked
|
||
assign_vmids(
|
||
self.rows,
|
||
vmids_pris,
|
||
self._vmid_start(),
|
||
lambda vmid: (ctx.get("ipconfig") or (lambda _v: "ip=dhcp"))(
|
||
self._bridge(), vmid
|
||
),
|
||
)
|
||
self._render_plan()
|
||
self._render_mise()
|
||
|
||
def _vmid_start(self):
|
||
brut = self.query_one("#f_vmid", Input).value.strip()
|
||
return (
|
||
int(brut) if brut.isdigit() else (ctx.get("next_vmid") or 100)
|
||
)
|
||
|
||
def _bridge(self):
|
||
valeur = self.query_one("#f_bridge", Select).value
|
||
if valeur is SELECT_NULL or valeur == CREER_PONT:
|
||
# La sentinelle n'est pas un pont : la rendre ferait déployer
|
||
# une VM sur « __creer_pont__ ».
|
||
return ""
|
||
return valeur
|
||
|
||
def _storage(self):
|
||
valeur = self.query_one("#f_storage", Select).value
|
||
return "" if valeur is SELECT_NULL else valeur
|
||
|
||
def _row_head(self, index, row):
|
||
"""La ligne de titre du socle, plus ce que Proxmox ajoute : le
|
||
VMID et l'adresse. Les deux sont décidés ICI et pas par l'hôte —
|
||
les montrer avant de lancer est le seul moyen de les vérifier."""
|
||
base = PlanMixin._row_head(self, index, row)
|
||
vm = row["vm"]
|
||
if row["state"] == "exists":
|
||
return base
|
||
adresse = (vm.get("ipconfig") or "").replace("ip=", "")
|
||
return f"{base} VMID {vm.get('vmid', '?')} {adresse}"
|
||
|
||
def _mount_rows(self) -> None:
|
||
"""(Re)construit le panneau droit.
|
||
|
||
Le verrou couvre TOUT le montage : poser « value= » sur un Select
|
||
fait émettre un Changed à Textual, que on_select_changed prendrait
|
||
pour une saisie."""
|
||
self._syncing = True
|
||
self._gen += 1
|
||
plan = self.query_one("#plan", VerticalScroll)
|
||
plan.remove_children()
|
||
cartes = []
|
||
for i, r in enumerate(self.rows):
|
||
vm = r["vm"]
|
||
cle = self._row_key(i)
|
||
item = self._plan_entries()[i]
|
||
rangee = Horizontal(
|
||
Button("+", id=f"p{i}", classes="vmcopy"),
|
||
Button("✎", id=f"r{i}", classes="vmcopy"),
|
||
Button(
|
||
"🔒" if cle in self.locked else "🔓",
|
||
id=f"l{i}",
|
||
variant=(
|
||
"success" if cle in self.locked else "default"
|
||
),
|
||
classes="vmlock",
|
||
),
|
||
*res_row_widgets(
|
||
i,
|
||
vm,
|
||
self._presets(),
|
||
labels={"vcpus": t("vCPU")},
|
||
null=SELECT_NULL,
|
||
),
|
||
(
|
||
Button("−", id=f"m{i}", classes="vmcopy")
|
||
if item.get("instance")
|
||
else Static("", classes="vmcopy")
|
||
),
|
||
classes="vmrow",
|
||
)
|
||
cartes.append(
|
||
Vertical(
|
||
Static(self._row_head(i, r), id=f"h{i}"),
|
||
rangee,
|
||
classes=(
|
||
"vmcard locked" if cle in self.locked else "vmcard"
|
||
),
|
||
)
|
||
)
|
||
|
||
# Marque de génération sur CHAQUE widget : « walk_children() » ne
|
||
# voit rien avant le montage, les enfants attendent dans
|
||
# « _pending_children ».
|
||
def marquer(node):
|
||
node._el_gen = self._gen
|
||
for child in getattr(node, "_pending_children", None) or []:
|
||
marquer(child)
|
||
|
||
for carte in cartes:
|
||
marquer(carte)
|
||
plan.mount_all(cartes)
|
||
self._shown_ids = self._row_ids()
|
||
self.call_after_refresh(self._after_mount_rows)
|
||
|
||
def _after_mount_rows(self) -> None:
|
||
self._sync_free_inputs()
|
||
self._syncing = False
|
||
|
||
def _render_plan(self) -> None:
|
||
for i, r in enumerate(self.rows):
|
||
try:
|
||
self.query_one(f"#h{i}", Static).update(
|
||
self._row_head(i, r)
|
||
)
|
||
except Exception:
|
||
pass
|
||
n, cpu, ram, disque = plan_totals(self.rows)
|
||
libre = ctx.get("free_ram") or 0
|
||
# La place du stockage CHOISI : elle change avec la liste, donc
|
||
# elle se relit à chaque rendu plutôt qu'une fois au montage.
|
||
place = gib((ctx.get("storage_avail") or {}).get(self._storage()))
|
||
alertes = []
|
||
if libre and ram > libre:
|
||
alertes.append(t("more RAM than the host has free"))
|
||
if place and disque > place:
|
||
alertes.append(t("more disk than the storage has free"))
|
||
alerte = f" ⚠ {' · '.join(alertes)}" if alertes else ""
|
||
self.query_one("#totals", Static).update(
|
||
f" {n} {t('VM')} {cpu} vCPU {ram} Mo RAM "
|
||
f"{disk_note(disque, place)} {res_label(self.profile)}"
|
||
f" {t('storage')} {self._storage() or '?'}"
|
||
f" {t('bridge')} {self._bridge() or '?'}{alerte}"
|
||
)
|
||
|
||
def _refresh_after(self, remonter=False) -> None:
|
||
"""Recalcule, et ne remonte les rangées que si le JEU a changé :
|
||
un remontage à chaque frappe volerait le focus."""
|
||
self._recompute()
|
||
if remonter or self._row_ids() != self._shown_ids:
|
||
self._mount_rows()
|
||
else:
|
||
self._sync_free_inputs()
|
||
|
||
# ---------------------------------------------------------------- #
|
||
# Les messages
|
||
# ---------------------------------------------------------------- #
|
||
def on_selection_list_selected_changed(self, _event) -> None:
|
||
self._refresh_after()
|
||
|
||
def on_radio_set_changed(self, event) -> None:
|
||
if event.radio_set.id == "f_arch":
|
||
self.arch = arches[event.index]
|
||
self._reload_catalog()
|
||
return
|
||
if event.radio_set.id == "f_profile":
|
||
choix = ("1", "2", "3", "4", "custom")[event.index]
|
||
self.profile = choix
|
||
sur_mesure = choix == "custom"
|
||
for champ in RES_FIELDS:
|
||
self.query_one(RES_FIELDS[champ][0], Select).disabled = (
|
||
not sur_mesure
|
||
)
|
||
if not sur_mesure:
|
||
self._show_free(champ, False)
|
||
# Un réglage commun reprend la main sur les VM non figées :
|
||
# c'est le sens même du mot « commun ».
|
||
self._clear_overrides(tuple(RES_FIELDS))
|
||
self._refresh_after(remonter=True)
|
||
|
||
def _sync_install_deps(self) -> None:
|
||
"""Grise ce que la case rend sans effet : la branche et le profil.
|
||
|
||
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
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.query_one("#t_install").set_class(not installe, "off")
|
||
except Exception:
|
||
pass
|
||
|
||
def on_checkbox_changed(self, event) -> None:
|
||
if event.checkbox.id == "f_install":
|
||
# Le disque d'ERPLibre entre — ou sort — du total.
|
||
self._sync_install_deps()
|
||
self._refresh_after()
|
||
|
||
def on_input_changed(self, event) -> None:
|
||
if event.input.id == "f_vmid":
|
||
self._refresh_after()
|
||
|
||
def on_input_submitted(self, event) -> None:
|
||
ident = event.input.id or ""
|
||
if ident in {RES_FIELDS[c][1][1:] for c in RES_FIELDS}:
|
||
self._apply_free(ident.split("_", 1)[1])
|
||
self._refresh_after(remonter=True)
|
||
return
|
||
if ident.startswith("c") and "_" in ident:
|
||
rang, champ = ident[1:].split("_", 1)
|
||
if rang.isdigit():
|
||
self._set_override(
|
||
int(rang), champ, self._read_row_free(int(rang), champ)
|
||
)
|
||
self._refresh_after()
|
||
|
||
def on_select_changed(self, event) -> None:
|
||
if self._syncing:
|
||
return
|
||
ident = event.select.id or ""
|
||
# La marque de génération ne concerne QUE les widgets de rangée :
|
||
# un widget global n'en porte pas. L'exiger de tous revenait à
|
||
# ignorer chaque réglage commun — mesuré, ni le stockage, ni la
|
||
# RAM générale n'atteignaient le plan.
|
||
if re.match(r"v\d+_", ident) and not self._is_current(
|
||
event.select
|
||
):
|
||
return
|
||
if ident == "f_bridge" and event.value == CREER_PONT:
|
||
self._creer_pont()
|
||
return
|
||
if ident in ("f_storage", "f_bridge"):
|
||
self._refresh_after()
|
||
return
|
||
# Réglage commun : « libre… » révèle la saisie, une valeur
|
||
# s'applique à toutes les VM non figées.
|
||
champ = SELECT_TO_FIELD.get(ident)
|
||
if champ:
|
||
if event.value is FREE:
|
||
self._show_free(champ, True)
|
||
self.query_one(RES_FIELDS[champ][1], Input).focus()
|
||
elif event.value is not SELECT_NULL:
|
||
self._show_free(champ, False)
|
||
self.custom[champ] = event.value
|
||
self._clear_overrides((champ,))
|
||
self._refresh_after(remonter=True)
|
||
return
|
||
# Réglage d'UNE rangée.
|
||
if ident.startswith("v") and "_" in ident:
|
||
rang, champ = ident[1:].split("_", 1)
|
||
if not rang.isdigit() or champ not in RES_FIELDS:
|
||
return
|
||
index = int(rang)
|
||
if event.value is FREE:
|
||
self._row_free(index, champ, True)
|
||
self._set_override(
|
||
index, champ, self._read_row_free(index, champ)
|
||
)
|
||
elif event.value is not SELECT_NULL:
|
||
# L'écho du montage n'est pas une saisie : sans ce test,
|
||
# les trois champs de chaque VM se surchargeaient dès
|
||
# l'affichage et toutes les rangées portaient la marque ✎.
|
||
if self._row_echo(index, champ, event.value):
|
||
return
|
||
self._row_free(index, champ, False)
|
||
self._set_override(index, champ, event.value)
|
||
self._refresh_after()
|
||
|
||
def on_button_pressed(self, event) -> None:
|
||
ident = event.button.id or ""
|
||
if ident == "go":
|
||
self.action_deploy()
|
||
elif ident == "no":
|
||
self.action_cancel()
|
||
elif ident == "prompts":
|
||
# Retour aux invites textuelles : {} n'est pas None, et
|
||
# l'appelant sait faire la différence entre « annulé » et
|
||
# « pose-moi les questions à l'ancienne ».
|
||
self.result = {}
|
||
self.exit()
|
||
elif ident.startswith("p") and ident[1:].isdigit():
|
||
self._add_copy(int(ident[1:]), 1)
|
||
elif ident.startswith("m") and ident[1:].isdigit():
|
||
self._add_copy(int(ident[1:]), -1)
|
||
elif ident.startswith("r") and ident[1:].isdigit():
|
||
self._rename(int(ident[1:]))
|
||
elif ident.startswith("l") and ident[1:].isdigit():
|
||
index = int(ident[1:])
|
||
self._set_lock(index, self._row_key(index) not in self.locked)
|
||
|
||
# ---------------------------------------------------------------- #
|
||
# 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
|
||
index = self.query_one("#f_profile_install", Select).value
|
||
label, cmd = (
|
||
profiles[index]
|
||
if profiles and isinstance(index, int)
|
||
else ("", "")
|
||
)
|
||
return {
|
||
"branch": self.query_one("#f_branch", Select).value,
|
||
"label": label,
|
||
"cmd": cmd,
|
||
}
|
||
|
||
def _form_values(self):
|
||
cle = self.query_one("#f_key", Input).value.strip()
|
||
return {
|
||
"host": ctx["host"],
|
||
"storage": self._storage(),
|
||
"bridge": self._bridge(),
|
||
"nameservers": ctx.get("nameservers") or (),
|
||
"res_label": res_label(self.profile),
|
||
"ssh_key": os.path.expanduser(cle) if cle else "",
|
||
"start": self.query_one("#f_start", Checkbox).value,
|
||
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
|
||
"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,
|
||
}
|
||
|
||
def action_deploy(self) -> None:
|
||
spec = build_spec(self.vms, noms_pris, self._form_values())
|
||
if not spec["vms"]:
|
||
self.notify(t("Nothing to deploy."), severity="warning")
|
||
return
|
||
if not spec["storage"]:
|
||
self.notify(
|
||
t("No storage able to hold a VM disk."), severity="error"
|
||
)
|
||
return
|
||
if not spec["bridge"]:
|
||
self.notify(t("No bridge on the host."), severity="error")
|
||
return
|
||
self.result = spec
|
||
self.exit()
|
||
|
||
def action_preview(self) -> None:
|
||
build = ctx.get("build_command")
|
||
if not build:
|
||
return
|
||
spec = build_spec(self.vms, noms_pris, self._form_values())
|
||
lignes = ["\n".join(build(vm, spec)) for vm in spec["vms"]] or [
|
||
t("Nothing selected.")
|
||
]
|
||
self.push_screen(preview_screen()(lignes))
|
||
|
||
def action_clear_vm(self) -> None:
|
||
"""Rend au réglage commun la VM sous le curseur (et son verrou)."""
|
||
index = self._focused_row()
|
||
if index is None:
|
||
return
|
||
cle = self._row_key(index)
|
||
self.locked.discard(cle)
|
||
self.overrides.pop(cle, None)
|
||
self._refresh_after(remonter=True)
|
||
|
||
def action_select_all(self) -> None:
|
||
self.query_one("#f_catalog", SelectionList).select_all()
|
||
|
||
def action_select_none(self) -> None:
|
||
self.query_one("#f_catalog", SelectionList).deselect_all()
|
||
|
||
def action_cancel(self) -> None:
|
||
self.result = None
|
||
self.exit()
|
||
|
||
app = ProxmoxForm()
|
||
if not run_app:
|
||
return app
|
||
app.run()
|
||
return app.result
|