Merge branch 'refactor_todo_qemu'
Refactor todo qemu files
This commit is contained in:
commit
0d480af24a
12 changed files with 10316 additions and 9567 deletions
683
script/todo/deploy_form_lib.py
Normal file
683
script/todo/deploy_form_lib.py
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Socle commun des formulaires de déploiement (QEMU/KVM et Proxmox VE).
|
||||
|
||||
Ce fichier existe pour une raison simple : les deux formulaires posent les
|
||||
mêmes questions de fond — combien de processeurs, combien de mémoire, quel
|
||||
disque, quelles machines, et montre-moi le plan avant de lancer. Seul CE QUI
|
||||
VARIE reste dans les fichiers de formulaire : le catalogue et les options
|
||||
propres à chaque hyperviseur.
|
||||
|
||||
Trois couches, du plus sûr au plus fragile :
|
||||
|
||||
1. La LOGIQUE PURE (lecture d'une taille, d'un entier, calcul du plan et des
|
||||
totaux, assemblage de la spec). Aucune dépendance à Textual, donc
|
||||
vérifiable sans terminal — c'est là que vivent les pièges déjà payés :
|
||||
« 128G » qui rendait 0, une spec qui perdait une clé.
|
||||
2. Le SOCLE VISUEL : les règles CSS que les deux formulaires partagent, et les
|
||||
fabriques des sélecteurs « préréglages + saisie libre ».
|
||||
3. La VUE DE PROGRESSION (`run_deploy_progress`), déjà générique : elle prend
|
||||
des travaux « (id, nom, argv) » et rend « (nom, code, sortie, durée) ».
|
||||
Elle ne sait rien de libvirt ni de qm, et sert donc les deux tels quels.
|
||||
|
||||
Compatibilité : `qemu_deploy_form` réexporte ce module, si bien que les
|
||||
appelants historiques (`from script.todo.qemu_deploy_form import parse_ram`)
|
||||
continuent de fonctionner.
|
||||
"""
|
||||
|
||||
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 libvirt_orphan(name) -> bool:
|
||||
"""Un disque qcow2 sans domaine, sur CETTE machine.
|
||||
|
||||
Propre à QEMU/KVM : un Proxmox distant n'a pas ce répertoire, et ses
|
||||
disques vivent dans un stockage que seul l'hôte connaît.
|
||||
"""
|
||||
return os.path.exists(f"/var/lib/libvirt/images/{name}.qcow2")
|
||||
|
||||
|
||||
def vm_status(name, domains, orphelin=None):
|
||||
"""É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.
|
||||
|
||||
`orphelin` dit comment reconnaître un disque resté seul. Le défaut regarde
|
||||
le répertoire de libvirt ; un hyperviseur distant passe le sien, ou
|
||||
`lambda _n: False` s'il n'a pas de disque orphelin à craindre."""
|
||||
if name in domains:
|
||||
return "exists", t("exists - skipped")
|
||||
if (orphelin or libvirt_orphan)(name):
|
||||
return "orphan", t("orphan disk - will FAIL")
|
||||
return "new", ""
|
||||
|
||||
|
||||
def plan_rows(vms, domains, extra_disk_gb=0, orphelin=None):
|
||||
"""Lignes du tableau du plan : une par VM, avec son état."""
|
||||
rows = []
|
||||
for vm in vms:
|
||||
state, note = vm_status(vm["name"], domains, orphelin)
|
||||
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"],
|
||||
# Au NIVEAU DU DÉPLOIEMENT, pas de l'installation : une VM sans
|
||||
# ERPLibre se suit aussi (cloud-init, puis relevé système). Absent de
|
||||
# cette assemblée, le choix du formulaire n'atteignait jamais la spec.
|
||||
"monitor": form.get("monitor", True),
|
||||
"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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Socle visuel — ce que les deux formulaires partagent
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sentinelle du dernier choix d'une liste de ressources : il ne porte pas de
|
||||
# valeur, il révèle la saisie libre placée juste dessous. Partagée pour que les
|
||||
# deux formulaires se comportent pareil devant « autre… ».
|
||||
FREE = "__free__"
|
||||
|
||||
# Règles communes : la mise en page (panneau de champs à gauche, plan à droite,
|
||||
# totaux dessous) et les fenêtres modales. Chaque formulaire y ajoute ses
|
||||
# propres règles — celles qui nomment SES widgets.
|
||||
#
|
||||
# Extrait du formulaire QEMU, où elles étaient mêlées à ses spécificités. Le
|
||||
# rendu du formulaire QEMU a été comparé caractère par caractère avant/après
|
||||
# l'extraction : identique.
|
||||
CSS_BASE = """
|
||||
#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; }
|
||||
.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; }
|
||||
.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; }
|
||||
#reslabel { color: $text-muted; }
|
||||
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; }
|
||||
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; }
|
||||
"""
|
||||
|
||||
|
||||
def res_choices(presets, fmt=None):
|
||||
"""[(libellé, valeur)] d'une liste de préréglages, « autre… » en dernier.
|
||||
|
||||
Le dernier choix est la sentinelle FREE : c'est lui qui révèle la saisie
|
||||
libre. Les deux formulaires l'utilisent, donc « autre… » se présente et se
|
||||
comporte pareil partout.
|
||||
"""
|
||||
faire = fmt or (lambda v: str(v))
|
||||
return [(faire(v), str(v)) for v in presets] + [(t("other…"), FREE)]
|
||||
|
||||
|
||||
def res_value(choix, libre, defaut, lecteur=None):
|
||||
"""Valeur retenue d'un couple (liste, saisie libre).
|
||||
|
||||
« autre… » sans rien taper ne veut pas dire zéro : il veut dire « laisse
|
||||
comme avant ». Ce repli est la raison d'être de cette fonction — sans lui,
|
||||
valider un formulaire à peine ouvert rétrécissait les machines.
|
||||
"""
|
||||
lire = lecteur or (lambda v: positive_int(v, 0))
|
||||
if choix == FREE or choix is None:
|
||||
return lire(libre) or defaut
|
||||
return lire(choix) or defaut
|
||||
|
||||
|
||||
# Les trois ressources que TOUT déploiement demande, et les deux widgets par
|
||||
# lesquels chacune se règle : une liste de préréglages (« #f_ram ») et la
|
||||
# saisie libre qu'elle révèle (« #c_ram »). Une seule table, pour que le
|
||||
# lecteur d'un formulaire trouve les champs de l'autre au même endroit.
|
||||
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()}
|
||||
|
||||
# La mémoire s'affiche en Go alors qu'elle se compte en Mo : c'est la seule
|
||||
# ressource dont la valeur ne se lit pas telle quelle.
|
||||
RES_FMT = {
|
||||
"vcpus": lambda v: str(v),
|
||||
"ram": lambda v: f"{v // 1024}G",
|
||||
"disk": lambda v: str(v),
|
||||
}
|
||||
|
||||
|
||||
def res_labels():
|
||||
"""Libellés des trois ressources, traduits à l'APPEL.
|
||||
|
||||
Pas une constante de module : la langue est choisie après l'import, et une
|
||||
table figée à l'import resterait en anglais.
|
||||
"""
|
||||
return {
|
||||
"vcpus": t("vCPU"),
|
||||
"ram": t("RAM: 2048 or 8G"),
|
||||
"disk": t("Disk"),
|
||||
}
|
||||
|
||||
|
||||
def res_row_widgets(index, vm, presets, labels=None, null=None):
|
||||
"""Les six widgets du triplet vCPU / RAM / disque d'une rangée de plan.
|
||||
|
||||
Chaque ressource se présente pareil : une liste de préréglages, plus une
|
||||
saisie libre masquée que « valeur libre… » révèle. Les deux formulaires
|
||||
appellent cette fabrique, donc les ids se correspondent d'un formulaire à
|
||||
l'autre — « v3_ram » pour la liste de la quatrième rangée, « c3_ram » pour
|
||||
sa saisie libre — et le code qui relit les surcharges n'a pas à savoir
|
||||
quel formulaire a monté la rangée.
|
||||
|
||||
`presets` donne les choix par champ ; `labels` permet à l'appelant de
|
||||
garder ses propres mots. `null` est la sentinelle « rien de sélectionné »
|
||||
de Textual, que l'appelant a déjà résolue selon sa version.
|
||||
"""
|
||||
from textual.widgets import Input, Select
|
||||
|
||||
if null is None: # pragma: no cover - dépend de la version de Textual
|
||||
null = getattr(Select, "NULL", Select.BLANK)
|
||||
mots = res_labels()
|
||||
mots.update(labels or {})
|
||||
widgets = []
|
||||
for champ in ("vcpus", "ram", "disk"):
|
||||
choix = presets[champ]
|
||||
valeur = vm[champ]
|
||||
# « valeur dans les préréglages » décide de TOUT : la liste montre la
|
||||
# valeur, ou elle se met en retrait et la saisie libre la porte.
|
||||
connue = valeur in choix
|
||||
widgets.append(
|
||||
Select(
|
||||
[(RES_FMT[champ](c), c) for c in choix]
|
||||
+ [(t("free value…"), FREE)],
|
||||
value=valeur if connue else null,
|
||||
prompt=mots[champ],
|
||||
id=f"v{index}_{champ}",
|
||||
)
|
||||
)
|
||||
widgets.append(
|
||||
Input(
|
||||
value="" if connue else str(valeur),
|
||||
placeholder=mots[champ],
|
||||
id=f"c{index}_{champ}",
|
||||
classes="freeval",
|
||||
)
|
||||
)
|
||||
return widgets
|
||||
|
||||
|
||||
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
|
||||
420
script/todo/deploy_form_plan.py
Normal file
420
script/todo/deploy_form_plan.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Le PLAN d'un déploiement : son état, ses gestes, ses deux fenêtres.
|
||||
|
||||
Un plan de déploiement se manipule pareil qu'on visse sur libvirt ou sur
|
||||
Proxmox : on choisit des entrées du catalogue, on en demande plusieurs
|
||||
exemplaires, on renomme une machine, on règle une ressource sur une seule
|
||||
ligne, on fige celle qui doit échapper au réglage commun. Cette mécanique
|
||||
— surcharges, verrous, exemplaires, saisies libres — n'a rien d'un
|
||||
hyperviseur ; elle est ici, une seule fois, et les deux formulaires en
|
||||
héritent.
|
||||
|
||||
Ce que le formulaire hôte doit fournir (le contrat, court exprès) :
|
||||
|
||||
* attributs `rows`, `vms`, `copies`, `overrides`, `locked`, `custom`,
|
||||
`profile`, `_gen`, `_shown_ids` ;
|
||||
* méthodes `_selected_entries()`, `_recompute()`, `_mount_rows()`,
|
||||
`_render_plan()` ;
|
||||
* crochets `_presets()` (choix par ressource), `_auto_name(index)` (le nom
|
||||
que la VM reprendrait si on effaçait le sien) et `_lock_fields(index)` (ce
|
||||
qu'un verrou recopie, ressources comprises).
|
||||
|
||||
Rien d'autre : le mixin ne connaît ni bureau, ni branche, ni stockage.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from script.todo.deploy_form_lib import (
|
||||
FREE,
|
||||
RES_FIELDS,
|
||||
clean_hostname,
|
||||
entry_key,
|
||||
expand_copies,
|
||||
parse_disk,
|
||||
parse_ram,
|
||||
positive_int,
|
||||
t,
|
||||
)
|
||||
|
||||
# Champs qui ne veulent rien dire l'un sans l'autre : retirer la commande
|
||||
# d'installation doit emporter son libellé, sinon le plan afficherait
|
||||
# « Odoo 18 » à côté d'une VM qui n'installe plus rien.
|
||||
COMPANIONS = {"install_cmd": ("install_label",)}
|
||||
|
||||
# Les écrans sont construits à l'APPEL, pas à l'import : ce module se lit sans
|
||||
# Textual installé (le CLI TODO l'importe pour ses fonctions pures).
|
||||
_ECRANS = {}
|
||||
|
||||
|
||||
def _build_screens():
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Input, Static
|
||||
|
||||
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()
|
||||
|
||||
return {"rename": RenameScreen, "preview": PreviewScreen}
|
||||
|
||||
|
||||
def rename_screen():
|
||||
"""La fenêtre de renommage, construite au premier appel."""
|
||||
if not _ECRANS:
|
||||
_ECRANS.update(_build_screens())
|
||||
return _ECRANS["rename"]
|
||||
|
||||
|
||||
def preview_screen():
|
||||
"""La fenêtre d'aperçu, construite au premier appel."""
|
||||
if not _ECRANS:
|
||||
_ECRANS.update(_build_screens())
|
||||
return _ECRANS["preview"]
|
||||
|
||||
|
||||
class PlanMixin:
|
||||
"""Les gestes du plan, communs à tous les formulaires de déploiement."""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Le jeu de VM affiché
|
||||
# ---------------------------------------------------------------- #
|
||||
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 _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 _row_key(self, index):
|
||||
entries = self._plan_entries()
|
||||
return entry_key(entries[index]) if index < len(entries) else None
|
||||
|
||||
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_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 _row_echo(self, index, field, value) -> bool:
|
||||
"""Cette valeur est-elle l'ÉCHO du montage plutôt qu'une saisie ?
|
||||
|
||||
Poser « value= » sur un Select 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 réglage commun devenait inopérant. On compare donc à ce que le
|
||||
modèle dit déjà : une valeur identique n'est pas une saisie.
|
||||
|
||||
Cas limite assumé : choisir explicitement la valeur que le réglage
|
||||
commun donne déjà n'enregistre pas de surcharge. La VM suivra donc ce
|
||||
réglage s'il change — ce qui est aussi le plus attendu quand on n'a
|
||||
rien changé de visible."""
|
||||
if index >= len(self.rows):
|
||||
return True
|
||||
return value == self.rows[index]["vm"].get(field)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Surcharges : ce qu'une VM garde quand le réglage commun change
|
||||
# ---------------------------------------------------------------- #
|
||||
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 _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
|
||||
for compagnon in COMPANIONS.get(field, ()):
|
||||
self.overrides[key].pop(compagnon, 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_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 tous les champs d'un coup.
|
||||
|
||||
`_lock_fields` dit CE QUI est recopié : chaque formulaire y ajoute
|
||||
ce qui, chez lui, vient d'un choix commun."""
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Button
|
||||
|
||||
key = self._row_key(index)
|
||||
if key is None or index >= len(self.rows):
|
||||
return
|
||||
if on:
|
||||
self.locked.add(key)
|
||||
self.overrides[key] = self._lock_fields(index)
|
||||
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 _lock_fields(self, index):
|
||||
"""Ce qu'un verrou recopie. Les ressources, au minimum."""
|
||||
vm = self.rows[index]["vm"]
|
||||
return {"vcpus": vm["vcpus"], "ram": vm["ram"], "disk": vm["disk"]}
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Exemplaires et renommage
|
||||
# ---------------------------------------------------------------- #
|
||||
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 _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
|
||||
auto = self._auto_name(index)
|
||||
|
||||
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(
|
||||
rename_screen()(self.rows[index]["vm"]["name"], auto), done
|
||||
)
|
||||
|
||||
def _auto_name(self, index):
|
||||
"""Nom que la VM reprendrait si on effaçait le sien."""
|
||||
return self._plan_entries()[index]["name"]
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Saisies libres — la valeur qui n'est pas dans la liste
|
||||
# ---------------------------------------------------------------- #
|
||||
def _row_free(self, index, field, visible) -> None:
|
||||
from textual.widgets import Input
|
||||
|
||||
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):
|
||||
from textual.widgets import Input
|
||||
|
||||
raw = self.query_one(f"#c{index}_{field}", Input).value.strip()
|
||||
return self._read_res(field, raw)
|
||||
|
||||
@staticmethod
|
||||
def _read_res(field, raw):
|
||||
"""Lecture d'une ressource saisie à la main. Le disque se lit en
|
||||
tailles (« 40G »), la mémoire accepte les deux, le reste est un
|
||||
entier — et une valeur invalide vaut « rien », jamais zéro."""
|
||||
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."""
|
||||
from textual.widgets import Input
|
||||
|
||||
widget = self.query_one(RES_FIELDS[field][1], Input)
|
||||
widget.display = bool(visible)
|
||||
widget.disabled = not visible
|
||||
|
||||
def _apply_free(self, field) -> None:
|
||||
"""Relit la saisie libre. Une valeur invalide n'écrase rien : le
|
||||
profil retombe alors sur celle du catalogue."""
|
||||
from textual.widgets import Input
|
||||
|
||||
raw = self.query_one(RES_FIELDS[field][1], Input).value.strip()
|
||||
self.custom[field] = self._read_res(field, raw)
|
||||
self._clear_overrides((field,))
|
||||
|
||||
def _sync_free_inputs(self) -> None:
|
||||
"""Chaque saisie libre de rangée s'affiche si — et seulement si — sa
|
||||
ressource échappe aux préréglages, ou si la liste attend une frappe."""
|
||||
from textual.widgets import Input, Select
|
||||
|
||||
presets = self._presets()
|
||||
for i, r in enumerate(self.rows):
|
||||
vm = r["vm"]
|
||||
for field, choix in presets.items():
|
||||
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 choix
|
||||
widget.display = free
|
||||
widget.disabled = not free
|
||||
1135
script/todo/proxmox_menu.py
Normal file
1135
script/todo/proxmox_menu.py
Normal file
File diff suppressed because it is too large
Load diff
1063
script/todo/qemu_access.py
Normal file
1063
script/todo/qemu_access.py
Normal file
File diff suppressed because it is too large
Load diff
2052
script/todo/qemu_deploy.py
Normal file
2052
script/todo/qemu_deploy.py
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1960
script/todo/qemu_install.py
Normal file
1960
script/todo/qemu_install.py
Normal file
File diff suppressed because it is too large
Load diff
2259
script/todo/qemu_manage.py
Normal file
2259
script/todo/qemu_manage.py
Normal file
File diff suppressed because it is too large
Load diff
504
script/todo/qemu_menu.py
Normal file
504
script/todo/qemu_menu.py
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Menu QEMU/KVM : l'entr\u00e9e, le catalogue et les statistiques.\n\nC'est la porte du menu (\u00ab prompt_execute_qemu \u00bb) et ce qui sert \u00e0 TOUT le\nreste : le catalogue des distributions, le choix d'une version et d'une\narchitecture, la v\u00e9rification des outils de l'h\u00f4te. Les quatre autres fichiers\ndu menu QEMU s'appuient sur celui-ci.\n\nMixin de la classe TODO : ses m\u00e9thodes vivent sur la m\u00eame instance que celles\ndes autres fichiers, elles s'appellent donc par \u00ab self. \u00bb sans rien importer."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from datetime import datetime
|
||||
from script.todo.todo_i18n import t
|
||||
import click
|
||||
|
||||
|
||||
class QemuMenuMixin:
|
||||
"""Menu QEMU/KVM : l'entr\u00e9e, le catalogue et les statistiques.\n\nC'est la porte du menu (\u00ab prompt_execute_qemu \u00bb) et ce qui sert \u00e0 TOUT le\nreste : le catalogue des distributions, le choix d'une version et d'une\narchitecture, la v\u00e9rification des outils de l'h\u00f4te. Les quatre autres fichiers\ndu menu QEMU s'appuient sur celui-ci.\n\nMixin de la classe TODO : ses m\u00e9thodes vivent sur la m\u00eame instance que celles\ndes autres fichiers, elles s'appellent donc par \u00ab self. \u00bb sans rien importer."""
|
||||
|
||||
def _qemu_script_path(self):
|
||||
"""Chemin absolu vers script/qemu/deploy_qemu.py."""
|
||||
path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..",
|
||||
"qemu",
|
||||
"deploy_qemu.py",
|
||||
)
|
||||
return os.path.realpath(path)
|
||||
|
||||
def _qemu_default_ssh_key(self):
|
||||
"""Première clé publique SSH trouvée dans ~/.ssh, sinon ''."""
|
||||
for name in ("id_ed25519.pub", "id_rsa.pub"):
|
||||
path = os.path.expanduser(f"~/.ssh/{name}")
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return ""
|
||||
|
||||
# distro -> (versions affichées, version par défaut). Source de vérité =
|
||||
# deploy_qemu.py ; ceci ne sert qu'au sélecteur interactif.
|
||||
_QEMU_DISTROS = {
|
||||
"ubuntu": (["24.04", "25.10", "26.04"], "24.04"),
|
||||
"debian": (["11", "12", "13"], "12"),
|
||||
"fedora": (["41", "42", "43", "44"], "42"),
|
||||
"almalinux": (["9", "10"], "9"),
|
||||
"rocky": (["9", "10"], "10"),
|
||||
# Leap 16.0 par défaut : numérotée et stable. Tumbleweed reste offerte,
|
||||
# comme banc d'essai des ruptures à venir. Voir OPENSUSE_VERSIONS dans
|
||||
# deploy_qemu.py, qui fait autorité sur le catalogue.
|
||||
"opensuse": (["16.0", "tumbleweed"], "16.0"),
|
||||
"arch": (["latest"], "latest"),
|
||||
# Proxmox VE : le numéro est celui de PVE, pas de Debian (9 = trixie).
|
||||
# Une seule version au catalogue, la seule qui couvre amd64 ET arm64 —
|
||||
# voir PROXMOX_VERSIONS dans deploy_qemu.py, qui fait autorité.
|
||||
"proxmox": (["9"], "9"),
|
||||
}
|
||||
|
||||
def _qemu_prompt_distro(self):
|
||||
"""Demande la distribution (défaut : ubuntu)."""
|
||||
distros = list(self._QEMU_DISTROS)
|
||||
print(f"\n{t('Distribution:')}")
|
||||
for i, d in enumerate(distros, 1):
|
||||
print(f" [{i}] {d}")
|
||||
sel = input(t("Choice (number or name, default: ubuntu): ")).strip()
|
||||
if not sel:
|
||||
return "ubuntu"
|
||||
try:
|
||||
idx = int(sel) - 1
|
||||
if 0 <= idx < len(distros):
|
||||
return distros[idx]
|
||||
except ValueError:
|
||||
if sel in distros:
|
||||
return sel
|
||||
print(t("Invalid selection, using ubuntu"))
|
||||
return "ubuntu"
|
||||
|
||||
def _qemu_prompt_version(self, distro):
|
||||
"""Demande la version pour la distro (défaut = version par défaut)."""
|
||||
versions, default = self._QEMU_DISTROS.get(distro, ([], ""))
|
||||
print(f"\n{t('Version for')} {distro.capitalize()} :")
|
||||
for i, v in enumerate(versions, 1):
|
||||
suffix = " *" if v == default else ""
|
||||
stat = self._qemu_stat_avg("version", v, distro)
|
||||
print(f" [{i}] {v}{suffix}{stat}")
|
||||
sel = input(
|
||||
f"{t('Choice (number or version, blank = default):')} "
|
||||
).strip()
|
||||
if not sel:
|
||||
return default
|
||||
try:
|
||||
idx = int(sel) - 1
|
||||
if 0 <= idx < len(versions):
|
||||
return versions[idx]
|
||||
except ValueError:
|
||||
if sel in versions:
|
||||
return sel
|
||||
print(f"{t('Invalid selection, using')} {default}")
|
||||
return default
|
||||
|
||||
# Repli SEULEMENT : la table qui fait autorité est ARCH_DISTRO_SUPPORT de
|
||||
# deploy_qemu.py, lue par _qemu_arch_distros. Ces tuples ont longtemps été
|
||||
# une copie à la main, avec le commentaire « cohérent avec deploy_qemu » en
|
||||
# guise de garantie — et la cohérence a rompu à la première évolution :
|
||||
# Debian a gagné s390x là-bas sans l'obtenir ici, donc l'écran ne le
|
||||
# proposait pas. On ne les garde que pour le cas où l'import échoue.
|
||||
_QEMU_S390X_DISTROS = (
|
||||
"ubuntu",
|
||||
"almalinux",
|
||||
"rocky",
|
||||
"fedora",
|
||||
"opensuse",
|
||||
"debian",
|
||||
)
|
||||
|
||||
_QEMU_ARM64_DISTROS = (
|
||||
"ubuntu",
|
||||
"debian",
|
||||
"fedora",
|
||||
"almalinux",
|
||||
"rocky",
|
||||
"opensuse",
|
||||
)
|
||||
|
||||
# Alias distro pour l'affichage (jeton générique -> nom courant).
|
||||
_QEMU_ARCH_ALIAS = {"amd64": "x86_64", "arm64": "aarch64"}
|
||||
|
||||
def _qemu_arch_distros(self, arch):
|
||||
"""Distros supportant `arch` (None = toutes, cas amd64).
|
||||
|
||||
Lu dans deploy_qemu.py, qui refuse aussi les combinaisons qu'il
|
||||
n'annonce pas : une seule table, donc aucun écran ne peut proposer un
|
||||
choix rejeté ensuite. « amd64 » n'y figure pas et rend None, ce qui
|
||||
veut bien dire « toutes » — c'est le contrat attendu ici.
|
||||
"""
|
||||
try:
|
||||
table = getattr(self._qemu_import_module(), "ARCH_DISTRO_SUPPORT")
|
||||
except Exception:
|
||||
# Repli sur les copies locales : mieux vaut un catalogue figé
|
||||
# qu'un écran vide si deploy_qemu.py est absent ou cassé.
|
||||
if arch == "s390x":
|
||||
return self._QEMU_S390X_DISTROS
|
||||
if arch == "arm64":
|
||||
return self._QEMU_ARM64_DISTROS
|
||||
return None
|
||||
return table.get(arch)
|
||||
|
||||
def _qemu_last_run_line(self):
|
||||
"""Ligne « dernière install » (distro version [arch] en durée), depuis
|
||||
l'historique (.venv.erplibre) ; '' si aucune donnée."""
|
||||
try:
|
||||
from script.todo import qemu_install_monitor as mon
|
||||
|
||||
r = mon.last_run()
|
||||
if r:
|
||||
return (
|
||||
f" ℹ {t('Last install:')} {r.get('distro')} "
|
||||
f"{r.get('version')} [{r.get('arch')}] — "
|
||||
f"{mon._fmt_secs(r.get('seconds', 0))}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def _qemu_stat_avg(self, field, value, distro=None):
|
||||
"""Suffixe « · ~5m moy (3) » : durée d'install MOYENNE historique pour
|
||||
cette archi/distro/version (fichier .venv.erplibre), ou '' si aucune
|
||||
donnée. Pour field='version', `distro` est requis."""
|
||||
try:
|
||||
from script.todo import qemu_install_monitor as mon
|
||||
|
||||
if field == "arch":
|
||||
secs, n = mon.avg_by_arch(value)
|
||||
elif field == "version":
|
||||
secs, n = mon.avg_by_version(distro, value)
|
||||
else:
|
||||
secs, n = mon.avg_by_distro(value)
|
||||
if secs:
|
||||
return f" · ~{mon._fmt_secs(secs)} {t('avg')} ({n})"
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def _qemu_ask_arch(self, opts, native, allow_all=False):
|
||||
"""Affiche les architectures `opts` (natif marqué d'un *) et renvoie le
|
||||
choix. Si `allow_all`, propose aussi [all] = toutes les archis (renvoie
|
||||
« all »). Toute arch non native est ÉMULÉE (TCG, lente)."""
|
||||
print(f"\n{t('Architecture:')}")
|
||||
for i, a in enumerate(opts, 1):
|
||||
alias = self._QEMU_ARCH_ALIAS.get(a)
|
||||
label = f"{a} ({alias})" if alias else a
|
||||
if a == native:
|
||||
label += f" — {t('native')} *"
|
||||
elif a == "s390x":
|
||||
label += f" ({t('IBM Z — emulated, slow; Ubuntu only')})"
|
||||
elif a == "arm64":
|
||||
label += f" ({t('ARM 64-bit — emulated, slow')})"
|
||||
else:
|
||||
label += f" ({t('emulated, slow')})"
|
||||
print(f" [{i}] {label}{self._qemu_stat_avg('arch', a)}")
|
||||
if allow_all:
|
||||
print(f" [all] {t('All supported architectures')}")
|
||||
sel = (
|
||||
input(f"{t('Choice (number or name, blank = native):')} ")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if not sel:
|
||||
return native
|
||||
if allow_all and sel in ("all", "*"):
|
||||
note = t("(includes emulated architectures — some VMs are slow)")
|
||||
print(f"⚠ {note}")
|
||||
return "all"
|
||||
chosen = None
|
||||
for i, a in enumerate(opts, 1):
|
||||
if sel in (str(i), a, self._QEMU_ARCH_ALIAS.get(a)):
|
||||
chosen = a
|
||||
break
|
||||
if chosen is None:
|
||||
print(f"{t('Invalid selection, using')} {native}")
|
||||
return native
|
||||
if chosen != native:
|
||||
warn = t(
|
||||
"This architecture is emulated (TCG): boot and install are"
|
||||
" much slower than the native one."
|
||||
)
|
||||
print(f"⚠ {warn}")
|
||||
return chosen
|
||||
|
||||
def _qemu_prompt_infra_arch(self):
|
||||
"""Architecture du parc (défaut : native de l'hôte, marquée d'un *).
|
||||
Toute arch non native est émulée ; le catalogue est ensuite restreint
|
||||
aux distros publiant cette arch."""
|
||||
native = self._native_arch()
|
||||
opts = ["amd64", "arm64", "s390x"]
|
||||
if native not in opts: # hôte exotique : garder le natif en tête
|
||||
opts.insert(0, native)
|
||||
return self._qemu_ask_arch(opts, native, allow_all=True)
|
||||
|
||||
def _qemu_list_images(self):
|
||||
"""Affiche la liste des distros/versions et leurs specs."""
|
||||
cmd = f"{self._qemu_script_path()} --list-images"
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||
|
||||
def _qemu_ensure_tools(self):
|
||||
"""virsh absent : proposer l'installation plutôt que de laisser
|
||||
chaque commande échouer sur « sudo: virsh: command not found ».
|
||||
|
||||
deploy_qemu.py --setup-host connaît les paquets de chaque
|
||||
distribution ; on ne devine donc rien ici, on le délègue."""
|
||||
if shutil.which("virsh"):
|
||||
return True
|
||||
print(f"\n⚠ {t('virsh is missing: libvirt is not installed here.')}")
|
||||
print(f" {t('Every VM command will fail until it is.')}")
|
||||
if not self._is_yes_default_yes(
|
||||
input(t("Install the QEMU/libvirt tools now? (Y/n): "))
|
||||
):
|
||||
return False
|
||||
cmd = f"sudo {self._QEMU_QEMU_PKGS}"
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||
if shutil.which("virsh"):
|
||||
print(f"✅ {t('libvirt is available.')}")
|
||||
return True
|
||||
# Sur une distribution à noyau roulant, --setup-host peut demander un
|
||||
# redémarrage avant que les modules soient chargeables.
|
||||
print(f"⚠ {t('virsh still missing; a reboot may be required.')}")
|
||||
return False
|
||||
|
||||
def prompt_execute_qemu(self):
|
||||
print(f"🤖 {t('Deploy a QEMU/KVM virtual machine (libvirt)!')}")
|
||||
script_path = self._qemu_script_path()
|
||||
if not os.path.isfile(script_path):
|
||||
print(f"{t('QEMU deploy script not found: ')}{script_path}")
|
||||
return False
|
||||
self._qemu_ensure_tools()
|
||||
choices = [
|
||||
{"section": t("Deployment")},
|
||||
{"prompt_description": t("Deploy VM(s) (one or many)")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Preview a deployment (dry-run, no sudo)"
|
||||
)
|
||||
},
|
||||
{"prompt_description": t("Download a cloud image only")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Reopen install monitoring (last run / history)"
|
||||
)
|
||||
},
|
||||
{"section": t("Manage")},
|
||||
{"prompt_description": t("List VMs (virsh list --all)")},
|
||||
{"prompt_description": t("Show a VM IP address")},
|
||||
{"prompt_description": t("Open the console on a VM")},
|
||||
{"prompt_description": t("Resize a VM disk")},
|
||||
{"prompt_description": t("Delete VM(s)")},
|
||||
{"prompt_description": t("Clean up QEMU (orphan files)")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Test a VM (open Odoo in a CLI browser)"
|
||||
)
|
||||
},
|
||||
{"prompt_description": t("Statistics (installs, durations, VMs)")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"SSH configuration (~/.ssh/config, ProxyJump)"
|
||||
)
|
||||
},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Remote desktop tunnel (VNC/RDP through SSH)"
|
||||
)
|
||||
},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Android emulator (start, tunnel, scrcpy)"
|
||||
)
|
||||
},
|
||||
{"section": t("Catalog")},
|
||||
{"prompt_description": t("List available images and specs")},
|
||||
]
|
||||
config_entries = self.config_file.get_config("qemu_from_makefile")
|
||||
if config_entries:
|
||||
choices.extend(config_entries)
|
||||
help_info = self.fill_help_info(choices)
|
||||
|
||||
while True:
|
||||
status = click.prompt(help_info)
|
||||
print()
|
||||
if status == "0":
|
||||
return False
|
||||
elif status == "1":
|
||||
self._qemu_deploy(dry_run=False)
|
||||
elif status == "2":
|
||||
self._qemu_deploy(dry_run=True)
|
||||
elif status == "3":
|
||||
self._qemu_download_image()
|
||||
elif status == "4":
|
||||
self._qemu_reopen_monitor()
|
||||
elif status == "5":
|
||||
self._qemu_list_vms(ask_advanced=True)
|
||||
elif status == "6":
|
||||
self._qemu_show_ip()
|
||||
elif status == "7":
|
||||
self._qemu_console()
|
||||
elif status == "8":
|
||||
self._qemu_resize_disk()
|
||||
elif status == "9":
|
||||
self._qemu_delete_vm()
|
||||
elif status == "10":
|
||||
self._qemu_cleanup()
|
||||
elif status == "11":
|
||||
self._qemu_test_vm()
|
||||
elif status == "12":
|
||||
self._qemu_stats()
|
||||
elif status == "13":
|
||||
self._qemu_ssh_config_menu()
|
||||
elif status == "14":
|
||||
self._qemu_tunnel_menu()
|
||||
elif status == "15":
|
||||
self._qemu_emulator_menu()
|
||||
elif status == "16":
|
||||
self._qemu_list_images()
|
||||
else:
|
||||
cmd_no_found = True
|
||||
try:
|
||||
int_cmd = int(status)
|
||||
# Ignore les entrées de section pour mapper le numéro
|
||||
# affiché sur la bonne commande (config incluse).
|
||||
real = [c for c in choices if not c.get("section")]
|
||||
if 0 < int_cmd <= len(real):
|
||||
cmd_no_found = False
|
||||
self.execute_from_configuration(real[int_cmd - 1])
|
||||
except ValueError:
|
||||
pass
|
||||
if cmd_no_found:
|
||||
print(t("Command not found !"))
|
||||
|
||||
def _qemu_stats(self):
|
||||
"""Statistiques d'utilisation de QEMU, et remise à zéro.
|
||||
|
||||
Tout vient de l'historique tenu par le moniteur d'installation
|
||||
(.venv.erplibre/qemu_install_stats.json) et de l'état libvirt courant.
|
||||
"""
|
||||
# Cet écran ne lit que des fichiers : il n'a pas besoin de Textual,
|
||||
# contrairement au dashboard du même module. Un échec d'import est
|
||||
# donc un vrai problème de module, pas une dépendance manquante.
|
||||
try:
|
||||
from script.todo import qemu_install_monitor as mon
|
||||
except ImportError as exc:
|
||||
print(f"{t('Command failed: ')}{exc}")
|
||||
return
|
||||
|
||||
while True:
|
||||
summary = mon.stats_summary()
|
||||
print(f"\n📊 {t('QEMU statistics')}")
|
||||
if not summary:
|
||||
print(f" {t('No installation recorded yet.')}")
|
||||
else:
|
||||
rate = 100 * summary["ok"] // max(summary["total"], 1)
|
||||
print(f"\n── {t('Installations')} ──")
|
||||
print(
|
||||
f" {t('Total'):<18}: {summary['total']}"
|
||||
f" ({summary['ok']} {t('succeeded')},"
|
||||
f" {summary['failed']} {t('failed')} — {rate} %)"
|
||||
)
|
||||
if summary["first_ts"]:
|
||||
days = max(
|
||||
1,
|
||||
(summary["last_ts"] - summary["first_ts"]) // 86400,
|
||||
)
|
||||
print(
|
||||
f" {t('Period'):<18}:"
|
||||
f" {self._qemu_stamp(summary['first_ts'])}"
|
||||
f" → {self._qemu_stamp(summary['last_ts'])}"
|
||||
f" ({days} {t('days')})"
|
||||
)
|
||||
print(
|
||||
f" {t('Median duration'):<18}:"
|
||||
f" {mon._fmt_secs(summary['median'])}"
|
||||
f" ({t('min')} {mon._fmt_secs(summary['min'])} ·"
|
||||
f" {t('max')} {mon._fmt_secs(summary['max'])})"
|
||||
)
|
||||
print(
|
||||
f" {t('Cumulated time'):<18}:"
|
||||
f" {mon._fmt_secs(summary['total_secs'])}"
|
||||
)
|
||||
for field, title in (
|
||||
("distro", t("By distribution")),
|
||||
("version", t("By version")),
|
||||
("arch", t("By architecture")),
|
||||
):
|
||||
rows = mon.stats_by(field)
|
||||
if not rows:
|
||||
continue
|
||||
print(f"\n── {title} ──")
|
||||
for key, count, avg, failed in rows[:8]:
|
||||
# Un groupe sans aucun succès n'a pas de moyenne : « — »
|
||||
# plutôt qu'un « ~0s » trompeur.
|
||||
moy = f"~{mon._fmt_secs(avg)}" if count else "—"
|
||||
fail = (
|
||||
f" ⚠ {failed} {self._plural(t('failure'), failed)}"
|
||||
if failed
|
||||
else ""
|
||||
)
|
||||
print(f" {key:<22} {count:>3} × {moy:<8}{fail}")
|
||||
|
||||
self._qemu_stats_vms(mon)
|
||||
print(f"\n [r] {t('Reset the statistics')}")
|
||||
print(f" [0] {t('Back')}")
|
||||
answer = input(f"💬 {t('Your choice')} : ").strip().lower()
|
||||
if answer in ("", "0"):
|
||||
return
|
||||
if answer == "r":
|
||||
if not summary:
|
||||
print(f" {t('Nothing to reset.')}")
|
||||
continue
|
||||
confirm = input(
|
||||
f" {t('Erase')} {summary['total']}"
|
||||
f" {t('recorded runs')}? (y/N): "
|
||||
).strip()
|
||||
if self._is_yes(confirm):
|
||||
count = mon.reset_stats()
|
||||
print(f" ✅ {count} {t('runs erased')}.")
|
||||
else:
|
||||
print(f" {t('Cancelled.')}")
|
||||
|
||||
@staticmethod
|
||||
def _qemu_stamp(ts):
|
||||
"""Horodatage court « 2026-08-01 »."""
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return "?"
|
||||
|
||||
def _qemu_stats_vms(self, mon):
|
||||
"""Machines virtuelles actuelles : nombre, états, place disque."""
|
||||
try:
|
||||
states = mon.virsh_domstates()
|
||||
except Exception:
|
||||
return
|
||||
if not states:
|
||||
return
|
||||
running = sum(1 for s in states.values() if s == "running")
|
||||
total_bytes = 0
|
||||
counted = 0
|
||||
for name in states:
|
||||
try:
|
||||
# vm_disk_path attend un dict ; le chemin par défaut de libvirt
|
||||
# se déduit du seul nom.
|
||||
size = mon.disk_actual_size(mon.vm_disk_path({"name": name}))
|
||||
except Exception:
|
||||
size = None
|
||||
if size:
|
||||
total_bytes += size
|
||||
counted += 1
|
||||
print(f"\n── {t('Virtual machines')} ──")
|
||||
print(
|
||||
f" {t('Defined'):<18}: {len(states)}"
|
||||
f" ({running} {t('running')},"
|
||||
f" {len(states) - running} {t('stopped')})"
|
||||
)
|
||||
if counted:
|
||||
print(
|
||||
f" {t('Disk used'):<18}:"
|
||||
f" {mon._fmt_size(total_bytes)}"
|
||||
f" ({counted} {self._plural(t('image'), counted)})"
|
||||
)
|
||||
8629
script/todo/todo.py
8629
script/todo/todo.py
File diff suppressed because it is too large
Load diff
|
|
@ -384,8 +384,13 @@ class TestLeMenu(unittest.TestCase):
|
|||
)
|
||||
|
||||
def test_every_qemu_entry_has_its_proxmox_counterpart(self):
|
||||
"""L'équivalent des dix-sept commandes, plus le choix de l'hôte."""
|
||||
src = open("script/todo/todo.py", encoding="utf-8").read()
|
||||
"""L'équivalent des dix-sept commandes, plus le choix de l'hôte.
|
||||
|
||||
Le menu vit dans son propre fichier depuis le refactor : la cohérence
|
||||
numéro/dispatch, elle, est vérifiée par le socle commun de
|
||||
test_todo_menu.py, qui sert les deux menus.
|
||||
"""
|
||||
src = open("script/todo/proxmox_menu.py", encoding="utf-8").read()
|
||||
debut = src.index(" def prompt_execute_proxmox(self):")
|
||||
bloc = src[debut : src.index(" def _pve_fetch_image(self):")]
|
||||
for n in range(1, 19):
|
||||
|
|
|
|||
|
|
@ -19,9 +19,8 @@ import re
|
|||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
TODO_PY = (
|
||||
Path(__file__).resolve().parent.parent / "script" / "todo" / "todo.py"
|
||||
)
|
||||
TODO_DIR = Path(__file__).resolve().parent.parent / "script" / "todo"
|
||||
TODO_PY = TODO_DIR / "todo.py"
|
||||
|
||||
# « [12] {t("Deploy - …")} » en début de ligne, dans la f-string du menu.
|
||||
RE_SHOWN = re.compile(r'^\[(\d+)\] \{t\("([^"]+)"\)\}', re.M)
|
||||
|
|
@ -136,28 +135,46 @@ class TestExecuteMenuNumbering(unittest.TestCase):
|
|||
self.assertEqual(set(self.EXPECTED) - shown_keys, set())
|
||||
|
||||
|
||||
class TestQemuMenuNumbering(unittest.TestCase):
|
||||
"""Le menu QEMU : même piège, autre forme.
|
||||
class MenuCoherence:
|
||||
"""Socle : un menu écrit en liste de dictionnaires est-il cohérent ?
|
||||
|
||||
Il ne s'écrit pas en f-string mais en liste de dictionnaires, où seules les
|
||||
entrées « prompt_description » consomment un numéro — les « section » sont
|
||||
des titres. Le décalage y est donc encore moins visible à l'œil : insérer
|
||||
une entrée avant la dernière renumérote tout ce qui suit, et le dispatch ne
|
||||
proteste pas. C'est arrivé en ajoutant l'émulateur Android avant
|
||||
« List available images ».
|
||||
Ce piège-là ne dépend pas du menu : seules les entrées
|
||||
« prompt_description » consomment un numéro (les « section » sont des
|
||||
titres), et le dispatch les renumérote à la main. Insérer une entrée avant
|
||||
la dernière décale tout ce qui suit sans que rien ne proteste — c'est
|
||||
arrivé en ajoutant l'émulateur Android avant « List available images ».
|
||||
|
||||
Depuis que les menus vivent dans leurs propres fichiers (le refactor de
|
||||
todo.py), ce socle sert DEUX menus : QEMU/KVM et Proxmox. Un troisième
|
||||
n'aura qu'à déclarer ses quatre attributs.
|
||||
|
||||
À déclarer par la sous-classe : SOURCE (le fichier), ENTRY (la ligne
|
||||
« def prompt_execute_… »), END (le membre suivant, qui borne la lecture) et
|
||||
EXPECTED (où mène chaque entrée, par le début de son libellé).
|
||||
"""
|
||||
|
||||
SOURCE = None
|
||||
ENTRY = ""
|
||||
END = ""
|
||||
EXPECTED = {}
|
||||
MINIMUM = 10
|
||||
|
||||
RE_ENTRY = re.compile(
|
||||
r'"(section|prompt_description)": t\(\s*\n?\s*"([^"]+)"'
|
||||
)
|
||||
# Les lignes de COMMENTAIRE entre le « elif » et l'appel sont sautées :
|
||||
# une entrée expliquée devenait invisible pour ce test, qui annonçait alors
|
||||
# « 18 affichées, 17 dispatchées » sans qu'aucune entrée ne manque. Un test
|
||||
# ne doit pas dépendre de l'endroit où quelqu'un met un commentaire.
|
||||
RE_DISPATCH_CALL = re.compile(
|
||||
r'(?:el)?if status == "(\d+)":\s*\n\s*(?:status = )?self\.(\w+)\('
|
||||
r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*'
|
||||
r'\s*(?:status = )?self\.(\w+)\('
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
source = TODO_PY.read_text(encoding="utf-8")
|
||||
start = source.index("def prompt_execute_qemu(self):")
|
||||
end = source.index("def _qemu_tunnel_menu(self):", start)
|
||||
source = self.SOURCE.read_text(encoding="utf-8")
|
||||
start = source.index(self.ENTRY)
|
||||
end = source.index(self.END, start)
|
||||
self.body = source[start:end]
|
||||
num = 0
|
||||
self.shown = []
|
||||
|
|
@ -171,7 +188,7 @@ class TestQemuMenuNumbering(unittest.TestCase):
|
|||
|
||||
def test_the_menu_was_actually_parsed(self):
|
||||
"""Sur une liste vide, tout test passe : mieux vaut tomber ici."""
|
||||
self.assertGreater(len(self.shown), 10)
|
||||
self.assertGreater(len(self.shown), self.MINIMUM)
|
||||
self.assertEqual(len(self.shown), len(self.dispatch))
|
||||
|
||||
def test_numbering_is_contiguous_from_one(self):
|
||||
|
|
@ -185,28 +202,6 @@ class TestQemuMenuNumbering(unittest.TestCase):
|
|||
[n for n, _ in self.shown], [n for n, _ in self.dispatch]
|
||||
)
|
||||
|
||||
# Où mène chaque entrée, par le début de son libellé. Une renumérotation ne
|
||||
# touche PAS cette table ; ajouter une entrée l'exige, et c'est le seul
|
||||
# moment où quelqu'un doit dire où elle mène.
|
||||
EXPECTED = {
|
||||
"Deploy VM(s)": "_qemu_deploy",
|
||||
"Preview a deployment": "_qemu_deploy",
|
||||
"Download a cloud image only": "_qemu_download_image",
|
||||
"Reopen": "_qemu_reopen_monitor",
|
||||
"List VMs": "_qemu_list_vms",
|
||||
"Show a VM IP address": "_qemu_show_ip",
|
||||
"Open the console on a VM": "_qemu_console",
|
||||
"Resize a VM disk": "_qemu_resize_disk",
|
||||
"Delete VM(s)": "_qemu_delete_vm",
|
||||
"Clean up QEMU": "_qemu_cleanup",
|
||||
"Test": "_qemu_test_vm",
|
||||
"Statistics": "_qemu_stats",
|
||||
"SSH configuration": "_qemu_ssh_config_menu",
|
||||
"Remote desktop tunnel": "_qemu_tunnel_menu",
|
||||
"Android emulator": "_qemu_emulator_menu",
|
||||
"List available images": "_qemu_list_images",
|
||||
}
|
||||
|
||||
def _key(self, label):
|
||||
for key in self.EXPECTED:
|
||||
if label.startswith(key):
|
||||
|
|
@ -235,6 +230,72 @@ class TestQemuMenuNumbering(unittest.TestCase):
|
|||
self.assertEqual(set(self.EXPECTED) - keys, set())
|
||||
|
||||
|
||||
class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase):
|
||||
"""Le menu QEMU/KVM, désormais dans script/todo/qemu_menu.py."""
|
||||
|
||||
SOURCE = TODO_DIR / "qemu_menu.py"
|
||||
ENTRY = "def prompt_execute_qemu(self):"
|
||||
END = "def _qemu_stats(self):"
|
||||
|
||||
# Où mène chaque entrée, par le début de son libellé. Une renumérotation ne
|
||||
# touche PAS cette table ; ajouter une entrée l'exige, et c'est le seul
|
||||
# moment où quelqu'un doit dire où elle mène.
|
||||
EXPECTED = {
|
||||
"Deploy VM(s)": "_qemu_deploy",
|
||||
"Preview a deployment": "_qemu_deploy",
|
||||
"Download a cloud image only": "_qemu_download_image",
|
||||
"Reopen": "_qemu_reopen_monitor",
|
||||
"List VMs": "_qemu_list_vms",
|
||||
"Show a VM IP address": "_qemu_show_ip",
|
||||
"Open the console on a VM": "_qemu_console",
|
||||
"Resize a VM disk": "_qemu_resize_disk",
|
||||
"Delete VM(s)": "_qemu_delete_vm",
|
||||
"Clean up QEMU": "_qemu_cleanup",
|
||||
"Test": "_qemu_test_vm",
|
||||
"Statistics": "_qemu_stats",
|
||||
"SSH configuration": "_qemu_ssh_config_menu",
|
||||
"Remote desktop tunnel": "_qemu_tunnel_menu",
|
||||
"Android emulator": "_qemu_emulator_menu",
|
||||
"List available images": "_qemu_list_images",
|
||||
}
|
||||
|
||||
|
||||
class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase):
|
||||
"""Le menu Proxmox : dix-huit entrées, le même piège.
|
||||
|
||||
Quatre d'entre elles mènent VOLONTAIREMENT à des méthodes du menu QEMU —
|
||||
c'est le même travail, et le refactor n'a pas dupliqué ce code. La table
|
||||
le dit noir sur blanc : si quelqu'un les recopiait un jour, ce test
|
||||
montrerait que la cible a changé.
|
||||
"""
|
||||
|
||||
SOURCE = TODO_DIR / "proxmox_menu.py"
|
||||
ENTRY = "def prompt_execute_proxmox(self):"
|
||||
END = "def _pve_fetch_image(self):"
|
||||
MINIMUM = 15
|
||||
|
||||
EXPECTED = {
|
||||
"Deploy a VM on the Proxmox host": "_pve_deploy",
|
||||
"Preview a deployment": "_pve_deploy",
|
||||
"Download a cloud image on the host": "_pve_fetch_image",
|
||||
"Reopen": "_qemu_reopen_monitor",
|
||||
"List VMs (qm list)": "_pve_list",
|
||||
"Show a VM IP address": "_pve_vm_ip",
|
||||
"Open the console on a VM": "_pve_console",
|
||||
"Resize a VM disk": "_pve_resize",
|
||||
"Delete VM(s)": "_pve_delete",
|
||||
"Clean up (orphan disks)": "_pve_cleanup",
|
||||
"Test a VM": "_pve_test_vm",
|
||||
"Statistics (host and VMs)": "_pve_stats",
|
||||
"SSH configuration": "_pve_ssh_config",
|
||||
"Remote desktop tunnel": "_qemu_tunnel_menu",
|
||||
"Android emulator": "_qemu_emulator_menu",
|
||||
"List available images": "_qemu_list_images",
|
||||
"Proxmox - example sequence": "_pve_example",
|
||||
"Change the Proxmox host": "_pve_forget_host",
|
||||
}
|
||||
|
||||
|
||||
class TestMenuLabels(unittest.TestCase):
|
||||
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue