Merge branch 'refactor_todo_qemu'

Refactor todo qemu files
This commit is contained in:
Mathieu Benoit 2026-08-25 03:16:33 -04:00
commit 0d480af24a
12 changed files with 10316 additions and 9567 deletions

View 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

View 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

File diff suppressed because it is too large Load diff

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

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

File diff suppressed because it is too large Load diff

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
View 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)})"
)

File diff suppressed because it is too large Load diff

View file

@ -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):

View file

@ -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.