erplibre/script/todo/deploy_form_plan.py

496 lines
19 KiB
Python
Raw Permalink Normal View History

#!/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
2026-09-04 02:03:52 -04:00
from textual.containers import Horizontal, Vertical, VerticalScroll
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()
2026-09-04 02:03:52 -04:00
class HelpScreen(ModalScreen):
"""Ce que chaque option d'installation pose VRAIMENT dans la VM.
`blocks` : [(titre, [lignes])], dans l'ordre du formulaire. Une case
cochée engage parfois huit poses, et son libellé n'en nomme que trois :
cet écran est le seul endroit où la liste complète est lisible AVANT
de déployer.
La fenêtre ne fait que LIRE : elle se ferme sans rien changer, ce qui
permet de l'ouvrir en plein remplissage du formulaire.
"""
BINDINGS = [
("escape", "close", t("Close")),
("q", "close", t("Close")),
]
def __init__(self, blocks, title=""):
super().__init__()
self._blocks = blocks
self._title = title or t("What each option installs")
def compose(self) -> ComposeResult:
with Vertical(id="helpbox"):
yield Static(
f" {self._title} ({t('Esc to close')})",
id="helptitle",
)
with VerticalScroll(id="helpbody"):
for titre, lignes in self._blocks:
yield Static(f"▸ {titre}", classes="helphead")
for ligne in lignes:
yield Static(f" {ligne}")
yield Static("")
def action_close(self) -> None:
self.dismiss()
return {
"rename": RenameScreen,
"preview": PreviewScreen,
"help": HelpScreen,
}
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"]
2026-09-04 02:03:52 -04:00
def help_screen():
"""La fenêtre d'aide, construite au premier appel."""
if not _ECRANS:
_ECRANS.update(_build_screens())
return _ECRANS["help"]
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
)
[FIX] proxmox : quatre écrans qui parlaient d'une machine locale La confirmation de suppression promettait à TOUTE VM « son disque qcow2 EFFACÉ », puis nommait /var/lib/libvirt/images/<nom>.qcow2. Sur une VM Proxmox ce fichier n'existe pas — au mieux, au pire c'est celui d'une autre VM du même nom. C'est la peur exacte qui avait fait remonter le nettoyage. Elle nomme désormais l'hôte, le VMID et « qm destroy ». « Console de l'hyperviseur » lisait le port par « virsh vncdisplay ». Un Proxmox n'a pas de libvirt : l'échec se lisait « écran fermé » et on conseillait « sudo virsh edit » sur une machine sans ce binaire. Ce n'est pas un écran fermé, c'est la mauvaise question — Proxmox sert le sien par un ticket. Les deux vrais chemins sont nommés : la console série, l'interface web par tunnel. La colonne Odoo était un 🟢 acquis pour toujours : « Odoo ne redescend pas en cours d'install » est faux — le service redémarre au moins une fois, et il lui arrive de mourir. Elle est relue, gratuitement sur Proxmox, toutes les trente secondes ailleurs. Un hôte muet reste distinct d'un Odoo tombé. Enfin « Versions principales » (F7) manquait à l'écran Proxmox, qui affiche pourtant le même catalogue. Les trois gestes du catalogue vivent maintenant dans le socle du plan, où ils ne peuvent plus diverger. --- EN --- The delete confirmation promised EVERY VM "its qcow2 disk ERASED", then named /var/lib/libvirt/images/<name>.qcow2. On a Proxmox VM that file does not exist — at best; at worst it is another VM's, of the same name. That is the very fear that surfaced the cleanup report. It now names the host, the VMID and "qm destroy". "Hypervisor console" read the port through "virsh vncdisplay". Proxmox has no libvirt: the failure read as "screen closed" and we advised "sudo virsh edit" on a machine without that binary. It is not a closed screen, it is the wrong question — Proxmox serves its own by ticket. Both real paths are named: the serial console, the web interface through a tunnel. The Odoo column was a 🟢 acquired forever: "Odoo does not go back down during the install" is false — the service restarts at least once, and it does die. It is re-read, free on Proxmox, every thirty seconds elsewhere. A silent host stays distinct from a dead Odoo. Finally "Main versions" (F7) was missing from the Proxmox screen, which shows the same catalog. The catalog's three gestures now live in the plan foundation, where they can no longer diverge. Assisted-by: Claude Opus 5
2026-08-24 23:07:55 -04:00
# ---------------------------------------------------------------- #
# Le catalogue — les trois raccourcis de sélection
# ---------------------------------------------------------------- #
def _catalog(self):
from textual.widgets import SelectionList
return self.query_one("#f_catalog", SelectionList)
def action_select_all(self) -> None:
self._catalog().select_all()
def action_select_none(self) -> None:
self._catalog().deselect_all()
def action_select_main(self) -> None:
"""Une VM par distribution : la version marquée par défaut.
Ici et non dans un formulaire : les trois gestes sont les mêmes des
deux côtés, et celui-ci manquait à l'écran Proxmox — qui affiche
pourtant le même catalogue, drapeau « default » compris."""
widget = self._catalog()
widget.deselect_all()
for i, e in enumerate(self._entries()):
if e.get("default"):
widget.select(i)
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