[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
2026-08-23 18:01:09 -04:00
|
|
|
#!/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
|
|
|
|
|
|
|
|
|
|
|
[ADD] todo : dire la place qui reste sous le plan de déploiement
La ligne de totaux annonçait « ~126 G » sans dire sur quoi : la demande
seule ne dit pas si ça rentre, et on l'apprenait au déploiement. Elle dit
maintenant la demande, ce qui reste et la capacité — « ~126 G / 20 G libres
sur 270 G » — et prévient dès que le plan dépasse. Les trois limites (RAM,
disque, cœurs) s'affichent ensemble : n'en montrer qu'une cachait les
autres. Sur Proxmox, la place vient du stockage choisi, que « pvesm status »
donnait déjà.
Deux défauts trouvés en le faisant : la marque de génération, exigée de tous
les widgets, faisait taire chaque réglage commun de l'écran Proxmox, et
« [x1] » disparaissait, lu comme une balise Rich.
--- EN ---
The totals line said "~126 G" without saying out of what: the demand alone
does not tell whether it fits, and one found out at deploy time. It now says
the demand, what is left and the capacity — "~126 G / 20 G free of 270 G" —
and warns as soon as the plan exceeds it. The three limits (RAM, disk,
cores) show together: showing only one hid the others. On Proxmox the room
comes from the chosen storage, which "pvesm status" already gave.
Two defects found on the way: the generation mark, required of every widget,
silenced each common setting on the Proxmox screen, and "[x1]" vanished,
read as a Rich tag.
Assisted-by: Claude Opus 5
2026-08-24 01:11:09 -04:00
|
|
|
def gib(nbytes) -> int:
|
|
|
|
|
"""Octets -> Gio entiers. Le plan compte en Go partout ailleurs : mêler
|
|
|
|
|
des unités sur la même ligne de totaux la rendrait illisible."""
|
|
|
|
|
try:
|
|
|
|
|
return int(nbytes) // (1 << 30)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def disk_note(plan_gb, free_gb, total_gb=0) -> str:
|
|
|
|
|
"""« ~50 G / 20 G libres sur 270 G » — la demande, le reste, la capacité.
|
|
|
|
|
|
|
|
|
|
La demande seule ne dit pas si ça rentre : c'est le rapprochement qui
|
|
|
|
|
décide, et c'est pourquoi la place s'affiche là même où la demande était
|
|
|
|
|
déjà écrite. Les deux nombres ne sont pas redondants — 20 Go libres sur
|
|
|
|
|
270 se lit autrement que 20 sur 24. Sans mesure (0), on n'invente rien :
|
|
|
|
|
la demande s'affiche seule.
|
|
|
|
|
"""
|
|
|
|
|
if not free_gb:
|
|
|
|
|
return f"~{plan_gb} G"
|
|
|
|
|
if not total_gb:
|
|
|
|
|
return f"~{plan_gb} G / {free_gb} G {t('free')}"
|
|
|
|
|
return f"~{plan_gb} G / {free_gb} G {t('free of')} {total_gb} G"
|
|
|
|
|
|
|
|
|
|
|
[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
2026-08-23 18:01:09 -04:00
|
|
|
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
|