A VM was declared ready without anything proving it. The mobile option now adds the repository to the manifest (additive, so it rides along an Odoo 18 install), runs the mobile repository's own install-android.sh — licences accepted included — then npm ci, vite build, cap sync, gradlew assembleDebug and npm test. A failure fails the VM: the exit code reaches the dashboard. Two gaps in that upstream installer had to be filled: unzip and wget, which no cloud image ships, and the SDK platform — it installs android-34 while variables.gradle asks for compileSdk 36, so the number is read from the file rather than frozen. Gradle writes tens of megabytes and hundreds of harmless lines carrying the word error: that output goes to a file of its own, and the log gets the named cause instead. --- FR --- Une VM était déclarée prête sans que rien ne le prouve. L'option mobile ajoute maintenant le dépôt au manifeste (additif, donc il accompagne une installation Odoo 18), lance l'install-android.sh du dépôt mobile lui-même — licences acceptées comprises —, puis npm ci, vite build, cap sync, gradlew assembleDebug et npm test. Un échec fait échouer la VM : le code de sortie remonte au tableau de bord. Deux manques de cet installateur amont ont dû être comblés : unzip et wget, qu'aucune image cloud ne livre, et la plateforme SDK — il pose android-34 quand variables.gradle réclame compileSdk 36, d'où le chiffre lu dans le fichier plutôt que figé. Gradle écrit des dizaines de mégaoctets et des centaines de lignes anodines portant le mot error : cette sortie part dans un fichier à part, et le journal reçoit la cause nommée. Assisted-by: Claude Opus 5
2128 lines
91 KiB
Python
2128 lines
91 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 Textual de déploiement QEMU, et vue de progression.
|
||
|
||
Deux interfaces mènent au même déploiement : les invites en ligne de
|
||
`todo.py` et ce formulaire. Toutes deux produisent la MÊME structure — la
|
||
« spec » — que `TODO._qemu_run_spec` consomme. Rien n'est décidé ici qui ne
|
||
puisse l'être là-bas, et réciproquement.
|
||
|
||
- build_vms(...) / plan_rows(...) : logique pure, testable sans terminal.
|
||
- run_deploy_form(ctx, run_app=True) : le formulaire ; renvoie une spec ou None.
|
||
- run_deploy_progress(jobs, ...) : blocs repliables par VM pendant le
|
||
déploiement, avec copie du log vers le presse-papiers (OSC 52).
|
||
|
||
Le formulaire ne lance AUCUNE commande privilégiée ni réseau : tout appel
|
||
`virsh` passe par sudo et une invite de mot de passe casserait l'affichage
|
||
Textual. Les données coûteuses (domaines existants, branches distantes) sont
|
||
préchargées par l'appelant et arrivent dans `ctx`.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import time
|
||
|
||
try:
|
||
from script.todo.todo_i18n import t
|
||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||
|
||
def t(key: str) -> str:
|
||
return key
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Logique pure — aucune dépendance à Textual, testable telle quelle
|
||
# --------------------------------------------------------------------------- #
|
||
def entry_key(entry) -> tuple:
|
||
"""Identité stable d'une VM du plan, indépendante de son rang d'affichage :
|
||
surcharges et verrous y survivent quand la sélection change.
|
||
|
||
Le quatrième membre est le numéro d'EXEMPLAIRE. Sans lui, deux copies de
|
||
la même entrée de catalogue partageaient une identité : régler la première
|
||
réglait la seconde, et les verrous se marchaient dessus."""
|
||
return (
|
||
entry["distro"],
|
||
entry["version"],
|
||
entry["arch"],
|
||
entry.get("instance", 0),
|
||
)
|
||
|
||
|
||
def copy_name(base: str, instance: int) -> str:
|
||
"""Nom du n-ième exemplaire. Le premier garde le nom du catalogue, pour
|
||
que les déploiements d'avant gardent le leur."""
|
||
return base if not instance else f"{base}-{instance + 1}"
|
||
|
||
|
||
def expand_copies(entries, copies):
|
||
"""[entrée] + {clé de base: exemplaires en plus} -> [entrée par VM].
|
||
|
||
Chaque exemplaire est une COPIE du dictionnaire, avec son numéro et son
|
||
nom : rien n'est partagé, donc régler l'un ne touche pas l'autre."""
|
||
out = []
|
||
for e in entries:
|
||
base = (e["distro"], e["version"], e["arch"])
|
||
for i in range(1 + max(0, (copies or {}).get(base, 0))):
|
||
item = dict(e)
|
||
item["instance"] = i
|
||
item["name"] = copy_name(e["name"], i)
|
||
out.append(item)
|
||
return out
|
||
|
||
|
||
def parse_disk(value):
|
||
"""« 60 », « 60G », « 1T », « 1,5T » -> « <n>G », ou None si invalide.
|
||
Même règle que `TODO._qemu_parse_disk` : tout le reste de la chaîne
|
||
raisonne en gigaoctets."""
|
||
txt = str(value).strip().upper().replace(",", ".")
|
||
factor = 1
|
||
if txt.endswith("T"):
|
||
factor, txt = 1024, txt[:-1]
|
||
elif txt.endswith("G"):
|
||
txt = txt[:-1]
|
||
try:
|
||
gigs = int(float(txt) * factor)
|
||
except ValueError:
|
||
return None
|
||
return f"{gigs}G" if gigs > 0 else None
|
||
|
||
|
||
def disk_gb(value) -> int:
|
||
"""« 60G » -> 60 (best effort), pour les totaux."""
|
||
parsed = parse_disk(value)
|
||
return int(parsed[:-1]) if parsed else 0
|
||
|
||
|
||
def positive_int(value, fallback):
|
||
"""Entier strictement positif, sinon `fallback`.
|
||
|
||
Ces valeurs viennent de widgets : une liste déroulante sans choix rend un
|
||
sentinelle, une saisie libre rend du texte, éventuellement vide. Aucun des
|
||
deux ne doit atteindre les totaux, qui les additionnent."""
|
||
try:
|
||
number = int(value)
|
||
except (TypeError, ValueError):
|
||
return fallback
|
||
return number if number > 0 else fallback
|
||
|
||
|
||
def parse_ram(value):
|
||
"""« 2048 », « 128G », « 1,5G » -> mébioctets, ou 0 si invalide.
|
||
|
||
Les valeurs proposées s'affichent en G — « 2G », « 16G » — alors que la
|
||
saisie libre comptait en Mo. Taper « 128G », ce que l'affichage invite à
|
||
faire, rendait 0 : la surcharge était alors RETIRÉE et la VM revenait à la
|
||
valeur du catalogue, sans un mot. On accepte donc les deux écritures, un
|
||
nombre nu restant des mébioctets."""
|
||
txt = str(value or "").strip().upper().replace(",", ".")
|
||
factor = 1
|
||
if txt.endswith("GI"):
|
||
factor, txt = 1024, txt[:-2]
|
||
elif txt.endswith(("G", "T")):
|
||
factor = 1024 * (1024 if txt.endswith("T") else 1)
|
||
txt = txt[:-1]
|
||
elif txt.endswith(("M", "MI")):
|
||
txt = txt.rstrip("IM")
|
||
try:
|
||
mib = int(float(txt) * factor)
|
||
except ValueError:
|
||
return 0
|
||
return mib if mib > 0 else 0
|
||
|
||
|
||
def apply_profile(
|
||
entries, profile, base_vcpus, host_cpu, custom=None, desktop=""
|
||
):
|
||
"""Applique le profil de ressources aux entrées choisies.
|
||
|
||
Reproduit à l'identique `TODO._qemu_prompt_resources` : un multiplicateur
|
||
monte la RAM minimale du catalogue et les vCPU en se bornant aux cœurs de
|
||
l'hôte ; « custom » impose les mêmes valeurs à tout le parc, une valeur
|
||
absente gardant celle du catalogue."""
|
||
out = []
|
||
for e in entries:
|
||
if profile == "custom":
|
||
cus = custom or {}
|
||
ram = positive_int(cus.get("ram"), e["ram"])
|
||
disk = parse_disk(cus.get("disk")) or e["disk"]
|
||
vcpus = positive_int(cus.get("vcpus"), base_vcpus)
|
||
else:
|
||
mult = int(profile)
|
||
ram = e["ram"] * mult
|
||
disk = e["disk"]
|
||
vcpus = min(base_vcpus * mult, host_cpu)
|
||
out.append(
|
||
{
|
||
"name": e["name"],
|
||
"distro": e["distro"],
|
||
"version": e["version"],
|
||
"arch": e["arch"],
|
||
"ram": ram,
|
||
"disk": disk,
|
||
"vcpus": vcpus,
|
||
# Type de VM (« » = serveur). Il vit sur la VM et non sur la
|
||
# spec entière depuis qu'il se choisit machine par machine ;
|
||
# `desktop` n'est plus que le défaut commun.
|
||
"desktop": desktop,
|
||
# Branche ERPLibre. Même raison : « » signifie « celle du
|
||
# formulaire », et une surcharge la remplace pour cette VM.
|
||
"branch": "",
|
||
# Profil d'installation (« ERPLibre + Odoo 18 »). Même
|
||
# convention : « » = celui du formulaire.
|
||
"install_cmd": "",
|
||
# Libelle du profil, pour que le recapitulatif puisse dire
|
||
# « Odoo 18 » sans connaitre la liste des profils.
|
||
"install_label": "",
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def apply_overrides(vms, entries, overrides):
|
||
"""Réapplique les réglages par VM (nom, vCPU, RAM, disque) après un
|
||
recalcul du profil. `overrides` est indexé par `entry_key`."""
|
||
for vm, e in zip(vms, entries):
|
||
for field, value in (overrides.get(entry_key(e)) or {}).items():
|
||
vm[field] = value
|
||
return vms
|
||
|
||
|
||
def clean_hostname(value):
|
||
"""Nom d'hote valide (RFC 1123) tire de la saisie, ou None.
|
||
|
||
Le nom d'une VM devient son NOM D'HOTE : une majuscule ou un point de
|
||
trop et cloud-init l'ignore en silence, la machine reste « ubuntu ».
|
||
Mieux vaut refuser ici que le decouvrir sur une VM deja deployee."""
|
||
txt = str(value or "").strip().lower()
|
||
if not txt or len(txt) > 63:
|
||
return None
|
||
if not re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?", txt):
|
||
return None
|
||
return txt
|
||
|
||
|
||
def vm_name(base, desktop, suffixes):
|
||
"""Nom de VM, suffixé du bureau quand il y en a un.
|
||
|
||
Le nom sert de nom d'hôte ET de clé de collision : une VM graphique et sa
|
||
jumelle serveur doivent donc porter des noms différents, sinon la seconde
|
||
est signalée « existe déjà » et silencieusement ignorée. Idempotent, le
|
||
nom étant recalculé à chaque frappe."""
|
||
suffix = (suffixes or {}).get(desktop or "")
|
||
if not suffix or base.endswith(f"-{suffix}"):
|
||
return base
|
||
return f"{base}-{suffix}"
|
||
|
||
|
||
def build_vms(
|
||
entries,
|
||
profile,
|
||
base_vcpus,
|
||
host_cpu,
|
||
custom,
|
||
overrides,
|
||
desktop="",
|
||
suffixes=None,
|
||
):
|
||
"""Catalogue choisi + profil + surcharges -> liste de VM de la spec."""
|
||
vms = apply_overrides(
|
||
apply_profile(entries, profile, base_vcpus, host_cpu, custom, desktop),
|
||
entries,
|
||
overrides,
|
||
)
|
||
# APRÈS les surcharges : c'est là seulement que le type de chaque VM est
|
||
# connu, puisqu'il se choisit machine par machine.
|
||
for vm, e in zip(vms, entries):
|
||
if (overrides or {}).get(entry_key(e), {}).get("name"):
|
||
# Nom donne a la main : il gagne, sans suffixe ajoute. Y coller
|
||
# « -gnome » reviendrait a corriger l'utilisateur.
|
||
continue
|
||
vm["name"] = vm_name(vm["name"], vm.get("desktop"), suffixes)
|
||
return vms
|
||
|
||
|
||
def vm_status(name, domains):
|
||
"""État d'un nom face à l'existant : ('new'|'exists'|'orphan', message).
|
||
|
||
Les deux collisions n'ont pas la même gravité — une VM définie est
|
||
ignorée, un qcow2 resté seul fait échouer deploy_qemu, qui refuse
|
||
d'écraser sans --force."""
|
||
if name in domains:
|
||
return "exists", t("exists - skipped")
|
||
if os.path.exists(f"/var/lib/libvirt/images/{name}.qcow2"):
|
||
return "orphan", t("orphan disk - will FAIL")
|
||
return "new", ""
|
||
|
||
|
||
def plan_rows(vms, domains, extra_disk_gb=0):
|
||
"""Lignes du tableau du plan : une par VM, avec son état."""
|
||
rows = []
|
||
for vm in vms:
|
||
state, note = vm_status(vm["name"], domains)
|
||
rows.append(
|
||
{
|
||
"vm": vm,
|
||
"state": state,
|
||
"note": note,
|
||
"disk_gb": disk_gb(vm["disk"]) + extra_disk_gb,
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
def plan_totals(rows):
|
||
"""Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de
|
||
neuf) : (nb, vcpus, ram_mo, disque_go)."""
|
||
fresh = [r for r in rows if r["state"] != "exists"]
|
||
return (
|
||
len(fresh),
|
||
sum(r["vm"]["vcpus"] for r in fresh),
|
||
sum(r["vm"]["ram"] for r in fresh),
|
||
sum(r["disk_gb"] for r in fresh),
|
||
)
|
||
|
||
|
||
def build_spec(vms, domains, form):
|
||
"""Assemble la spec finale, dans la forme exacte que produit la CLI."""
|
||
known = set(domains)
|
||
return {
|
||
"res_label": form["res_label"],
|
||
"vms": [vm for vm in vms if vm["name"] not in known],
|
||
"existing": [vm["name"] for vm in vms if vm["name"] in known],
|
||
"ssh_key": form["ssh_key"],
|
||
"timezone": form.get("timezone", ""),
|
||
"desktop": form.get("desktop", ""),
|
||
"vm_tools": tuple(form.get("vm_tools") or ()),
|
||
"python_provider": form.get("python_provider", ""),
|
||
"app_store": form.get("app_store", "deb"),
|
||
"install": form["install"],
|
||
"add_ssh_config": form["add_ssh_config"],
|
||
"parallelism": form["parallelism"],
|
||
}
|
||
|
||
|
||
def fmt_dur(secs) -> str:
|
||
mm, ss = divmod(int(secs), 60)
|
||
if mm >= 60:
|
||
return f"{mm // 60}h{mm % 60:02d}"
|
||
return f"{mm}m{ss:02d}" if mm else f"{ss}s"
|
||
|
||
|
||
# Au-delà, un OSC 52 est tronqué par certains terminaux (xterm notamment).
|
||
# On copie alors la FIN du log — la partie qui porte l'erreur.
|
||
CLIP_LIMIT = 100_000
|
||
|
||
|
||
def clip_payload(text, limit=CLIP_LIMIT):
|
||
"""(texte_à_copier, tronqué?) — on garde la fin, pas le début."""
|
||
if len(text) <= limit:
|
||
return text, False
|
||
return text[-limit:], True
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Formulaire Textual
|
||
# --------------------------------------------------------------------------- #
|
||
def run_deploy_form(ctx, run_app: bool = True):
|
||
"""Formulaire de déploiement. Renvoie une spec, ou None si annulé.
|
||
`run_app=False` renvoie l'instance sans la lancer (tests headless)."""
|
||
from textual.app import App, ComposeResult
|
||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||
from textual.screen import ModalScreen
|
||
from textual.widgets import (
|
||
Button,
|
||
Checkbox,
|
||
Footer,
|
||
Header,
|
||
Input,
|
||
RadioButton,
|
||
RadioSet,
|
||
Select,
|
||
SelectionList,
|
||
Static,
|
||
)
|
||
|
||
# Textual 8 a ramené Select.BLANK à un alias déprécié valant False ; le
|
||
# sentinelle « rien de choisi » est Select.NULL. Comparer à BLANK ne
|
||
# filtrait donc plus rien, et NoSelection — dépourvu de __bool__, donc
|
||
# tenu pour vrai — passait pour une valeur jusque dans les totaux.
|
||
SELECT_NULL = getattr(Select, "NULL", Select.BLANK)
|
||
|
||
catalog = ctx["catalog"]
|
||
arches = ctx["arches"]
|
||
domains = set(ctx.get("domains") or [])
|
||
profiles = ctx.get("install_profiles") or []
|
||
branches = ctx.get("branches") or ["master"]
|
||
host_cpu = ctx.get("host_cpu") or 2
|
||
free_ram = ctx.get("free_ram") or 0
|
||
base_vcpus = ctx.get("base_vcpus") or 2
|
||
extra_disk = ctx.get("extra_disk_gb") or 0
|
||
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 [])
|
||
# {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les
|
||
# saveurs — on ne le redéfinit pas ici.
|
||
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
|
||
# Outils de développement d'une VM graphique : [(clé, libellé, indice)] et
|
||
# leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait
|
||
# que les afficher et rendre les cases cochées.
|
||
vm_tools = list(ctx.get("vm_tools") or [])
|
||
tool_disk = dict(ctx.get("vm_tool_disk") or {})
|
||
tool_arches = dict(ctx.get("vm_tool_arches") or {})
|
||
tool_desktops = dict(ctx.get("vm_tool_desktops") or {})
|
||
tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {})
|
||
tool_families = dict(ctx.get("vm_tool_families") or {})
|
||
distro_family = dict(ctx.get("distro_family") or {})
|
||
# Architectures pour lesquelles mise publie un binaire.
|
||
mise_arches = set(ctx.get("mise_arches") or ())
|
||
# [(clé, libellé)] des magasins d'applications, et les distributions qui
|
||
# livrent snapd — la question n'a de sens que pour celles-là, graphiques.
|
||
app_stores = list(ctx.get("app_stores") or [])
|
||
snap_distros = set(ctx.get("snap_distros") or ())
|
||
# Fuseaux proposés, celui de l'hôte en tête (voir todo.py).
|
||
timezones = list(ctx.get("timezones") or [])
|
||
defaults = ctx.get("defaults") or {}
|
||
result = {"spec": None}
|
||
|
||
AUTO = "__auto__"
|
||
# Dernier choix de chaque liste de ressources : il ne porte pas de valeur,
|
||
# il révèle la saisie libre placée juste dessous.
|
||
FREE = "__free__"
|
||
# « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le
|
||
# rendrait indistinguable de la sentinelle « rien de sélectionné ».
|
||
SERVER = "__server__"
|
||
RES_FIELDS = {
|
||
"vcpus": ("#f_vcpus", "#c_vcpus"),
|
||
"ram": ("#f_ram", "#c_ram"),
|
||
"disk": ("#f_disk", "#c_disk"),
|
||
}
|
||
SELECT_TO_FIELD = {sel[1:]: f for f, (sel, _i) in RES_FIELDS.items()}
|
||
INPUT_TO_FIELD = {inp[1:]: f for f, (_s, inp) in RES_FIELDS.items()}
|
||
|
||
def entry_label(e):
|
||
star = " *" if e.get("default") else ""
|
||
return (
|
||
f"{e['distro']} {e['version']}{star} [{e['arch']}] "
|
||
f"RAM≥{e['ram']}Mo {e['disk']}"
|
||
)
|
||
|
||
class RenameScreen(ModalScreen):
|
||
"""Nom d'une VM. Vide = revenir au nom automatique."""
|
||
|
||
BINDINGS = [("escape", "cancel", t("Cancel"))]
|
||
|
||
def __init__(self, name, auto):
|
||
super().__init__()
|
||
self._name = name
|
||
self._auto = auto
|
||
|
||
def compose(self) -> ComposeResult:
|
||
with Vertical(id="renbox"):
|
||
yield Static(t("Rename the VM"), id="rentitle")
|
||
yield Input(value=self._name, id="renval")
|
||
yield Static(
|
||
f" {t('Empty = back to the automatic name:')} "
|
||
f"{self._auto}",
|
||
id="renhint",
|
||
)
|
||
with Horizontal(id="renbtns"):
|
||
yield Button(t("Cancel"), id="ren_no")
|
||
yield Button(t("Rename"), variant="primary", id="ren_ok")
|
||
|
||
def on_button_pressed(self, event) -> None:
|
||
if event.button.id != "ren_ok":
|
||
self.dismiss(None)
|
||
return
|
||
self.dismiss(self.query_one("#renval", Input).value)
|
||
|
||
def action_cancel(self) -> None:
|
||
self.dismiss(None)
|
||
|
||
class PreviewScreen(ModalScreen):
|
||
"""Aperçu des commandes qui seraient lancées (aucune exécution)."""
|
||
|
||
BINDINGS = [
|
||
("escape", "close", t("Close")),
|
||
("q", "close", t("Close")),
|
||
]
|
||
|
||
def __init__(self, lines):
|
||
super().__init__()
|
||
self._lines = lines
|
||
|
||
def compose(self) -> ComposeResult:
|
||
with Vertical(id="prevbox"):
|
||
yield Static(
|
||
f" {t('Preview (dry-run):')} ({t('Esc to close')})",
|
||
id="prevtitle",
|
||
)
|
||
yield Static("\n\n".join(self._lines), id="prevbody")
|
||
|
||
def action_close(self) -> None:
|
||
self.dismiss()
|
||
|
||
class DeployForm(App):
|
||
CSS = """
|
||
#body { height: 1fr; }
|
||
#fields { width: 62; border: solid $accent; overflow-y: auto; }
|
||
#right { width: 1fr; }
|
||
#plan {
|
||
height: 1fr; border: solid $accent;
|
||
overflow-x: auto; scrollbar-size-horizontal: 1;
|
||
}
|
||
#totals { height: auto; color: $text-muted; padding: 0 1; }
|
||
.grouptitle { color: $accent; text-style: bold; padding: 1 0 0 0; }
|
||
SelectionList { height: 10; border: solid $panel; }
|
||
RadioSet { height: auto; layout: horizontal; }
|
||
.freeval { display: none; width: 9; }
|
||
/* « width: auto » sur la CARTE, pas seulement sur la rangée. Un
|
||
conteneur Textual vaut « width: 1fr » par défaut : la carte se bornait
|
||
donc au panneau, et c'est ELLE que « #plan » mesure pour sa largeur
|
||
virtuelle. La rangée avait beau être en « auto », son débordement était
|
||
coupé dans une carte qui ne grandissait pas, et la barre horizontale
|
||
n'apparaissait jamais. */
|
||
.vmcard {
|
||
height: auto; width: auto;
|
||
border-bottom: solid $panel; padding: 0 1;
|
||
}
|
||
/* Une VM figée se voit à la LIGNE, pas à une case perdue au bout :
|
||
c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui
|
||
échappe au profil. */
|
||
.vmcard.locked { background: $success 20%; }
|
||
.vmlock { width: 5; min-width: 5; }
|
||
/* La branche porte des noms longs (« 1.6.0 », « develop »,
|
||
« feature/xyz ») : trop étroite, la liste les tronque et on ne sait
|
||
plus ce qu'on a choisi. */
|
||
|
||
|
||
.vmcopy { width: 5; min-width: 5; }
|
||
.vmhead { height: 1; }
|
||
/* « width: auto » et le défilement du plan : sans eux, une rangée
|
||
plus large que le panneau est COUPÉE au lieu d'être atteignable. */
|
||
.vmrow { height: 3; width: auto; align-vertical: middle; }
|
||
.vmrow Select { width: 15; }
|
||
.vmrow Input { width: 11; }
|
||
/* Ces deux règles portent « .vmrow Select » EN PLUS de leur classe :
|
||
« .vmrow Select » (une classe + un type) l'emporte sur « .vmbranch »
|
||
(une classe) par spécificité CSS. Écrites simplement, elles étaient
|
||
silencieusement écrasées à 15 — et le test, qui ne vérifiait que la
|
||
présence de la classe, passait sans rien prouver. */
|
||
.vmrow Select.vmbranch { width: 34; }
|
||
.vmrow Select.vmprof { width: 40; }
|
||
#reslabel { color: $text-muted; }
|
||
RenameScreen { align: center middle; }
|
||
#renbox {
|
||
width: 60; height: auto; padding: 1 2;
|
||
border: thick $accent; background: $surface;
|
||
}
|
||
#rentitle { color: $accent; text-style: bold; }
|
||
#renhint { color: $text-muted; }
|
||
#renbtns { height: auto; padding-top: 1; }
|
||
PreviewScreen { align: center middle; }
|
||
#prevbox {
|
||
width: 90%; height: 70%; padding: 1 2;
|
||
border: thick $accent; background: $surface;
|
||
}
|
||
#prevtitle { height: 1; color: $accent; text-style: bold; }
|
||
#prevbody { height: 1fr; overflow-y: auto; }
|
||
"""
|
||
# Touches de fonction plutôt que ctrl+lettre : ctrl+p est pris par la
|
||
# palette de commandes de Textual, et une lettre seule serait avalée
|
||
# par le champ de saisie qui a le focus.
|
||
BINDINGS = [
|
||
("f5", "deploy", t("Deploy")),
|
||
("f4", "clear_vm", t("Reset VM")),
|
||
("f3", "preview", t("Preview")),
|
||
("f9", "dump_state", t("Diagnostic dump")),
|
||
("f6", "select_all", t("All")),
|
||
("f7", "select_main", t("Main versions")),
|
||
("f8", "select_none", t("None")),
|
||
("escape", "cancel", t("Cancel")),
|
||
]
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.arch = ctx.get("native") or arches[0]
|
||
self.profile = "1"
|
||
self.custom = {}
|
||
self._free = {}
|
||
self.overrides = {}
|
||
# Vrai pendant qu'on repositionne les widgets nous-mêmes : sans
|
||
# ce verrou, poser une valeur déclencherait on_select_changed, qui
|
||
# réécrirait une surcharge — une boucle qui se nourrit seule.
|
||
self._syncing = False
|
||
# Jeu de VM actuellement monté dans le panneau droit.
|
||
self._shown_ids = ()
|
||
# Génération du jeu de rangées monté. Les identifiants de widgets
|
||
# portent un RANG, et le rang se décale quand on coche ou décoche
|
||
# une entrée : un événement émis par un widget déjà détruit
|
||
# s'appliquerait alors à la VM qui a pris sa place. Chaque widget
|
||
# de rangée retient sa génération ; ceux d'une génération périmée
|
||
# sont ignorés.
|
||
self._gen = 0
|
||
# VM dont les ressources sont FIGÉES, par identité de catalogue.
|
||
# Distinct des surcharges : une VM peut être modifiée sans être
|
||
# verrouillée, et le verrou fige TOUT, pas seulement ce qu'on a
|
||
# touché.
|
||
self.locked = set()
|
||
# {clé de base: nombre d'exemplaires EN PLUS du premier}.
|
||
self.copies = {}
|
||
self.rows = []
|
||
self.vms = []
|
||
self.rows = []
|
||
|
||
# -- construction de l'écran ----------------------------------- #
|
||
def compose(self) -> ComposeResult:
|
||
yield Header()
|
||
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"))
|
||
# Chaque ressource offre ses suggestions plus « libre… »,
|
||
# qui révèle une saisie. C'est l'équivalent TUI de la règle
|
||
# de la CLI : une lettre choisit une suggestion, un chiffre
|
||
# vaut pour lui-même.
|
||
yield Select(
|
||
[(str(c), c) for c in ctx["cpu_presets"]]
|
||
+ [(t("free value…"), FREE)],
|
||
prompt=t("vCPU"),
|
||
id="f_vcpus",
|
||
disabled=True,
|
||
)
|
||
yield Input(
|
||
placeholder=t("vCPU"),
|
||
id="c_vcpus",
|
||
classes="freeval",
|
||
disabled=True,
|
||
)
|
||
yield Select(
|
||
[
|
||
(f"{m} ({m // 1024}G)", m)
|
||
for m in ctx["ram_presets"]
|
||
]
|
||
+ [(t("free value…"), FREE)],
|
||
prompt=t("RAM: 2048 or 8G"),
|
||
id="f_ram",
|
||
disabled=True,
|
||
)
|
||
yield Input(
|
||
placeholder=t("RAM: 2048 or 8G"),
|
||
id="c_ram",
|
||
classes="freeval",
|
||
disabled=True,
|
||
)
|
||
yield Select(
|
||
[(d, d) for d in ctx["disk_presets"]]
|
||
+ [(t("free value…"), FREE)],
|
||
prompt=t("Disk"),
|
||
id="f_disk",
|
||
disabled=True,
|
||
)
|
||
yield Input(
|
||
placeholder=t("Disk (e.g. 250G, 1.5T)"),
|
||
id="c_disk",
|
||
classes="freeval",
|
||
disabled=True,
|
||
)
|
||
yield Static(
|
||
f" {t('The profile and these fields change EVERY VM.')}"
|
||
f"\n {t('A VM edited on the right (marked) keeps its own.')}",
|
||
id="scopetarget",
|
||
)
|
||
# Serveur par défaut : c'est ce que sert une image cloud,
|
||
# et GNOME ajoute une à deux heures sur une architecture
|
||
# émulée. Le plan annonce le surcoût disque.
|
||
yield Static(t("VM type (default):"), classes="grouptitle")
|
||
with RadioSet(id="f_type"):
|
||
yield RadioButton(
|
||
t("Server (no graphical interface)"),
|
||
value=not defaults.get("desktop", ""),
|
||
)
|
||
for key, label in desktops:
|
||
yield RadioButton(
|
||
f"{t('Graphical (server + desktop):')} {label}",
|
||
value=defaults.get("desktop", "") == key,
|
||
)
|
||
if app_stores:
|
||
yield Static(
|
||
t("Application store:"), classes="grouptitle"
|
||
)
|
||
with RadioSet(id="f_store"):
|
||
for i, (_k, label) in enumerate(app_stores):
|
||
yield RadioButton(label, value=i == 0)
|
||
yield Static("", id="storewarn")
|
||
if vm_tools:
|
||
# Une case par outil, et non une liste déroulante : ils
|
||
# sont indépendants, et chacun se prend ou se laisse.
|
||
yield Static(
|
||
t("Development tools:"), classes="grouptitle"
|
||
)
|
||
for key, label, hint in vm_tools:
|
||
gb = tool_disk.get(key, 0)
|
||
yield Checkbox(
|
||
f"{label} +{gb} Go — {hint}",
|
||
value=key in (defaults.get("tools") or ()),
|
||
id=f"f_tool_{key}",
|
||
)
|
||
yield Static("", id="toolwarn")
|
||
yield Static("ERPLibre", classes="grouptitle")
|
||
yield Checkbox(
|
||
t("Install ERPLibre"),
|
||
value=defaults.get("install", True),
|
||
id="f_install",
|
||
)
|
||
yield Select(
|
||
[(b, b) for b in branches],
|
||
value=(
|
||
"develop" if "develop" in branches else branches[0]
|
||
),
|
||
allow_blank=False,
|
||
id="f_branch",
|
||
)
|
||
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 Checkbox(
|
||
t("Production (/opt, confined)"),
|
||
value=defaults.get("prod", False),
|
||
id="f_prod",
|
||
)
|
||
yield Checkbox(
|
||
t("Monitoring dashboard"),
|
||
value=defaults.get("monitor", True),
|
||
id="f_monitor",
|
||
)
|
||
yield Static(t("Timezone"), classes="grouptitle")
|
||
# Une liste plutôt qu'une saisie : un nom IANA mal
|
||
# orthographié n'est pas refusé par cloud-init, il est
|
||
# IGNORÉ — la VM reste en UTC et on ne s'en aperçoit
|
||
# qu'aux horodatages. « libre… » garde la porte ouverte
|
||
# aux six cents autres fuseaux de la base.
|
||
yield Select(
|
||
[(z, z) for z in timezones]
|
||
+ [(t("free value…"), FREE)],
|
||
value=(timezones[0] if timezones else SELECT_NULL),
|
||
allow_blank=False,
|
||
id="f_tz_sel",
|
||
)
|
||
yield Input(
|
||
value=ctx.get("timezone") or "",
|
||
placeholder=t("Timezone for the VMs"),
|
||
id="f_tz",
|
||
classes="freeval",
|
||
)
|
||
yield Static("SSH", classes="grouptitle")
|
||
yield Input(
|
||
value=ctx.get("ssh_key") or "",
|
||
placeholder=t("SSH public key path"),
|
||
id="f_key",
|
||
)
|
||
yield Checkbox(
|
||
t("Add each VM to ~/.ssh/config"),
|
||
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,
|
||
# le nombre reprend la main, et son défaut suit l'hôte —
|
||
# la CLI comptait déjà les CPU, la TUI restait figée à 4.
|
||
yield Checkbox(
|
||
t("One run per install"),
|
||
value=defaults.get("par_per_install", True),
|
||
id="f_par_all",
|
||
)
|
||
yield Select(
|
||
[(str(n), n) for n in range(1, host_cpu + 1)],
|
||
value=host_cpu,
|
||
allow_blank=False,
|
||
disabled=True,
|
||
id="f_par",
|
||
)
|
||
with Vertical(id="right"):
|
||
# Une liste de widgets, pas un tableau : chaque VM porte
|
||
# SES listes déroulantes, modifiables sur place. Un
|
||
# DataTable ne sait pas héberger de widget.
|
||
yield VerticalScroll(id="plan")
|
||
yield Static("", id="totals")
|
||
yield Footer()
|
||
|
||
def on_mount(self) -> None:
|
||
self.title = t("Deploy ERPLibre VM(s)!")
|
||
self._reload_catalog(first_load=True)
|
||
|
||
# -- catalogue et recalcul ------------------------------------- #
|
||
def _plan_entries(self):
|
||
"""Entrées du plan : la sélection, dépliée en exemplaires."""
|
||
return expand_copies(self._selected_entries(), self.copies)
|
||
|
||
def _entries(self):
|
||
return catalog.get(self.arch, [])
|
||
|
||
def _reload_catalog(self, first_load=False):
|
||
"""(Re)charge la liste à cocher.
|
||
|
||
RIEN n'est coché d'avance : déployer coûte cher, et une case
|
||
pré-cochée ferait créer une VM que personne n'a demandée. Le « * »
|
||
marque toujours la version principale, et F7 les coche toutes.
|
||
|
||
Après un changement d'architecture, les cases déjà cochées sont
|
||
conservées quand l'entrée existe encore — l'identité est
|
||
(distro, version, archi), pas le rang dans la liste."""
|
||
widget = self.query_one("#f_catalog", SelectionList)
|
||
keep = (
|
||
set()
|
||
if first_load
|
||
else {
|
||
entry_key(self._entries_before[i])
|
||
for i in widget.selected
|
||
if i < len(self._entries_before)
|
||
}
|
||
)
|
||
widget.clear_options()
|
||
entries = self._entries()
|
||
for i, e in enumerate(entries):
|
||
widget.add_option((entry_label(e), i, entry_key(e) in keep))
|
||
self._entries_before = entries
|
||
self._recompute()
|
||
|
||
def _selected_entries(self):
|
||
widget = self.query_one("#f_catalog", SelectionList)
|
||
entries = self._entries()
|
||
return [entries[i] for i in sorted(widget.selected)]
|
||
|
||
def _recompute(self):
|
||
entries = self._plan_entries()
|
||
self.vms = build_vms(
|
||
entries,
|
||
self.profile,
|
||
base_vcpus,
|
||
host_cpu,
|
||
self.custom,
|
||
self.overrides,
|
||
self._default_desktop(),
|
||
desktop_suffixes,
|
||
)
|
||
# ERPLibre et GNOME pèsent chacun sur le disque, et se cumulent.
|
||
grow = 0
|
||
if self.query_one("#f_install", Checkbox).value:
|
||
grow += extra_disk
|
||
self.rows = plan_rows(self.vms, domains, grow)
|
||
# Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle
|
||
# seule : un supplément commun mentait dès que les types
|
||
# différaient d'une machine à l'autre.
|
||
tools = self._vm_tools()
|
||
for row in self.rows:
|
||
if row["vm"].get("desktop"):
|
||
row["disk_gb"] += desktop_disk
|
||
# Même règle pour les outils, et pour la même raison : ils ne
|
||
# pèsent que sur les VM qui les reçoivent réellement. Android
|
||
# Studio n'existe qu'en x86_64, les extensions GNOME n'ont de
|
||
# sens que sous GNOME — une VM qui ne les aura pas ne doit pas
|
||
# se voir gonfler son disque.
|
||
row["disk_gb"] += sum(
|
||
tool_disk.get(k, 0)
|
||
for k in self._tools_for_vm(row["vm"], tools)
|
||
)
|
||
# Le plan doit MONTRER qu'une VM a été personnalisée : sans marque,
|
||
# deux lignes aux ressources différentes n'ont aucune explication à
|
||
# l'écran, et la surcharge est oubliée à la relecture. Le drapeau
|
||
# vit sur la ligne d'affichage, jamais sur la VM : celle-ci part
|
||
# telle quelle dans la spec, que la CLI produit à l'identique.
|
||
for row, entry in zip(self.rows, entries):
|
||
key = entry_key(entry)
|
||
row["custom"] = bool(self.overrides.get(key))
|
||
row["locked"] = key in self.locked
|
||
self._render_plan()
|
||
self._render_mise()
|
||
self._render_store()
|
||
self._render_tools()
|
||
|
||
def _vm_tools(self):
|
||
"""Clés des outils cochés, dans l'ordre de la liste."""
|
||
picked = []
|
||
for key, _label, _hint in vm_tools:
|
||
try:
|
||
if self.query_one(f"#f_tool_{key}", Checkbox).value:
|
||
picked.append(key)
|
||
except Exception:
|
||
continue
|
||
return tuple(picked)
|
||
|
||
def _tools_for_vm(self, vm, tools):
|
||
"""Outils qu'une VM donnée recevra vraiment.
|
||
|
||
Même filtre que todo.py côté déploiement : une VM ARM ne verra
|
||
jamais Android Studio, une VM Cinnamon jamais les extensions GNOME,
|
||
un serveur aucun des IDE — mais un serveur reçoit bien la
|
||
compilation mobile, qui n'a rien à afficher, et une distribution
|
||
sans apt ne la reçoit pas, son installateur n'existant que là."""
|
||
out = []
|
||
for key in tools:
|
||
arches = tool_arches.get(key) or ()
|
||
desks = tool_desktops.get(key) or ()
|
||
fams = tool_families.get(key) or ()
|
||
if tool_needs_desktop.get(key) and not vm.get("desktop"):
|
||
continue
|
||
if arches and vm["arch"] not in arches:
|
||
continue
|
||
if desks and vm.get("desktop") not in desks:
|
||
continue
|
||
if fams and distro_family.get(vm["distro"], "") not in fams:
|
||
continue
|
||
out.append(key)
|
||
return out
|
||
|
||
def _render_tools(self):
|
||
"""Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et
|
||
NOMME ce qui sera écarté.
|
||
|
||
Une case par outil, et non un blocage en bloc : sur un parc de
|
||
serveurs les IDE se grisent, la compilation mobile reste offerte.
|
||
Cocher Android Studio sur un parc ARM ne produit rien — le dire ici
|
||
évite de le découvrir dans le journal d'installation."""
|
||
if not vm_tools:
|
||
return
|
||
for key, _label, _hint in vm_tools:
|
||
usable = any(
|
||
self._tools_for_vm(vm, (key,)) for vm in self.vms
|
||
)
|
||
self.query_one(f"#f_tool_{key}", Checkbox).disabled = not usable
|
||
picked = self._vm_tools()
|
||
skipped = sorted(
|
||
{
|
||
vm["name"]
|
||
for vm in self.vms
|
||
for k in picked
|
||
if k not in self._tools_for_vm(vm, picked)
|
||
}
|
||
)
|
||
self.query_one("#toolwarn", Static).update(
|
||
f" ⚠ {t('Partly skipped (arch or desktop):')} "
|
||
f"{', '.join(skipped)}"
|
||
if skipped
|
||
else ""
|
||
)
|
||
|
||
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 _app_store(self):
|
||
"""Magasin retenu. Sans VM concernée, la réponse est « deb » :
|
||
elle ne change rien, et laisser passer « snap » réactiverait snapd
|
||
pour rien."""
|
||
if not app_stores or not self._app_store_needed():
|
||
return "deb"
|
||
index = self.query_one("#f_store", RadioSet).pressed_index
|
||
if index is None or not (0 <= index < len(app_stores)):
|
||
return app_stores[0][0]
|
||
return app_stores[index][0]
|
||
|
||
def _app_store_needed(self):
|
||
"""Au moins une VM graphique sur une distribution qui livre snapd."""
|
||
return any(
|
||
vm.get("desktop") and vm["distro"] in snap_distros
|
||
for vm in self.vms
|
||
)
|
||
|
||
def _render_store(self):
|
||
"""Grise le choix quand aucune VM ne le concerne, et dit pourquoi."""
|
||
if not app_stores:
|
||
return
|
||
needed = self._app_store_needed()
|
||
self.query_one("#f_store", RadioSet).disabled = not needed
|
||
self.query_one("#storewarn", Static).update(
|
||
""
|
||
if needed
|
||
else f" {t('No graphical VM on a snap-based distro.')}"
|
||
)
|
||
|
||
def _mise_usable(self):
|
||
return any(vm["arch"] in mise_arches for vm in self.vms)
|
||
|
||
def _profile_cmd(self):
|
||
"""Commande du profil choisi en haut : le défaut de chaque VM."""
|
||
if not profiles:
|
||
return ""
|
||
index = self.query_one("#f_profile_install", Select).value
|
||
return profiles[index if isinstance(index, int) else 0][1]
|
||
|
||
def _row_profile_index(self, i):
|
||
"""Rang du profil que la rangée doit AFFICHER."""
|
||
cmd = ""
|
||
if i < len(self.rows):
|
||
cmd = self.rows[i]["vm"].get("install_cmd") or ""
|
||
cmd = cmd or self._profile_cmd()
|
||
for k, (_lbl, c) in enumerate(profiles):
|
||
if c == cmd:
|
||
return k
|
||
return 0
|
||
|
||
def _branch(self):
|
||
"""Branche du formulaire : le défaut de chaque VM."""
|
||
value = self.query_one("#f_branch", Select).value
|
||
return value if isinstance(value, str) else branches[0]
|
||
|
||
def _default_desktop(self):
|
||
"""« » pour un serveur, sinon la clé de la saveur choisie."""
|
||
"""Type de VM par défaut. Chaque rangée peut s'en écarter."""
|
||
index = self.query_one("#f_type", RadioSet).pressed_index
|
||
if index is None or index < 1 or index > len(desktops):
|
||
return ""
|
||
return desktops[index - 1][0]
|
||
|
||
# -- panneau droit : une rangée de widgets par VM ---------------- #
|
||
def _row_ids(self):
|
||
"""Identité du JEU de VM affiché. Reconstruire les widgets à chaque
|
||
frappe ferait perdre le focus en pleine saisie : on ne le fait que
|
||
si la liste elle-même a changé."""
|
||
return tuple(entry_key(e) for e in self._plan_entries())
|
||
|
||
def _type_options(self):
|
||
return [(t("Server"), SERVER)] + [
|
||
(label, key) for key, label in desktops
|
||
]
|
||
|
||
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 prenait
|
||
pour une saisie. Résultat mesuré — les trois champs de CHAQUE VM
|
||
recevaient une surcharge dès l'affichage, le profil x1..x4 ne
|
||
pouvait plus rien changer, et toutes les lignes portaient la
|
||
marque ✎. Il est relâché après le rafraîchissement, une fois ces
|
||
messages consommés."""
|
||
self._syncing = True
|
||
self._gen += 1
|
||
plan = self.query_one("#plan", VerticalScroll)
|
||
plan.remove_children()
|
||
widgets = []
|
||
for i, r in enumerate(self.rows):
|
||
vm = r["vm"]
|
||
item = self._plan_entries()[i]
|
||
key = entry_key(item)
|
||
row = Horizontal(
|
||
# « + » ajoute un exemplaire de CETTE entrée ; « - » ne
|
||
# s'affiche que sur une copie, pour qu'on ne puisse pas
|
||
# retirer l'original par mégarde.
|
||
Button("+", id=f"p{i}", classes="vmcopy"),
|
||
Button("✎", id=f"r{i}", classes="vmcopy"),
|
||
Button(
|
||
"🔒" if key in self.locked else "🔓",
|
||
id=f"l{i}",
|
||
variant="success" if key in self.locked else "default",
|
||
classes="vmlock",
|
||
),
|
||
Select(
|
||
[(str(c), c) for c in ctx["cpu_presets"]]
|
||
+ [(t("free value…"), FREE)],
|
||
value=(
|
||
vm["vcpus"]
|
||
if vm["vcpus"] in ctx["cpu_presets"]
|
||
else SELECT_NULL
|
||
),
|
||
prompt="vCPU",
|
||
id=f"v{i}_vcpus",
|
||
),
|
||
Input(
|
||
value=(
|
||
""
|
||
if vm["vcpus"] in ctx["cpu_presets"]
|
||
else str(vm["vcpus"])
|
||
),
|
||
placeholder="vCPU",
|
||
id=f"c{i}_vcpus",
|
||
classes="freeval",
|
||
),
|
||
Select(
|
||
[(f"{m // 1024}G", m) for m in ctx["ram_presets"]]
|
||
+ [(t("free value…"), FREE)],
|
||
value=(
|
||
vm["ram"]
|
||
if vm["ram"] in ctx["ram_presets"]
|
||
else SELECT_NULL
|
||
),
|
||
prompt=t("RAM: 2048 or 8G"),
|
||
id=f"v{i}_ram",
|
||
),
|
||
Input(
|
||
value=(
|
||
""
|
||
if vm["ram"] in ctx["ram_presets"]
|
||
else str(vm["ram"])
|
||
),
|
||
placeholder=t("RAM: 2048 or 8G"),
|
||
id=f"c{i}_ram",
|
||
classes="freeval",
|
||
),
|
||
Select(
|
||
[(d, d) for d in ctx["disk_presets"]]
|
||
+ [(t("free value…"), FREE)],
|
||
value=(
|
||
vm["disk"]
|
||
if vm["disk"] in ctx["disk_presets"]
|
||
else SELECT_NULL
|
||
),
|
||
prompt=t("Disk"),
|
||
id=f"v{i}_disk",
|
||
),
|
||
Input(
|
||
value=(
|
||
""
|
||
if vm["disk"] in ctx["disk_presets"]
|
||
else str(vm["disk"])
|
||
),
|
||
placeholder=t("Disk"),
|
||
id=f"c{i}_disk",
|
||
classes="freeval",
|
||
),
|
||
(
|
||
Button("−", id=f"m{i}", classes="vmcopy")
|
||
if item.get("instance")
|
||
else Static("", classes="vmcopy")
|
||
),
|
||
Select(
|
||
[(b, b) for b in branches],
|
||
classes="vmbranch",
|
||
# Repli sur la branche du FORMULAIRE, jamais sur
|
||
# branches[0] : les rangées sont remontées dès que le
|
||
# jeu de VM change (une entrée cochée, une copie
|
||
# ajoutée, un renommage), et elles retombaient alors
|
||
# toutes sur « develop » quel que soit le choix commun.
|
||
value=(
|
||
self.rows[i]["vm"].get("branch")
|
||
if i < len(self.rows)
|
||
else ""
|
||
)
|
||
or self._branch(),
|
||
allow_blank=False,
|
||
id=f"v{i}_branch",
|
||
),
|
||
(
|
||
Select(
|
||
[(lbl, i) for i, (lbl, _c) in enumerate(profiles)],
|
||
value=self._row_profile_index(i),
|
||
allow_blank=False,
|
||
classes="vmprof",
|
||
id=f"v{i}_prof",
|
||
)
|
||
if profiles
|
||
else Static("", classes="vmprof")
|
||
),
|
||
Select(
|
||
self._type_options(),
|
||
value=vm.get("desktop") or SERVER,
|
||
allow_blank=False,
|
||
id=f"v{i}_type",
|
||
),
|
||
classes="vmrow",
|
||
)
|
||
widgets.append(
|
||
Vertical(
|
||
Static(self._row_head(i, r), id=f"h{i}"),
|
||
row,
|
||
# Pas d'id sur la carte : « remove_children() » est
|
||
# ASYNCHRONE, les anciennes sont encore là au montage
|
||
# et Textual refuse deux frères de même id. Les ids
|
||
# des champs vivent un niveau plus bas, dans un parent
|
||
# neuf — la collision ne les touche pas. On atteint
|
||
# donc la carte par son RANG.
|
||
classes=(
|
||
"vmcard locked" if key in self.locked else "vmcard"
|
||
),
|
||
)
|
||
)
|
||
|
||
# Marque de génération, posée sur CHAQUE widget de rangée.
|
||
# « walk_children() » ne voit RIEN avant le montage : les enfants
|
||
# passés au constructeur attendent dans « _pending_children ».
|
||
def mark(node):
|
||
node._el_gen = self._gen
|
||
for child in getattr(node, "_pending_children", None) or []:
|
||
mark(child)
|
||
|
||
for card in widgets:
|
||
mark(card)
|
||
# Le montage vient APRÈS le marquage, jamais avant : « mount_all »
|
||
# consomme « _pending_children » et le vide. Marquer ensuite était
|
||
# une COURSE — gagnée sur une machine, perdue sur une autre. Perdue,
|
||
# les listes des rangées n'avaient plus de génération, TOUS les
|
||
# changements par VM étaient rejetés en silence, et seuls les
|
||
# réglages globaux semblaient agir.
|
||
if widgets:
|
||
plan.mount_all(widgets)
|
||
self._shown_ids = self._row_ids()
|
||
# Les saisies libres ne se révèlent qu'après le montage : leur
|
||
# style ne peut pas être touché avant qu'elles existent.
|
||
self.call_after_refresh(self._after_mount_rows)
|
||
|
||
def _after_mount_rows(self) -> None:
|
||
self._sync_free_inputs()
|
||
self._syncing = False
|
||
|
||
def _refresh_row_widgets(self) -> None:
|
||
"""Remet les listes de chaque rangée sur ce que la VM vaut MAINTENANT.
|
||
|
||
Sans cela, changer le profil x1..x4 mettait les totaux à jour mais
|
||
laissait les listes sur leurs anciennes valeurs : l'écran affichait
|
||
8192 pendant que la VM valait 4096. Les rangées ne sont remontées
|
||
que si le JEU de VM change — pour ne pas voler le focus — donc ce
|
||
rafraîchissement doit se faire à la main.
|
||
|
||
Aucun risque de boucle : on_select_changed ignore une valeur déjà
|
||
égale à celle du modèle, et le modèle vient précisément d'être
|
||
recalculé."""
|
||
for i, r in enumerate(self.rows):
|
||
vm = r["vm"]
|
||
for field, presets in (
|
||
("vcpus", ctx["cpu_presets"]),
|
||
("ram", ctx["ram_presets"]),
|
||
("disk", ctx["disk_presets"]),
|
||
):
|
||
try:
|
||
sel = self.query_one(f"#v{i}_{field}", Select)
|
||
except Exception:
|
||
continue
|
||
# Une liste posée sur « libre… » ne doit PAS être remise
|
||
# sur une valeur : l'utilisateur vient de la choisir, et
|
||
# tant qu'il n'a rien tapé la VM vaut encore celle du
|
||
# profil — on la lui reprendrait sous les doigts.
|
||
if sel.value is FREE:
|
||
continue
|
||
# Une valeur libre déjà saisie n'est dans aucune liste :
|
||
# liste vide, la saisie à côté porte le nombre.
|
||
sel.value = (
|
||
vm[field] if vm[field] in presets else SELECT_NULL
|
||
)
|
||
for wid, value in (
|
||
(f"#v{i}_type", vm.get("desktop") or SERVER),
|
||
(f"#v{i}_branch", vm.get("branch") or self._branch()),
|
||
(f"#v{i}_prof", self._row_profile_index(i)),
|
||
):
|
||
try:
|
||
self.query_one(wid, Select).value = value
|
||
except Exception:
|
||
pass
|
||
self._sync_free_inputs()
|
||
|
||
def _sync_free_inputs(self) -> None:
|
||
for i, r in enumerate(self.rows):
|
||
vm = r["vm"]
|
||
for field, presets in (
|
||
("vcpus", ctx["cpu_presets"]),
|
||
("ram", ctx["ram_presets"]),
|
||
("disk", ctx["disk_presets"]),
|
||
):
|
||
try:
|
||
widget = self.query_one(f"#c{i}_{field}", Input)
|
||
except Exception:
|
||
continue
|
||
# Visible si la valeur EST libre, ou si la liste est
|
||
# posée sur « libre… » en attente d'une saisie.
|
||
try:
|
||
chosen_free = (
|
||
self.query_one(f"#v{i}_{field}", Select).value
|
||
is FREE
|
||
)
|
||
except Exception:
|
||
chosen_free = False
|
||
free = chosen_free or vm[field] not in presets
|
||
widget.display = free
|
||
widget.disabled = not free
|
||
|
||
def _row_head(self, index, row):
|
||
"""Ligne de titre d'une VM : nom, origine, état, marque de
|
||
personnalisation. Sans elle, deux rangées aux réglages différents
|
||
n'ont aucune explication à l'écran."""
|
||
vm = row["vm"]
|
||
icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[row["state"]]
|
||
state = "" if row["state"] == "new" else f" {icon}{row['note']}"
|
||
if row.get("locked"):
|
||
mark = " 🔒 figée"
|
||
elif row.get("custom"):
|
||
mark = " ✎"
|
||
else:
|
||
mark = ""
|
||
return (
|
||
f"[b]{vm['name']}[/b] {vm['distro']} {vm['version']} "
|
||
f"[{vm['arch']}] {row['disk_gb']}G{state}{mark}"
|
||
)
|
||
|
||
def _render_plan(self):
|
||
# Le JEU de VM a-t-il changé ? Si oui on remonte les widgets, sinon
|
||
# on se contente des titres : remonter à chaque frappe volerait le
|
||
# focus au champ en cours de saisie.
|
||
if self._row_ids() != self._shown_ids:
|
||
self._mount_rows()
|
||
else:
|
||
for i, r in enumerate(self.rows):
|
||
try:
|
||
self.query_one(f"#h{i}", Static).update(
|
||
self._row_head(i, r)
|
||
)
|
||
except Exception:
|
||
pass
|
||
self._refresh_row_widgets()
|
||
if not self.rows:
|
||
# Rien de coché : un total à zéro n'apprend rien, on dit
|
||
# plutôt comment remplir la liste.
|
||
self.query_one("#totals", Static).update(
|
||
f" {t('Tick what to deploy')} — "
|
||
f"{t('F7 main versions · F6 all')}"
|
||
)
|
||
return
|
||
n, cpus, ram, disk = plan_totals(self.rows)
|
||
warn = ""
|
||
if free_ram and ram > free_ram:
|
||
warn = f" ⚠ {t('> host free RAM')}"
|
||
elif cpus > host_cpu:
|
||
warn = f" ⚠ {t('> host cores')} ({host_cpu})"
|
||
dupes = len({vm["name"] for vm in self.vms}) != len(self.vms)
|
||
dup_txt = (
|
||
f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}"
|
||
if dupes
|
||
else ""
|
||
)
|
||
# Une VM DÉJÀ DÉFINIE n'est pas recréée : elle ne consomme rien
|
||
# de neuf, donc plan_totals l'écarte. Mais un total à zéro sans
|
||
# explication se lit comme un bogue — on a cru que les réglages
|
||
# par VM n'avaient aucun effet, alors qu'ils portaient sur une
|
||
# machine qui ne sera pas créée.
|
||
skipped = sum(1 for r in self.rows if r["state"] == "exists")
|
||
skip_txt = (
|
||
f" ({skipped} {t('already defined, not counted')})"
|
||
if skipped
|
||
else ""
|
||
)
|
||
self.query_one("#totals", Static).update(
|
||
f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G"
|
||
f"{skip_txt}{warn}{dup_txt}"
|
||
)
|
||
|
||
# -- réactions aux champs -------------------------------------- #
|
||
def on_radio_set_changed(self, event) -> None:
|
||
if event.radio_set.id == "f_arch":
|
||
self.arch = arches[event.radio_set.pressed_index]
|
||
self._reload_catalog()
|
||
elif event.radio_set.id == "f_type":
|
||
self._clear_overrides(("desktop",))
|
||
# Recalcul : le disque annonce inclut le bureau, et la
|
||
# colonne Statut affiche le type de VM.
|
||
self._recompute()
|
||
elif event.radio_set.id == "f_profile":
|
||
index = event.radio_set.pressed_index
|
||
self.profile = "custom" if index == 4 else str(index + 1)
|
||
# Un multiplicateur x1..x4 ne touche QUE les vCPU et la RAM —
|
||
# apply_profile y laisse le disque du catalogue. Y effacer une
|
||
# taille de disque réglée à la main la faisait disparaître sans
|
||
# rien mettre à la place : on revenait à 20G sans l'avoir
|
||
# demandé. Le disque n'est rendu au commun que par le profil
|
||
# « personnalisé », qui en porte un.
|
||
fields = ("vcpus", "ram")
|
||
if self.profile == "custom":
|
||
fields += ("disk",)
|
||
self._clear_overrides(fields)
|
||
custom = self.profile == "custom"
|
||
for field, (sel, _inp) in RES_FIELDS.items():
|
||
self.query_one(sel, Select).disabled = not custom
|
||
self._show_free(field, custom and self._free.get(field))
|
||
self._recompute()
|
||
|
||
# -- rangées du panneau droit ------------------------------- #
|
||
def _is_current(self, widget):
|
||
"""Le widget appartient-il au jeu de rangées ACTUEL ?"""
|
||
return getattr(widget, "_el_gen", None) == self._gen
|
||
|
||
def _focused_row(self):
|
||
"""Rang de la VM dont un widget a le focus, ou None. C'est la seule
|
||
désignation qui ait un sens ici : il n'y a plus de curseur unique,
|
||
chaque rangée est éditable directement."""
|
||
wid = getattr(self.focused, "id", "") or ""
|
||
match = re.match(r"[vch](\d+)(?:_|$)", wid)
|
||
if not match:
|
||
return None
|
||
index = int(match.group(1))
|
||
return index if 0 <= index < len(self.rows) else None
|
||
|
||
def _row_key(self, index):
|
||
entries = self._plan_entries()
|
||
return entry_key(entries[index]) if index < len(entries) else None
|
||
|
||
def _clear_overrides(self, fields) -> None:
|
||
"""Rend au choix commun les VM NON figées, pour ces champs-là.
|
||
|
||
Le cadenas est la seule chose qui résiste. Une valeur réglée à la
|
||
main sur une rangée cède donc au choix global suivant : c'est ce
|
||
qu'on attend d'un réglage « général », et le verrou existe
|
||
précisément pour dire « pas celle-ci ».
|
||
|
||
Par champ, pas en bloc : changer la RAM générale n'a aucune raison
|
||
d'effacer le disque qu'on a réglé sur une VM.
|
||
|
||
« name » n'y figure jamais : un renommage est explicite et ne
|
||
découle d'aucune valeur générale."""
|
||
changed = False
|
||
for key in list(self.overrides):
|
||
if key in self.locked:
|
||
continue
|
||
for field in fields:
|
||
changed |= self.overrides[key].pop(field, None) is not None
|
||
if field == "install_cmd":
|
||
# Le libelle n'a pas de sens sans sa commande.
|
||
self.overrides[key].pop("install_label", None)
|
||
if not self.overrides[key]:
|
||
self.overrides.pop(key, None)
|
||
if changed:
|
||
# Forcer le remontage : une rangée peut porter une saisie
|
||
# LIBRE, que le simple rafraîchissement laisse en place — on
|
||
# verrait « 12 » à l'écran pendant que la VM vaut 2. Le
|
||
# remontage rebâtit tout depuis le modèle. Sans risque de vol
|
||
# de focus : ce chemin part d'un widget GLOBAL, jamais d'une
|
||
# rangée.
|
||
self._shown_ids = ()
|
||
|
||
def _set_override(self, index, field, value) -> None:
|
||
"""Écrit — ou retire — la surcharge d'UNE VM."""
|
||
key = self._row_key(index)
|
||
if key is None:
|
||
return
|
||
if value in ("", 0, None):
|
||
# Saisie vidée ou invalide : on RETIRE la surcharge au lieu
|
||
# d'écrire un zéro, qui donnerait une VM à 0 vCPU.
|
||
self.overrides.get(key, {}).pop(field, None)
|
||
if not self.overrides.get(key):
|
||
self.overrides.pop(key, None)
|
||
else:
|
||
self.overrides.setdefault(key, {})[field] = value
|
||
|
||
def _row_free(self, index, field, visible) -> None:
|
||
widget = self.query_one(f"#c{index}_{field}", Input)
|
||
widget.display = bool(visible)
|
||
widget.disabled = not visible
|
||
if visible:
|
||
widget.focus()
|
||
|
||
def _read_row_free(self, index, field):
|
||
raw = self.query_one(f"#c{index}_{field}", Input).value.strip()
|
||
if field == "disk":
|
||
return parse_disk(raw) or ""
|
||
if field == "ram":
|
||
return parse_ram(raw)
|
||
return positive_int(raw, 0)
|
||
|
||
def _show_free(self, field, visible) -> None:
|
||
"""Montre ou cache la saisie libre d'une ressource."""
|
||
widget = self.query_one(RES_FIELDS[field][1], Input)
|
||
widget.display = bool(visible)
|
||
widget.disabled = not visible
|
||
|
||
def on_selection_list_selected_changed(self, event) -> None:
|
||
self._recompute()
|
||
|
||
def on_select_changed(self, event) -> None:
|
||
if self._syncing:
|
||
return
|
||
wid = event.select.id or ""
|
||
row = re.match(r"v(\d+)_(vcpus|ram|disk|type|branch|prof)$", wid)
|
||
if row and not self._is_current(event.select):
|
||
# Widget d'une génération périmée : son rang ne désigne plus
|
||
# la même VM. L'appliquer écraserait le réglage d'une voisine.
|
||
return
|
||
if row:
|
||
index, field = int(row.group(1)), row.group(2)
|
||
if index >= len(self.rows):
|
||
return
|
||
# Poser « value= » au montage fait émettre un Changed que
|
||
# Textual délivre APRÈS coup : un verrou temporel ne l'attrape
|
||
# pas — mesuré, les trois champs de chaque VM se retrouvaient
|
||
# surchargés dès l'affichage et le profil x1..x4 devenait
|
||
# inopérant. On compare donc à ce que le modèle dit déjà : une
|
||
# valeur identique n'est pas une saisie, c'est l'écho.
|
||
#
|
||
# Cas limite assumé : choisir explicitement la valeur que le
|
||
# profil donne déjà n'enregistre pas de surcharge. La VM
|
||
# suivra donc le profil s'il change — ce qui est aussi le plus
|
||
# attendu quand on n'a rien changé de visible.
|
||
vm_now = self.rows[index]["vm"]
|
||
if field == "prof":
|
||
label, cmd = profiles[event.value]
|
||
if cmd == (
|
||
vm_now.get("install_cmd") or self._profile_cmd()
|
||
):
|
||
return
|
||
same = cmd == self._profile_cmd()
|
||
self._set_override(
|
||
index, "install_cmd", "" if same else cmd
|
||
)
|
||
self._set_override(
|
||
index, "install_label", "" if same else label
|
||
)
|
||
self._recompute()
|
||
return
|
||
if field == "branch":
|
||
# « la branche du formulaire » n'est pas une surcharge :
|
||
# la VM doit suivre si on la change en haut.
|
||
current = vm_now.get("branch") or self._branch()
|
||
if event.value == current:
|
||
return
|
||
self._set_override(
|
||
index,
|
||
"branch",
|
||
"" if event.value == self._branch() else event.value,
|
||
)
|
||
self._recompute()
|
||
return
|
||
if field == "type":
|
||
new_desk = "" if event.value == SERVER else event.value
|
||
if new_desk == (vm_now.get("desktop") or ""):
|
||
return
|
||
elif (
|
||
event.value is not FREE and event.value is not SELECT_NULL
|
||
):
|
||
if event.value == vm_now.get(field):
|
||
return
|
||
if field == "type":
|
||
self._set_override(
|
||
index,
|
||
"desktop",
|
||
"" if event.value == SERVER else event.value,
|
||
)
|
||
# « Serveur » est un choix légitime, pas un retrait : on le
|
||
# note explicitement pour qu'il tienne face au défaut.
|
||
if event.value == SERVER:
|
||
key = self._row_key(index)
|
||
if key is not None:
|
||
self.overrides.setdefault(key, {})["desktop"] = ""
|
||
elif event.value is FREE:
|
||
self._row_free(index, field, True)
|
||
self._set_override(
|
||
index, field, self._read_row_free(index, field)
|
||
)
|
||
elif event.value is not SELECT_NULL:
|
||
self._row_free(index, field, False)
|
||
self._set_override(index, field, event.value)
|
||
self._recompute()
|
||
return
|
||
# Les choix GLOBAUX de branche et de profil ne portent aucune
|
||
# valeur de ressource : ils tombaient donc dans le « return »
|
||
# ci-dessous sans rien recalculer, et les rangées restaient sur
|
||
# l'ancienne version. Elles n'en gardent pas de copie — « » y
|
||
# veut dire « celle du formulaire » — il suffit de redessiner.
|
||
if event.select.id == "f_tz_sel":
|
||
# « libre… » révèle la saisie ; un fuseau choisi la referme et
|
||
# y recopie le nom, seule valeur que lisent _form_values et la
|
||
# spec — un seul endroit porte la réponse.
|
||
free = event.value is FREE
|
||
field = self.query_one("#f_tz", Input)
|
||
field.display = free
|
||
field.disabled = not free
|
||
if free:
|
||
field.focus()
|
||
elif isinstance(event.value, str):
|
||
field.value = event.value
|
||
return
|
||
if event.select.id in ("f_branch", "f_profile_install"):
|
||
self._clear_overrides(
|
||
("branch",)
|
||
if event.select.id == "f_branch"
|
||
else ("install_cmd",)
|
||
)
|
||
self._recompute()
|
||
return
|
||
field = SELECT_TO_FIELD.get(event.select.id)
|
||
if not field:
|
||
return
|
||
if event.value is FREE:
|
||
# La valeur retenue est celle de la saisie, pas ce choix-ci.
|
||
self._free[field] = True
|
||
self._show_free(field, True)
|
||
self.query_one(RES_FIELDS[field][1], Input).focus()
|
||
self._apply_free(field)
|
||
elif event.value is not SELECT_NULL:
|
||
self._free[field] = False
|
||
self._show_free(field, False)
|
||
self.custom[field] = event.value
|
||
self._clear_overrides((field,))
|
||
self._recompute()
|
||
|
||
def _apply_free(self, field) -> None:
|
||
"""Relit la saisie libre. Une valeur invalide n'écrase rien : le
|
||
profil retombe alors sur celle du catalogue."""
|
||
raw = self.query_one(RES_FIELDS[field][1], Input).value.strip()
|
||
if field == "disk":
|
||
self.custom[field] = parse_disk(raw) or ""
|
||
elif field == "ram":
|
||
self.custom[field] = parse_ram(raw)
|
||
else:
|
||
self.custom[field] = positive_int(raw, 0)
|
||
self._clear_overrides((field,))
|
||
|
||
def on_input_changed(self, event) -> None:
|
||
if self._syncing:
|
||
return
|
||
wid = event.input.id or ""
|
||
row = re.match(r"c(\d+)_(vcpus|ram|disk)$", wid)
|
||
if row and not self._is_current(event.input):
|
||
return
|
||
if row:
|
||
index, field = int(row.group(1)), row.group(2)
|
||
self._set_override(
|
||
index, field, self._read_row_free(index, field)
|
||
)
|
||
self._recompute()
|
||
return
|
||
field = INPUT_TO_FIELD.get(event.input.id)
|
||
if field:
|
||
self._apply_free(field)
|
||
self._recompute()
|
||
|
||
def _set_lock(self, index, on) -> None:
|
||
"""Fige — ou libère — les ressources d'une VM.
|
||
|
||
Figer, c'est recopier les valeurs EFFECTIVES du moment dans les
|
||
surcharges : le profil commun ne les atteint plus. Libérer les
|
||
retire, et la VM retombe sous le profil. Le mécanisme est celui
|
||
des surcharges, déjà éprouvé ; le verrou n'en est que la commande
|
||
explicite, et il couvre les quatre champs d'un coup."""
|
||
key = self._row_key(index)
|
||
if key is None or index >= len(self.rows):
|
||
return
|
||
if on:
|
||
vm = self.rows[index]["vm"]
|
||
self.locked.add(key)
|
||
# TOUT ce que la VM tient d'un choix commun est recopié, pas
|
||
# seulement les ressources : la branche et le profil Odoo en
|
||
# font partie. Les oublier laissait une VM « figée » changer
|
||
# de version d'ERPLibre dès qu'on touchait au choix générique,
|
||
# ce qui vide le mot de son sens.
|
||
#
|
||
# Les deux se résolvent AVANT d'être figés : « » y signifie
|
||
# « celle du formulaire », et geler une chaîne vide ne
|
||
# gèlerait rien du tout.
|
||
self.overrides[key] = {
|
||
"vcpus": vm["vcpus"],
|
||
"ram": vm["ram"],
|
||
"disk": vm["disk"],
|
||
"desktop": vm.get("desktop") or "",
|
||
"branch": vm.get("branch") or self._branch(),
|
||
"install_cmd": (
|
||
vm.get("install_cmd") or self._profile_cmd()
|
||
),
|
||
"install_label": (
|
||
vm.get("install_label")
|
||
or (
|
||
profiles[self._row_profile_index(index)][0]
|
||
if profiles
|
||
else ""
|
||
)
|
||
),
|
||
}
|
||
else:
|
||
self.locked.discard(key)
|
||
self.overrides.pop(key, None)
|
||
self._recompute()
|
||
# La couleur de la ligne suit le verrou sans tout remonter : un
|
||
# remontage volerait le focus à la case qu'on vient de cocher.
|
||
cards = self.query_one("#plan", VerticalScroll).children
|
||
if index < len(cards):
|
||
cards[index].set_class(on, "locked")
|
||
btn = self.query_one(f"#l{index}", Button)
|
||
btn.label = "🔒" if on else "🔓"
|
||
btn.variant = "success" if on else "default"
|
||
|
||
def _add_copy(self, index, delta) -> None:
|
||
"""Ajoute ou retire un exemplaire de l'entrée visée.
|
||
|
||
Retirer enlève le DERNIER exemplaire, et avec lui ses réglages :
|
||
les garder ferait resurgir d'anciennes valeurs à la copie
|
||
suivante, sans que rien ne l'explique."""
|
||
entries = self._plan_entries()
|
||
if index >= len(entries):
|
||
return
|
||
item = entries[index]
|
||
base = (item["distro"], item["version"], item["arch"])
|
||
count = self.copies.get(base, 0)
|
||
if delta > 0:
|
||
self.copies[base] = count + 1
|
||
else:
|
||
if count <= 0:
|
||
return
|
||
gone = (*base, count)
|
||
self.overrides.pop(gone, None)
|
||
self.locked.discard(gone)
|
||
self.copies[base] = count - 1
|
||
if not self.copies[base]:
|
||
self.copies.pop(base, None)
|
||
self._recompute()
|
||
# Le JEU de VM a changé : les rangées doivent être rebâties.
|
||
self._mount_rows()
|
||
|
||
def on_button_pressed(self, event) -> None:
|
||
match = re.match(r"([pm])(\d+)$", event.button.id or "")
|
||
if match:
|
||
self._add_copy(
|
||
int(match.group(2)), 1 if match.group(1) == "p" else -1
|
||
)
|
||
return
|
||
match = re.match(r"r(\d+)$", event.button.id or "")
|
||
if match:
|
||
self._rename(int(match.group(1)))
|
||
return
|
||
match = re.match(r"l(\d+)$", event.button.id or "")
|
||
if match:
|
||
index = int(match.group(1))
|
||
key = self._row_key(index)
|
||
if key is not None:
|
||
self._set_lock(index, key not in self.locked)
|
||
|
||
def _rename(self, index) -> None:
|
||
"""Renomme une VM. Le nom saisi devient une surcharge comme les
|
||
autres : il survit au recalcul, et F4 le retire avec le reste."""
|
||
key = self._row_key(index)
|
||
if key is None or index >= len(self.rows):
|
||
return
|
||
entries = self._plan_entries()
|
||
auto = vm_name(
|
||
entries[index]["name"],
|
||
self.rows[index]["vm"].get("desktop"),
|
||
desktop_suffixes,
|
||
)
|
||
|
||
def done(value):
|
||
if value is None:
|
||
return
|
||
if not str(value).strip():
|
||
self.overrides.get(key, {}).pop("name", None)
|
||
if not self.overrides.get(key):
|
||
self.overrides.pop(key, None)
|
||
else:
|
||
clean = clean_hostname(value)
|
||
if not clean:
|
||
self.notify(
|
||
t("Invalid name: letters, digits, hyphens."),
|
||
severity="error",
|
||
)
|
||
return
|
||
self.overrides.setdefault(key, {})["name"] = clean
|
||
self._recompute()
|
||
self._mount_rows()
|
||
|
||
self.push_screen(
|
||
RenameScreen(self.rows[index]["vm"]["name"], auto), done
|
||
)
|
||
|
||
def on_checkbox_changed(self, event) -> None:
|
||
if event.checkbox.id == "f_install":
|
||
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
|
||
elif event.checkbox.id == "f_par_all":
|
||
self.query_one("#f_par", Select).disabled = event.value
|
||
elif str(event.checkbox.id or "").startswith("f_tool_"):
|
||
# Un IDE de plus, c'est un disque plus grand : le plan doit le
|
||
# montrer AVANT de déployer, pas après une heure d'installation.
|
||
self._recompute()
|
||
|
||
# -- actions ---------------------------------------------------- #
|
||
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_select_main(self) -> None:
|
||
"""Une VM par distro : la version marquée par défaut."""
|
||
widget = self.query_one("#f_catalog", SelectionList)
|
||
widget.deselect_all()
|
||
for i, e in enumerate(self._entries()):
|
||
if e.get("default"):
|
||
widget.select(i)
|
||
|
||
def action_clear_vm(self) -> None:
|
||
"""Rend au profil commun la VM dont un widget a le focus. Sans
|
||
cette sortie, un réglage posé par erreur ne se défaisait qu'en
|
||
rouvrant le formulaire."""
|
||
index = self._focused_row()
|
||
if index is None:
|
||
return
|
||
key = self._row_key(index)
|
||
if key is None or key not in self.overrides:
|
||
return
|
||
self.overrides.pop(key)
|
||
# Les widgets de la rangée portent encore l'ancienne valeur : on
|
||
# les remonte pour qu'ils disent la vérité.
|
||
self._recompute()
|
||
self._mount_rows()
|
||
|
||
def _form_values(self):
|
||
install = None
|
||
if self.query_one("#f_install", Checkbox).value and profiles:
|
||
index = self.query_one("#f_profile_install", Select).value
|
||
label, cmd = profiles[index if isinstance(index, int) else 0]
|
||
install = {
|
||
"branch": self.query_one("#f_branch", Select).value,
|
||
"prod": self.query_one("#f_prod", Checkbox).value,
|
||
"label": label,
|
||
"cmd": cmd,
|
||
"monitor": self.query_one("#f_monitor", Checkbox).value,
|
||
}
|
||
key = self.query_one("#f_key", Input).value.strip()
|
||
return {
|
||
"res_label": (
|
||
t("custom")
|
||
if self.profile == "custom"
|
||
else f"x{self.profile}"
|
||
),
|
||
"ssh_key": os.path.expanduser(key) if key else "",
|
||
# Un champ vidé retombe sur le fuseau de l'hôte plutôt que sur
|
||
# rien : sans valeur, la VM démarrerait en UTC.
|
||
"timezone": self.query_one("#f_tz", Input).value.strip()
|
||
or ctx.get("timezone")
|
||
or "",
|
||
"desktop": self._default_desktop(),
|
||
"vm_tools": self._vm_tools(),
|
||
"python_provider": self._python_provider(),
|
||
"app_store": self._app_store(),
|
||
"install": install,
|
||
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
|
||
# Une exécution par installation : le nombre de VM retenues
|
||
# fait foi. Le déploiement le borne ensuite à ce même nombre,
|
||
# donc une valeur haute ne crée jamais de travailleur inutile.
|
||
"parallelism": (
|
||
max(1, len(self.vms))
|
||
if self.query_one("#f_par_all", Checkbox).value
|
||
else self.query_one("#f_par", Select).value
|
||
),
|
||
}
|
||
|
||
def action_preview(self) -> None:
|
||
spec = build_spec(self.vms, domains, self._form_values())
|
||
build = ctx.get("build_command")
|
||
if not build:
|
||
return
|
||
lines = [build(vm, spec, True) for vm in spec["vms"]]
|
||
self.push_screen(PreviewScreen(lines or [t("Nothing selected.")]))
|
||
|
||
def action_dump_state(self) -> None:
|
||
"""Écrit l'état COMPLET dans un fichier, widgets ET modèle.
|
||
|
||
Une capture d'écran ne dit pas si l'écart vient de ce qu'on voit
|
||
ou de ce qui sera déployé. Ce vidage met les deux côte à côte, VM
|
||
par VM : si la liste affiche 16384 et que le modèle dit 1024, le
|
||
défaut est dans la prise en compte ; s'ils s'accordent et que la
|
||
VM déployée diffère, il est en aval."""
|
||
path = os.path.expanduser("~/.erplibre/deploy-form-dump.txt")
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
out = [
|
||
"=== formulaire de deploiement ===",
|
||
f"profil={self.profile} custom={self.custom}",
|
||
f"verrous={sorted(self.locked)}",
|
||
f"surcharges={self.overrides}",
|
||
f"generation={self._gen} jeu_monte={self._shown_ids}",
|
||
f"branche_globale={self._branch()}",
|
||
f"profil_odoo_global={self._profile_cmd()}",
|
||
"",
|
||
" # widget -> modele, VM par VM",
|
||
]
|
||
for i, r in enumerate(self.rows):
|
||
vm = r["vm"]
|
||
shown = {}
|
||
for f in ("vcpus", "ram", "disk", "type", "branch", "prof"):
|
||
try:
|
||
shown[f] = self.query_one(f"#v{i}_{f}", Select).value
|
||
except Exception:
|
||
shown[f] = "-"
|
||
try:
|
||
free = self.query_one(f"#c{i}_{f}", Input)
|
||
if free.display:
|
||
shown[f] = f"libre:{free.value!r}"
|
||
except Exception:
|
||
pass
|
||
out.append(f" [{i}] {vm['name']} etat={r['state']}")
|
||
out.append(f" widgets = {shown}")
|
||
out.append(
|
||
f" modele = vcpus={vm['vcpus']} ram={vm['ram']} "
|
||
f"disk={vm['disk']} desktop={vm.get('desktop')!r} "
|
||
f"branch={vm.get('branch')!r} cmd={vm.get('install_cmd')!r}"
|
||
)
|
||
spec = build_spec(self.vms, domains, self._form_values())
|
||
out.append("")
|
||
out.append(" # spec qui partirait au deploiement")
|
||
for vm in spec["vms"]:
|
||
out.append(
|
||
f" {vm['name']}: ram={vm['ram']} vcpus={vm['vcpus']} "
|
||
f"disk={vm['disk']}"
|
||
)
|
||
out.append(f" ignorees (existent deja) = {spec['existing']}")
|
||
with open(path, "w", encoding="utf-8") as fh:
|
||
fh.write("\n".join(out) + "\n")
|
||
self.notify(f"{t('State written to')} {path}")
|
||
|
||
def action_deploy(self) -> None:
|
||
if not self.vms:
|
||
self.notify(t("Nothing selected."), severity="warning")
|
||
return
|
||
spec = build_spec(self.vms, domains, self._form_values())
|
||
if not spec["vms"]:
|
||
self.notify(
|
||
t("Nothing to create - every VM already exists."),
|
||
severity="warning",
|
||
)
|
||
return
|
||
orphans = [r for r in self.rows if r["state"] == "orphan"]
|
||
if orphans and not getattr(self, "_orphan_ack", False):
|
||
# Un qcow2 orphelin fait échouer deploy_qemu : on prévient une
|
||
# première fois, F5 à nouveau vaut confirmation.
|
||
self._orphan_ack = True
|
||
self.notify(
|
||
t("orphan disk - will FAIL")
|
||
+ f" ({len(orphans)}) — "
|
||
+ t("press F5 again to confirm"),
|
||
severity="error",
|
||
timeout=10,
|
||
)
|
||
return
|
||
result["spec"] = spec
|
||
self.exit()
|
||
|
||
def action_cancel(self) -> None:
|
||
self.exit()
|
||
|
||
app = DeployForm()
|
||
# Exposé pour les tests headless (run_app=False), qui pilotent l'app
|
||
# eux-mêmes et ont besoin de lire la spec produite.
|
||
app._result = result
|
||
if not run_app:
|
||
return app
|
||
app.run()
|
||
return result["spec"]
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Vue de progression : un bloc repliable par VM
|
||
# --------------------------------------------------------------------------- #
|
||
def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||
"""Déploie `jobs` = [(id, nom, argv)] en parallèle, un bloc repliable par
|
||
VM. Renvoie [(nom, rc, sortie, durée)]. `run_app=False` renvoie l'app.
|
||
|
||
Un bloc reste DÉPLIÉ tant que la VM tourne, se replie dès qu'elle réussit
|
||
— et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire."""
|
||
import subprocess
|
||
import threading
|
||
|
||
from textual.app import App, ComposeResult
|
||
from textual.containers import Vertical, VerticalScroll
|
||
from textual.widgets import (
|
||
Button,
|
||
Collapsible,
|
||
Footer,
|
||
Header,
|
||
RichLog,
|
||
Static,
|
||
)
|
||
|
||
results = []
|
||
|
||
def slug(name):
|
||
"""Identifiant de widget : Textual n'accepte ni point ni tiret en
|
||
tête, et les noms de VM en contiennent."""
|
||
return "vm_" + "".join(c if c.isalnum() else "_" for c in name)
|
||
|
||
class Progress(App):
|
||
CSS = """
|
||
#blocks { height: 1fr; }
|
||
RichLog { height: 14; border: solid $panel; }
|
||
#summary { height: auto; color: $accent; padding: 0 1; }
|
||
#hint { height: auto; color: $text-muted; padding: 0 1; }
|
||
"""
|
||
BINDINGS = [
|
||
("c", "copy_current", t("Copy log")),
|
||
("C", "copy_all", t("Copy all logs")),
|
||
("q", "quit", t("Quit")),
|
||
]
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self._out = {name: "" for _jid, name, _p in jobs}
|
||
self._done = 0
|
||
self._t0 = time.time()
|
||
self._slots = threading.Semaphore(max(1, parallelism))
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Header()
|
||
with VerticalScroll(id="blocks"):
|
||
for jid, name, _parts in jobs:
|
||
with Collapsible(
|
||
title=f"⏳ [{jid}] {name}",
|
||
collapsed=False,
|
||
id=slug(name),
|
||
):
|
||
yield RichLog(
|
||
id=f"log_{slug(name)}",
|
||
highlight=False,
|
||
markup=False,
|
||
wrap=True,
|
||
)
|
||
with Vertical():
|
||
yield Static("", id="summary")
|
||
yield Static(
|
||
f" {t('c copy log · C copy all · q quit')}", id="hint"
|
||
)
|
||
yield Button(t("Copy all logs"), id="copyall")
|
||
yield Footer()
|
||
|
||
def on_mount(self) -> None:
|
||
self.title = t("Deploying")
|
||
self._refresh_summary()
|
||
for jid, name, parts in jobs:
|
||
self.run_job(jid, name, parts)
|
||
|
||
def _refresh_summary(self):
|
||
self.query_one("#summary", Static).update(
|
||
f" {self._done}/{len(jobs)} — "
|
||
f"{fmt_dur(time.time() - self._t0)}"
|
||
)
|
||
|
||
# `thread=True` : subprocess.run est bloquant ; le faire dans un
|
||
# thread garde la boucle d'événements Textual fluide. Le sémaphore
|
||
# borne les déploiements SIMULTANÉS — sans lui, demander « 4 en
|
||
# parallèle » en lancerait autant que de VM.
|
||
def run_job(self, jid, name, parts):
|
||
def _job() -> None:
|
||
with self._slots:
|
||
t0 = time.time()
|
||
try:
|
||
res = subprocess.run(
|
||
parts, capture_output=True, text=True
|
||
)
|
||
rc = res.returncode
|
||
out = (res.stdout or "") + (res.stderr or "")
|
||
except (OSError, subprocess.SubprocessError) as exc:
|
||
rc, out = 1, str(exc)
|
||
self.call_from_thread(
|
||
self._finish, jid, name, rc, out, time.time() - t0
|
||
)
|
||
|
||
self.run_worker(_job, thread=True, group="deploy", exclusive=False)
|
||
|
||
def _finish(self, jid, name, rc, out, secs):
|
||
self._out[name] = out
|
||
results.append((name, rc, out, secs))
|
||
self._done += 1
|
||
log = self.query_one(f"#log_{slug(name)}", RichLog)
|
||
for line in out.strip().splitlines():
|
||
log.write(line)
|
||
block = self.query_one(f"#{slug(name)}", Collapsible)
|
||
mark = "✅" if rc == 0 else "❌"
|
||
block.title = f"{mark} [{jid}] {name} · {fmt_dur(secs)}" + (
|
||
"" if rc == 0 else f" · rc={rc}"
|
||
)
|
||
# Un succès se replie (il n'y a plus rien à y lire) ; un échec
|
||
# reste ouvert.
|
||
block.collapsed = rc == 0
|
||
self._refresh_summary()
|
||
|
||
# -- presse-papiers (OSC 52 : traverse SSH) --------------------- #
|
||
def _copy(self, text, what):
|
||
payload, cut = clip_payload(text)
|
||
if not payload.strip():
|
||
self.notify(t("Nothing to copy."), severity="warning")
|
||
return
|
||
self.copy_to_clipboard(payload)
|
||
note = f"{what} — {len(payload)} {t('chars')}"
|
||
if cut:
|
||
note += f" ({t('tail only, log was truncated')})"
|
||
self.notify(
|
||
note + "\n" + t("Needs an OSC 52 capable terminal."),
|
||
title=t("Clipboard"),
|
||
timeout=8,
|
||
)
|
||
|
||
def action_copy_current(self) -> None:
|
||
focused = self.focused
|
||
for _jid, name, _p in jobs:
|
||
node = focused
|
||
while node is not None:
|
||
if getattr(node, "id", None) == slug(name):
|
||
self._copy(self._out[name], name)
|
||
return
|
||
node = node.parent
|
||
self.action_copy_all()
|
||
|
||
def action_copy_all(self) -> None:
|
||
blob = "\n".join(
|
||
f"───── {name} ─────\n{self._out[name]}"
|
||
for _jid, name, _p in jobs
|
||
)
|
||
self._copy(blob, t("all logs"))
|
||
|
||
def on_button_pressed(self, event) -> None:
|
||
if event.button.id == "copyall":
|
||
self.action_copy_all()
|
||
|
||
app = Progress()
|
||
app._results = results # lecture par les tests headless
|
||
if not run_app:
|
||
return app
|
||
app.run()
|
||
return results
|