[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose vivait. Il en garde 4 400 — les menus et les aides générales — et six fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO. Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées seulement là où un appel de classe devait changer de nom. Les deux formulaires de déploiement posent le même travail : ils partagent maintenant la logique pure, le CSS, la fabrique des rangées de ressources et les gestes du plan (surcharges, verrous, exemplaires, renommage). Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes : même écran au SVG près, même état, même spec. --- EN --- todo.py had passed 13,000 lines: nobody could find where anything lived. It keeps 4,400 — the menus and the general helpers — and six files each own one subject, assembled as mixins on the TODO class. No member lost: 438 before, 438 after, and thirteen sources changed only where a class-level call had to change name. Both deployment forms do the same work: they now share the pure logic, the CSS, the resource-row factory and the plan's gestures (overrides, locks, copies, renaming). Verified by rendering the QEMU form before and after across nine gestures: same screen down to the SVG, same state, same spec. Assisted-by: Claude Opus 5
This commit is contained in:
parent
9abac6a555
commit
95e70150c3
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):
|
def test_every_qemu_entry_has_its_proxmox_counterpart(self):
|
||||||
"""L'équivalent des dix-sept commandes, plus le choix de l'hôte."""
|
"""L'équivalent des dix-sept commandes, plus le choix de l'hôte.
|
||||||
src = open("script/todo/todo.py", encoding="utf-8").read()
|
|
||||||
|
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):")
|
debut = src.index(" def prompt_execute_proxmox(self):")
|
||||||
bloc = src[debut : src.index(" def _pve_fetch_image(self):")]
|
bloc = src[debut : src.index(" def _pve_fetch_image(self):")]
|
||||||
for n in range(1, 19):
|
for n in range(1, 19):
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,8 @@ import re
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
TODO_PY = (
|
TODO_DIR = Path(__file__).resolve().parent.parent / "script" / "todo"
|
||||||
Path(__file__).resolve().parent.parent / "script" / "todo" / "todo.py"
|
TODO_PY = TODO_DIR / "todo.py"
|
||||||
)
|
|
||||||
|
|
||||||
# « [12] {t("Deploy - …")} » en début de ligne, dans la f-string du menu.
|
# « [12] {t("Deploy - …")} » en début de ligne, dans la f-string du menu.
|
||||||
RE_SHOWN = re.compile(r'^\[(\d+)\] \{t\("([^"]+)"\)\}', re.M)
|
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())
|
self.assertEqual(set(self.EXPECTED) - shown_keys, set())
|
||||||
|
|
||||||
|
|
||||||
class TestQemuMenuNumbering(unittest.TestCase):
|
class MenuCoherence:
|
||||||
"""Le menu QEMU : même piège, autre forme.
|
"""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
|
Ce piège-là ne dépend pas du menu : seules les entrées
|
||||||
entrées « prompt_description » consomment un numéro — les « section » sont
|
« prompt_description » consomment un numéro (les « section » sont des
|
||||||
des titres. Le décalage y est donc encore moins visible à l'œil : insérer
|
titres), et le dispatch les renumérote à la main. Insérer une entrée avant
|
||||||
une entrée avant la dernière renumérote tout ce qui suit, et le dispatch ne
|
la dernière décale tout ce qui suit sans que rien ne proteste — c'est
|
||||||
proteste pas. C'est arrivé en ajoutant l'émulateur Android avant
|
arrivé en ajoutant l'émulateur Android avant « List available images ».
|
||||||
« 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(
|
RE_ENTRY = re.compile(
|
||||||
r'"(section|prompt_description)": t\(\s*\n?\s*"([^"]+)"'
|
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(
|
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):
|
def setUp(self):
|
||||||
source = TODO_PY.read_text(encoding="utf-8")
|
source = self.SOURCE.read_text(encoding="utf-8")
|
||||||
start = source.index("def prompt_execute_qemu(self):")
|
start = source.index(self.ENTRY)
|
||||||
end = source.index("def _qemu_tunnel_menu(self):", start)
|
end = source.index(self.END, start)
|
||||||
self.body = source[start:end]
|
self.body = source[start:end]
|
||||||
num = 0
|
num = 0
|
||||||
self.shown = []
|
self.shown = []
|
||||||
|
|
@ -171,7 +188,7 @@ class TestQemuMenuNumbering(unittest.TestCase):
|
||||||
|
|
||||||
def test_the_menu_was_actually_parsed(self):
|
def test_the_menu_was_actually_parsed(self):
|
||||||
"""Sur une liste vide, tout test passe : mieux vaut tomber ici."""
|
"""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))
|
self.assertEqual(len(self.shown), len(self.dispatch))
|
||||||
|
|
||||||
def test_numbering_is_contiguous_from_one(self):
|
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]
|
[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):
|
def _key(self, label):
|
||||||
for key in self.EXPECTED:
|
for key in self.EXPECTED:
|
||||||
if label.startswith(key):
|
if label.startswith(key):
|
||||||
|
|
@ -235,6 +230,72 @@ class TestQemuMenuNumbering(unittest.TestCase):
|
||||||
self.assertEqual(set(self.EXPECTED) - keys, set())
|
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):
|
class TestMenuLabels(unittest.TestCase):
|
||||||
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.
|
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue