diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index 723f4d4..f5979f4 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -43,6 +43,7 @@ if [[ ${#FILES[@]} -eq 0 ]]; then # Aucun argument : tout ce que le dépôt sait tester sans base de données. mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \ test/test_todo_*.py test/test_install_*.py test/test_run_*.py \ + test/test_proxmox_*.py \ 2>/dev/null) fi diff --git a/script/todo/README.base.md b/script/todo/README.base.md index 500f481..e2e5b2e 100644 --- a/script/todo/README.base.md +++ b/script/todo/README.base.md @@ -13,6 +13,40 @@ The `mail/` package is the mail client reachable from `Assistant > Mail`: several IMAP/SMTP accounts, a local cache, and a Textual TUI. See [../../doc/EMAIL.md](../../doc/EMAIL.md). +## Where the code lives + +`todo.py` carries the menus and the general helpers. Everything around a +single subject sits in its own file, and the whole thing is assembled by +mixins on the `TODO` class — one file, one subject, its header states its +boundary. + +| File | What it owns | +|------|--------------| +| `todo.py` | the menus, the configuration, the general helpers | +| `qemu_menu.py` | the QEMU/KVM menu, the image catalogue, the statistics | +| `qemu_deploy.py` | deciding then running a deployment | +| `qemu_install.py` | the recipes run inside a VM | +| `qemu_manage.py` | lifecycle, disks, hardware, cleanup, addresses | +| `qemu_access.py` | SSH, tunnels, consoles, Android emulator | +| `proxmox_menu.py` | the same, on a REMOTE Proxmox VE host | + +The two deployment forms — libvirt here, Proxmox over there — ask the same +questions, so they share a foundation rather than each holding a copy: + +| File | What it owns | +|------|--------------| +| `deploy_form_lib.py` | pure logic (sizes, plan, totals, spec), the shared CSS, the resource-row factory, the progress view | +| `deploy_form_plan.py` | the plan's gestures: overrides, locks, copies, renaming, free values | +| `qemu_deploy_form.py` | what QEMU/KVM adds: desktops, tools, branches, install profiles | +| `proxmox_deploy_form.py` | what Proxmox adds: host, storage, bridge, VMID, address | + +A form inherits `PlanMixin` and provides three hooks: which presets each +resource offers, the name a VM would fall back to, and what a lock freezes. +`test_todo_deploy_form_lib.py` fails if a form redefines a gesture the +foundation already carries — that is what keeps the architecture from drifting +back into two copies. + + TODO est un robot assistant pour utiliser ERPLibre Exécutez-le avec `./script/todo/todo.py` ou `make todo`. @@ -22,3 +56,37 @@ Pour un nouveau projet, copiez todo_example.json vers private/todo/todo_override Le paquet `mail/` est le client courriel accessible depuis `Assistant > Courriel` : plusieurs comptes IMAP/SMTP, un cache local, et un TUI Textual. Voir [../../doc/EMAIL.fr.md](../../doc/EMAIL.fr.md). + +## Où vit le code + +`todo.py` porte les menus et les aides générales. Tout ce qui tourne autour +d'un même sujet vit dans son fichier, et l'ensemble est assemblé par des +mixins sur la classe `TODO` — un fichier, un sujet, son en-tête dit sa +frontière. + +| Fichier | Ce qu'il porte | +|---------|----------------| +| `todo.py` | les menus, la configuration, les aides générales | +| `qemu_menu.py` | le menu QEMU/KVM, le catalogue d'images, les statistiques | +| `qemu_deploy.py` | décider puis exécuter un déploiement | +| `qemu_install.py` | les recettes exécutées DANS une VM | +| `qemu_manage.py` | cycle de vie, disques, matériel, nettoyage, adresses | +| `qemu_access.py` | SSH, tunnels, consoles, émulateur Android | +| `proxmox_menu.py` | la même chose, sur un hôte Proxmox VE DISTANT | + +Les deux formulaires de déploiement — libvirt ici, Proxmox ailleurs — posent +les mêmes questions : ils partagent donc un socle au lieu d'en garder chacun +une copie. + +| Fichier | Ce qu'il porte | +|---------|----------------| +| `deploy_form_lib.py` | la logique pure (tailles, plan, totaux, spec), le CSS commun, la fabrique des rangées de ressources, la vue de progression | +| `deploy_form_plan.py` | les gestes du plan : surcharges, verrous, exemplaires, renommage, valeurs libres | +| `qemu_deploy_form.py` | ce que QEMU/KVM ajoute : bureaux, outils, branches, profils d'installation | +| `proxmox_deploy_form.py` | ce que Proxmox ajoute : hôte, stockage, pont, VMID, adresse | + +Un formulaire hérite de `PlanMixin` et fournit trois crochets : les +préréglages de chaque ressource, le nom auquel une VM retombe, et ce qu'un +verrou fige. `test_todo_deploy_form_lib.py` échoue si un formulaire redit un +geste que le socle porte déjà — c'est ce qui empêche l'architecture de +retomber en deux copies. diff --git a/script/todo/README.fr.md b/script/todo/README.fr.md index e21ef79..3a386f4 100644 --- a/script/todo/README.fr.md +++ b/script/todo/README.fr.md @@ -6,4 +6,38 @@ Pour un nouveau projet, copiez todo_example.json vers private/todo/todo_override Le paquet `mail/` est le client courriel accessible depuis `Assistant > Courriel` : plusieurs comptes IMAP/SMTP, un cache local, et un -TUI Textual. Voir [../../doc/EMAIL.fr.md](../../doc/EMAIL.fr.md). \ No newline at end of file +TUI Textual. Voir [../../doc/EMAIL.fr.md](../../doc/EMAIL.fr.md). + +## Où vit le code + +`todo.py` porte les menus et les aides générales. Tout ce qui tourne autour +d'un même sujet vit dans son fichier, et l'ensemble est assemblé par des +mixins sur la classe `TODO` — un fichier, un sujet, son en-tête dit sa +frontière. + +| Fichier | Ce qu'il porte | +|---------|----------------| +| `todo.py` | les menus, la configuration, les aides générales | +| `qemu_menu.py` | le menu QEMU/KVM, le catalogue d'images, les statistiques | +| `qemu_deploy.py` | décider puis exécuter un déploiement | +| `qemu_install.py` | les recettes exécutées DANS une VM | +| `qemu_manage.py` | cycle de vie, disques, matériel, nettoyage, adresses | +| `qemu_access.py` | SSH, tunnels, consoles, émulateur Android | +| `proxmox_menu.py` | la même chose, sur un hôte Proxmox VE DISTANT | + +Les deux formulaires de déploiement — libvirt ici, Proxmox ailleurs — posent +les mêmes questions : ils partagent donc un socle au lieu d'en garder chacun +une copie. + +| Fichier | Ce qu'il porte | +|---------|----------------| +| `deploy_form_lib.py` | la logique pure (tailles, plan, totaux, spec), le CSS commun, la fabrique des rangées de ressources, la vue de progression | +| `deploy_form_plan.py` | les gestes du plan : surcharges, verrous, exemplaires, renommage, valeurs libres | +| `qemu_deploy_form.py` | ce que QEMU/KVM ajoute : bureaux, outils, branches, profils d'installation | +| `proxmox_deploy_form.py` | ce que Proxmox ajoute : hôte, stockage, pont, VMID, adresse | + +Un formulaire hérite de `PlanMixin` et fournit trois crochets : les +préréglages de chaque ressource, le nom auquel une VM retombe, et ce qu'un +verrou fige. `test_todo_deploy_form_lib.py` échoue si un formulaire redit un +geste que le socle porte déjà — c'est ce qui empêche l'architecture de +retomber en deux copies. \ No newline at end of file diff --git a/script/todo/README.md b/script/todo/README.md index 6f981e2..9ccb31a 100644 --- a/script/todo/README.md +++ b/script/todo/README.md @@ -7,3 +7,37 @@ For a new project, copy todo_example.json to private/todo/todo_override.json | p The `mail/` package is the mail client reachable from `Assistant > Mail`: several IMAP/SMTP accounts, a local cache, and a Textual TUI. See [../../doc/EMAIL.md](../../doc/EMAIL.md). + +## Where the code lives + +`todo.py` carries the menus and the general helpers. Everything around a +single subject sits in its own file, and the whole thing is assembled by +mixins on the `TODO` class — one file, one subject, its header states its +boundary. + +| File | What it owns | +|------|--------------| +| `todo.py` | the menus, the configuration, the general helpers | +| `qemu_menu.py` | the QEMU/KVM menu, the image catalogue, the statistics | +| `qemu_deploy.py` | deciding then running a deployment | +| `qemu_install.py` | the recipes run inside a VM | +| `qemu_manage.py` | lifecycle, disks, hardware, cleanup, addresses | +| `qemu_access.py` | SSH, tunnels, consoles, Android emulator | +| `proxmox_menu.py` | the same, on a REMOTE Proxmox VE host | + +The two deployment forms — libvirt here, Proxmox over there — ask the same +questions, so they share a foundation rather than each holding a copy: + +| File | What it owns | +|------|--------------| +| `deploy_form_lib.py` | pure logic (sizes, plan, totals, spec), the shared CSS, the resource-row factory, the progress view | +| `deploy_form_plan.py` | the plan's gestures: overrides, locks, copies, renaming, free values | +| `qemu_deploy_form.py` | what QEMU/KVM adds: desktops, tools, branches, install profiles | +| `proxmox_deploy_form.py` | what Proxmox adds: host, storage, bridge, VMID, address | + +A form inherits `PlanMixin` and provides three hooks: which presets each +resource offers, the name a VM would fall back to, and what a lock freezes. +`test_todo_deploy_form_lib.py` fails if a form redefines a gesture the +foundation already carries — that is what keeps the architecture from drifting +back into two copies. + diff --git a/script/todo/deploy_form_lib.py b/script/todo/deploy_form_lib.py index 147aab9..94cafff 100644 --- a/script/todo/deploy_form_lib.py +++ b/script/todo/deploy_form_lib.py @@ -291,6 +291,31 @@ def plan_rows(vms, domains, extra_disk_gb=0, orphelin=None): return rows +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" + + def plan_totals(rows): """Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de neuf) : (nb, vcpus, ram_mo, disque_go).""" @@ -370,6 +395,9 @@ CSS_BASE = """ } #totals { height: auto; color: $text-muted; padding: 0 1; } .grouptitle { color: $accent; text-style: bold; padding: 1 0 0 0; } +/* Une section que le choix d'installation rend inerte : le titre pâlit avec +ses champs, pour qu'elle se lise inactive d'un coup d'œil. */ +.grouptitle.off { color: $text-muted; text-style: none; } .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, diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py new file mode 100644 index 0000000..dd227c1 --- /dev/null +++ b/script/todo/proxmox_deploy_form.py @@ -0,0 +1,756 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Formulaire de déploiement sur un hôte Proxmox VE. + +Même écran que pour QEMU/KVM — catalogue à gauche, plan à droite, totaux +dessous — parce que c'est le même travail : choisir des systèmes, régler des +ressources, vérifier avant de lancer. Tout ce qui est commun vient de +`deploy_form_lib` (logique pure, socle CSS, fabrique des ressources) et de +`deploy_form_plan` (surcharges, verrous, exemplaires, renommage). Ne reste +ici que ce que Proxmox a en propre : + +* l'hôte, choisi AVANT d'ouvrir l'écran — il faut ssh et sudo, et une invite + de mot de passe pendant que Textual affiche casserait le terminal ; +* le stockage et le pont, LUS SUR L'HÔTE : « local-lvm » n'existe pas partout + et un pont inventé fait échouer « qm create » ; +* le VMID, et l'adresse qui s'en déduit sur un pont interne. + +Le formulaire ne touche à rien : il rend une spec. C'est l'appelant +(`ProxmoxMenuMixin._pve_deploy`) qui exécute. +""" + +import os +import re + +from script.todo.deploy_form_lib import ( + CSS_BASE, + FREE, + RES_FIELDS, + SELECT_TO_FIELD, + build_vms, + disk_note, + entry_key, + gib, + plan_rows, + plan_totals, + res_row_widgets, + t, +) +from script.todo.deploy_form_plan import PlanMixin, preview_screen + +# Aucun disque orphelin à craindre : les disques d'un Proxmox distant vivent +# dans un stockage que seul l'hôte connaît, jamais dans /var/lib/libvirt. +PAS_D_ORPHELIN = None + + +def assign_vmids(rows, used, start, ipconfig): + """Pose un VMID libre et son adresse sur chaque VM À CRÉER. + + Proxmox refuse un VMID déjà pris, et il le dit APRÈS le téléchargement de + l'image : on choisit donc avant, d'après ce que l'hôte déclare. Les VM qui + existent déjà sont sautées — elles ont le leur. + + `ipconfig(vmid)` rend la ligne cloud-init : « ip=dhcp » sur un pont qui + donne sur le LAN, une adresse fixe dérivée du VMID sur un pont interne. + """ + pris = {int(v) for v in used or () if str(v).isdigit()} + suivant = max(int(start or 0), 100) + for r in rows: + if r["state"] == "exists": + continue + while suivant in pris: + suivant += 1 + pris.add(suivant) + r["vm"]["vmid"] = suivant + r["vm"]["ipconfig"] = ipconfig(suivant) if ipconfig else "ip=dhcp" + suivant += 1 + return rows + + +def res_label(profile) -> str: + """Comment le plan nomme le réglage commun choisi.""" + return t("custom") if profile == "custom" else f"x{profile}" + + +def build_spec(vms, existants, form): + """La spec que le déploiement exécutera. Une VM qui existe déjà n'y entre + pas : Proxmox refuserait le VMID, et on ne veut surtout pas l'écraser.""" + connus = set(existants) + return { + "host": form["host"], + "storage": form["storage"], + "bridge": form["bridge"], + "res_label": form["res_label"], + "vms": [vm for vm in vms if vm["name"] not in connus], + "existing": [vm["name"] for vm in vms if vm["name"] in connus], + "ssh_key": form["ssh_key"], + "user": form.get("user") or "erplibre", + "start": form["start"], + "add_ssh_config": form["add_ssh_config"], + "install": form["install"], + "monitor": form["monitor"], + "parallelism": form["parallelism"], + } + + +def run_proxmox_form(ctx, run_app: bool = True): + """Formulaire Proxmox. Renvoie une spec, None si annulé, {} pour retomber + sur les invites textuelles. `run_app=False` rend l'instance sans la lancer + (tests headless).""" + from textual.app import App, ComposeResult + from textual.containers import Horizontal, Vertical, VerticalScroll + from textual.widgets import ( + Button, + Checkbox, + Footer, + Header, + Input, + RadioButton, + RadioSet, + Select, + SelectionList, + Static, + ) + + SELECT_NULL = getattr(Select, "NULL", Select.BLANK) + arches = ctx["arches"] + catalog = ctx["catalog"] + noms_pris = ctx["names"] + vmids_pris = ctx["vmids"] + branches = ctx.get("branches") or ["master"] + profiles = ctx.get("install_profiles") or [] + stockages = ctx.get("storages") or [] + ponts = ctx.get("bridges") or [] + # {système: (libellé, commande)} — ce qu'un système impose d'installer. + distro_profiles = ctx.get("distro_profiles") or {} + # Les commandes qui ne posent PAS ERPLibre : sa marge disque ne les suit + # pas. DÉDUITES des profils imposés — une seconde clé de contexte à tenir + # en accord avec la première aurait fini par en différer, et la marge + # serait revenue sans qu'on le voie. Jugé sur la commande effective de la + # rangée : un choix explicite compte donc autant que la règle du système. + no_erplibre = { + impose[1].strip() for impose in distro_profiles.values() if impose + } + + def entry_label(e): + return f"{e['distro']} {e['version']} [{e['arch']}] {e['name']}" + + class ProxmoxForm(PlanMixin, App): + TITLE = t("Deploy one or more ERPLibre VMs on Proxmox VE!") + BINDINGS = [ + ("f5", "deploy", t("Deploy")), + ("f4", "clear_vm", t("Reset VM")), + ("f3", "preview", t("Preview")), + ("f6", "select_all", t("All")), + ("f8", "select_none", t("None")), + ("escape", "cancel", t("Cancel")), + ] + # Le socle porte la mise en page et les modales ; ne reste ici que ce + # qui nomme les widgets propres à Proxmox. + CSS = ( + CSS_BASE + + """ + SelectionList { height: 10; border: solid $panel; } + RadioSet { height: auto; layout: horizontal; } + .vmrow Select.vmbranch { width: 34; } + #hostline { height: 1; color: $accent; padding: 0 1; } + """ + ) + + def __init__(self): + super().__init__() + self.arch = ctx.get("native") or arches[0] + self.profile = "1" + self.custom = {} + self.overrides = {} + self.locked = set() + self.copies = {} + self.rows = [] + self.vms = [] + self.result = None + # Génération des widgets de rangée : un message qui arrive d'un + # jeu périmé ne doit pas être pris pour une saisie. + self._gen = 0 + self._shown_ids = () + self._syncing = False + + # ---------------------------------------------------------------- # + # L'écran + # ---------------------------------------------------------------- # + def compose(self) -> ComposeResult: + yield Header() + hote = ctx["host"].get("label") or ctx["host"]["target"] + yield Static( + f" {t('Proxmox host')} : {hote}" + f" {t('node')} : {ctx.get('node') or '?'}", + id="hostline", + ) + with Horizontal(id="body"): + with VerticalScroll(id="fields"): + yield Static(t("Architecture"), classes="grouptitle") + with RadioSet(id="f_arch"): + for a in arches: + label = a if a != "all" else t("all archs") + yield RadioButton(label, value=a == self.arch) + yield Static(t("Catalog"), classes="grouptitle") + yield SelectionList(id="f_catalog") + yield Static( + t("Resources — applied to ALL VMs"), + classes="grouptitle", + ) + with RadioSet(id="f_profile"): + for label in ("x1", "x2", "x3", "x4"): + yield RadioButton(label, value=label == "x1") + yield RadioButton(t("custom")) + # Les mêmes trois ressources qu'ailleurs, montées par la + # même fabrique : « libre… » révèle la saisie du dessous. + for champ, presets, etiquette in ( + ("vcpus", ctx["cpu_presets"], t("vCPU")), + ("ram", ctx["ram_presets"], t("RAM: 2048 or 8G")), + ("disk", ctx["disk_presets"], t("Disk")), + ): + yield Select( + [ + ( + ( + f"{v // 1024}G" + if champ == "ram" + else str(v) + ), + v, + ) + for v in presets + ] + + [(t("free value…"), FREE)], + prompt=etiquette, + id=RES_FIELDS[champ][0][1:], + disabled=True, + ) + yield Input( + placeholder=etiquette, + id=RES_FIELDS[champ][1][1:], + classes="freeval", + disabled=True, + ) + yield Static(t("Proxmox VE"), classes="grouptitle") + yield Select( + [(s, s) for s in stockages], + value=( + ctx.get("storage") + or (stockages[0] if stockages else SELECT_NULL) + ), + prompt=t("Storage"), + allow_blank=not stockages, + id="f_storage", + ) + yield Select( + [(b, b) for b in ponts], + value=( + ctx.get("bridge") + or (ponts[0] if ponts else SELECT_NULL) + ), + prompt=t("Bridge"), + allow_blank=not ponts, + id="f_bridge", + ) + yield Static(f" {t('First VMID')}") + yield Input( + value=str(ctx.get("next_vmid") or 100), + placeholder="100", + id="f_vmid", + ) + yield Static(t("Access"), classes="grouptitle") + yield Static(f" {t('SSH public key')}") + yield Input( + value=ctx.get("ssh_key") or "", + placeholder="~/.ssh/id_ed25519.pub", + id="f_key", + ) + yield Checkbox( + t("Start the VM after creating it"), + value=True, + id="f_start", + ) + yield Checkbox( + t("Add an entry to ~/.ssh/config"), + value=True, + id="f_sshcfg", + ) + # La case commande TOUTE installation — ERPLibre, Odoo, + # mais aussi l'hyperviseur Proxmox VE d'une VM imbriquée. + # Nommée « ERPLibre », elle laissait croire qu'un système + # Proxmox s'installerait quand même. + yield Static( + t("Installation"), + id="t_install", + classes="grouptitle", + ) + yield Checkbox( + t("Install software in the VM"), + value=True, + id="f_install", + ) + yield Select( + [(lbl, i) for i, (lbl, _c) in enumerate(profiles)], + value=0 if profiles else SELECT_NULL, + allow_blank=not profiles, + id="f_profile_install", + ) + yield Select( + [(b, b) for b in branches], + value=branches[0], + allow_blank=False, + id="f_branch", + ) + # Hors de la section « Installation » : le suivi regarde la + # VM ARRIVER, même quand rien ne s'installe. Rangé dedans, + # il se serait grisé avec elle. + yield Static( + t("Monitoring and parallelism"), + classes="grouptitle", + ) + yield Checkbox( + t("Follow the installation (dashboard)"), + value=True, + id="f_monitor", + ) + yield Static(f" {t('Parallelism')}") + yield Select( + [(str(n), n) for n in (1, 2, 3, 4)], + value=1, + allow_blank=False, + id="f_par", + ) + with Vertical(id="right"): + yield VerticalScroll(id="plan") + yield Static("", id="totals") + with Horizontal(id="actions"): + yield Button(t("Deploy"), variant="primary", id="go") + yield Button(t("Text prompts"), id="prompts") + yield Button(t("Cancel"), id="no") + yield Footer() + + def on_mount(self) -> None: + self._reload_catalog() + self._sync_install_deps() + + # ---------------------------------------------------------------- # + # Le plan + # ---------------------------------------------------------------- # + def _entries(self): + return catalog.get(self.arch) or [] + + def _selected_entries(self): + choisis = set(self.query_one("#f_catalog", SelectionList).selected) + return [e for e in self._entries() if entry_key(e) in choisis] + + def _presets(self): + return { + "vcpus": ctx["cpu_presets"], + "ram": ctx["ram_presets"], + "disk": ctx["disk_presets"], + } + + def _reload_catalog(self) -> None: + liste = self.query_one("#f_catalog", SelectionList) + garde = set(liste.selected) + liste.clear_options() + for e in self._entries(): + cle = entry_key(e) + liste.add_option((entry_label(e), cle, cle in garde)) + self._recompute() + self._mount_rows() + + def _recompute(self) -> None: + entries = self._plan_entries() + self.vms = build_vms( + entries, + self.profile, + ctx["base_vcpus"], + ctx["host_cpu"], + self.custom, + self.overrides, + ) + # Ce qu'un système IMPOSE d'installer, posé sur le MODÈLE : le + # déploiement lit « install_cmd » VM par VM. + for vm in self.vms: + impose = distro_profiles.get(vm["distro"]) + if impose and not vm.get("install_cmd"): + vm["install_label"], vm["install_cmd"] = impose + self.rows = plan_rows( + self.vms, noms_pris, orphelin=lambda _n: False + ) + # Le supplément d'ERPLibre ne vaut que pour les VM qui l'auront + # vraiment : une VM Proxmox ne clonera pas le dépôt. + if self.query_one("#f_install", Checkbox).value: + commun = (self._install() or {}).get("cmd") or "" + for row in self.rows: + cmd_vm = row["vm"].get("install_cmd") or commun + if cmd_vm.strip() not in no_erplibre: + row["disk_gb"] += ctx.get("extra_disk_gb", 0) + for row, entry in zip(self.rows, entries): + cle = entry_key(entry) + row["custom"] = bool(self.overrides.get(cle)) + row["locked"] = cle in self.locked + assign_vmids( + self.rows, + vmids_pris, + self._vmid_start(), + lambda vmid: (ctx.get("ipconfig") or (lambda _v: "ip=dhcp"))( + self._bridge(), vmid + ), + ) + self._render_plan() + + def _vmid_start(self): + brut = self.query_one("#f_vmid", Input).value.strip() + return ( + int(brut) if brut.isdigit() else (ctx.get("next_vmid") or 100) + ) + + def _bridge(self): + valeur = self.query_one("#f_bridge", Select).value + return "" if valeur is SELECT_NULL else valeur + + def _storage(self): + valeur = self.query_one("#f_storage", Select).value + return "" if valeur is SELECT_NULL else valeur + + def _row_head(self, index, row): + """La ligne de titre du socle, plus ce que Proxmox ajoute : le + VMID et l'adresse. Les deux sont décidés ICI et pas par l'hôte — + les montrer avant de lancer est le seul moyen de les vérifier.""" + base = PlanMixin._row_head(self, index, row) + vm = row["vm"] + if row["state"] == "exists": + return base + adresse = (vm.get("ipconfig") or "").replace("ip=", "") + return f"{base} VMID {vm.get('vmid', '?')} {adresse}" + + def _mount_rows(self) -> None: + """(Re)construit le panneau droit. + + Le verrou couvre TOUT le montage : poser « value= » sur un Select + fait émettre un Changed à Textual, que on_select_changed prendrait + pour une saisie.""" + self._syncing = True + self._gen += 1 + plan = self.query_one("#plan", VerticalScroll) + plan.remove_children() + cartes = [] + for i, r in enumerate(self.rows): + vm = r["vm"] + cle = self._row_key(i) + item = self._plan_entries()[i] + rangee = Horizontal( + Button("+", id=f"p{i}", classes="vmcopy"), + Button("✎", id=f"r{i}", classes="vmcopy"), + Button( + "🔒" if cle in self.locked else "🔓", + id=f"l{i}", + variant=( + "success" if cle in self.locked else "default" + ), + classes="vmlock", + ), + *res_row_widgets( + i, + vm, + self._presets(), + labels={"vcpus": t("vCPU")}, + null=SELECT_NULL, + ), + ( + Button("−", id=f"m{i}", classes="vmcopy") + if item.get("instance") + else Static("", classes="vmcopy") + ), + classes="vmrow", + ) + cartes.append( + Vertical( + Static(self._row_head(i, r), id=f"h{i}"), + rangee, + classes=( + "vmcard locked" if cle in self.locked else "vmcard" + ), + ) + ) + + # Marque de génération sur CHAQUE widget : « walk_children() » ne + # voit rien avant le montage, les enfants attendent dans + # « _pending_children ». + def marquer(node): + node._el_gen = self._gen + for child in getattr(node, "_pending_children", None) or []: + marquer(child) + + for carte in cartes: + marquer(carte) + plan.mount_all(cartes) + self._shown_ids = self._row_ids() + self.call_after_refresh(self._after_mount_rows) + + def _after_mount_rows(self) -> None: + self._sync_free_inputs() + self._syncing = False + + def _render_plan(self) -> None: + for i, r in enumerate(self.rows): + try: + self.query_one(f"#h{i}", Static).update( + self._row_head(i, r) + ) + except Exception: + pass + n, cpu, ram, disque = plan_totals(self.rows) + libre = ctx.get("free_ram") or 0 + # La place du stockage CHOISI : elle change avec la liste, donc + # elle se relit à chaque rendu plutôt qu'une fois au montage. + place = gib((ctx.get("storage_avail") or {}).get(self._storage())) + alertes = [] + if libre and ram > libre: + alertes.append(t("more RAM than the host has free")) + if place and disque > place: + alertes.append(t("more disk than the storage has free")) + alerte = f" ⚠ {' · '.join(alertes)}" if alertes else "" + self.query_one("#totals", Static).update( + f" {n} {t('VM')} {cpu} vCPU {ram} Mo RAM " + f"{disk_note(disque, place)} {res_label(self.profile)}" + f" {t('storage')} {self._storage() or '?'}" + f" {t('bridge')} {self._bridge() or '?'}{alerte}" + ) + + def _refresh_after(self, remonter=False) -> None: + """Recalcule, et ne remonte les rangées que si le JEU a changé : + un remontage à chaque frappe volerait le focus.""" + self._recompute() + if remonter or self._row_ids() != self._shown_ids: + self._mount_rows() + else: + self._sync_free_inputs() + + # ---------------------------------------------------------------- # + # Les messages + # ---------------------------------------------------------------- # + def on_selection_list_selected_changed(self, _event) -> None: + self._refresh_after() + + def on_radio_set_changed(self, event) -> None: + if event.radio_set.id == "f_arch": + self.arch = arches[event.index] + self._reload_catalog() + return + if event.radio_set.id == "f_profile": + choix = ("1", "2", "3", "4", "custom")[event.index] + self.profile = choix + sur_mesure = choix == "custom" + for champ in RES_FIELDS: + self.query_one(RES_FIELDS[champ][0], Select).disabled = ( + not sur_mesure + ) + if not sur_mesure: + self._show_free(champ, False) + # Un réglage commun reprend la main sur les VM non figées : + # c'est le sens même du mot « commun ». + self._clear_overrides(tuple(RES_FIELDS)) + self._refresh_after(remonter=True) + + def _sync_install_deps(self) -> None: + """Grise ce que la case rend sans effet : la branche et le profil. + + Le suivi n'en fait pas partie — il regarde la VM arriver même + quand rien ne s'installe.""" + installe = self.query_one("#f_install", Checkbox).value + for cible in ("#f_profile_install", "#f_branch"): + try: + self.query_one(cible).disabled = not installe + except Exception: + pass + try: + self.query_one("#t_install").set_class(not installe, "off") + except Exception: + pass + + def on_checkbox_changed(self, event) -> None: + if event.checkbox.id == "f_install": + # Le disque d'ERPLibre entre — ou sort — du total. + self._sync_install_deps() + self._refresh_after() + + def on_input_changed(self, event) -> None: + if event.input.id == "f_vmid": + self._refresh_after() + + def on_input_submitted(self, event) -> None: + ident = event.input.id or "" + if ident in {RES_FIELDS[c][1][1:] for c in RES_FIELDS}: + self._apply_free(ident.split("_", 1)[1]) + self._refresh_after(remonter=True) + return + if ident.startswith("c") and "_" in ident: + rang, champ = ident[1:].split("_", 1) + if rang.isdigit(): + self._set_override( + int(rang), champ, self._read_row_free(int(rang), champ) + ) + self._refresh_after() + + def on_select_changed(self, event) -> None: + if self._syncing: + return + ident = event.select.id or "" + # La marque de génération ne concerne QUE les widgets de rangée : + # un widget global n'en porte pas. L'exiger de tous revenait à + # ignorer chaque réglage commun — mesuré, ni le stockage, ni la + # RAM générale n'atteignaient le plan. + if re.match(r"v\d+_", ident) and not self._is_current( + event.select + ): + return + if ident in ("f_storage", "f_bridge"): + self._refresh_after() + return + # Réglage commun : « libre… » révèle la saisie, une valeur + # s'applique à toutes les VM non figées. + champ = SELECT_TO_FIELD.get(ident) + if champ: + if event.value is FREE: + self._show_free(champ, True) + self.query_one(RES_FIELDS[champ][1], Input).focus() + elif event.value is not SELECT_NULL: + self._show_free(champ, False) + self.custom[champ] = event.value + self._clear_overrides((champ,)) + self._refresh_after(remonter=True) + return + # Réglage d'UNE rangée. + if ident.startswith("v") and "_" in ident: + rang, champ = ident[1:].split("_", 1) + if not rang.isdigit() or champ not in RES_FIELDS: + return + index = int(rang) + if event.value is FREE: + self._row_free(index, champ, True) + self._set_override( + index, champ, self._read_row_free(index, champ) + ) + elif event.value is not SELECT_NULL: + # L'écho du montage n'est pas une saisie : sans ce test, + # les trois champs de chaque VM se surchargeaient dès + # l'affichage et toutes les rangées portaient la marque ✎. + if self._row_echo(index, champ, event.value): + return + self._row_free(index, champ, False) + self._set_override(index, champ, event.value) + self._refresh_after() + + def on_button_pressed(self, event) -> None: + ident = event.button.id or "" + if ident == "go": + self.action_deploy() + elif ident == "no": + self.action_cancel() + elif ident == "prompts": + # Retour aux invites textuelles : {} n'est pas None, et + # l'appelant sait faire la différence entre « annulé » et + # « pose-moi les questions à l'ancienne ». + self.result = {} + self.exit() + elif ident.startswith("p") and ident[1:].isdigit(): + self._add_copy(int(ident[1:]), 1) + elif ident.startswith("m") and ident[1:].isdigit(): + self._add_copy(int(ident[1:]), -1) + elif ident.startswith("r") and ident[1:].isdigit(): + self._rename(int(ident[1:])) + elif ident.startswith("l") and ident[1:].isdigit(): + index = int(ident[1:]) + self._set_lock(index, self._row_key(index) not in self.locked) + + # ---------------------------------------------------------------- # + # Les actions + # ---------------------------------------------------------------- # + def _install(self): + if not self.query_one("#f_install", Checkbox).value: + return None + index = self.query_one("#f_profile_install", Select).value + label, cmd = ( + profiles[index] + if profiles and isinstance(index, int) + else ("", "") + ) + return { + "branch": self.query_one("#f_branch", Select).value, + "label": label, + "cmd": cmd, + } + + def _form_values(self): + cle = self.query_one("#f_key", Input).value.strip() + return { + "host": ctx["host"], + "storage": self._storage(), + "bridge": self._bridge(), + "res_label": res_label(self.profile), + "ssh_key": os.path.expanduser(cle) if cle else "", + "start": self.query_one("#f_start", Checkbox).value, + "add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value, + "install": self._install(), + # Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans + # ERPLibre se suit aussi (cloud-init, puis relevé système). + "monitor": self.query_one("#f_monitor", Checkbox).value, + "parallelism": self.query_one("#f_par", Select).value, + } + + def action_deploy(self) -> None: + spec = build_spec(self.vms, noms_pris, self._form_values()) + if not spec["vms"]: + self.notify(t("Nothing to deploy."), severity="warning") + return + if not spec["storage"]: + self.notify( + t("No storage able to hold a VM disk."), severity="error" + ) + return + if not spec["bridge"]: + self.notify(t("No bridge on the host."), severity="error") + return + self.result = spec + self.exit() + + def action_preview(self) -> None: + build = ctx.get("build_command") + if not build: + return + spec = build_spec(self.vms, noms_pris, self._form_values()) + lignes = ["\n".join(build(vm, spec)) for vm in spec["vms"]] or [ + t("Nothing selected.") + ] + self.push_screen(preview_screen()(lignes)) + + def action_clear_vm(self) -> None: + """Rend au réglage commun la VM sous le curseur (et son verrou).""" + index = self._focused_row() + if index is None: + return + cle = self._row_key(index) + self.locked.discard(cle) + self.overrides.pop(cle, None) + self._refresh_after(remonter=True) + + def action_select_all(self) -> None: + self.query_one("#f_catalog", SelectionList).select_all() + + def action_select_none(self) -> None: + self.query_one("#f_catalog", SelectionList).deselect_all() + + def action_cancel(self) -> None: + self.result = None + self.exit() + + app = ProxmoxForm() + if not run_app: + return app + app.run() + return app.result diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 6a31fcd..e7d4fd3 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -18,9 +18,10 @@ import shlex import subprocess import time +import click + from script.todo import todo_prefs from script.todo.todo_i18n import t -import click class ProxmoxMenuMixin: @@ -36,6 +37,9 @@ class ProxmoxMenuMixin: # la machine locale. Il faut donc d'abord SAVOIR OÙ, et le retenir — sans # quoi chacune des dix-sept commandes reposerait la question. _PVE_PREF_KEY = "proxmox_host" + # Le script qui transforme une Debian en hyperviseur. Autonome : il se + # laisse exécuter par un tube, sans être copié d'abord. + PVE_INSTALL_SCRIPT = "script/proxmox/install_proxmox.sh" def _pve_host(self, ask=True): """Hôte Proxmox retenu, ou None. Demande au besoin. @@ -141,9 +145,7 @@ class ProxmoxMenuMixin: def _pve_host_from_ssh_config(self): """Un alias de ~/.ssh/config : il porte déjà utilisateur, port et ProxyJump — rien à redemander, et le rebond traverse.""" - entrees = self._ssh_config_entries( - os.path.expanduser("~/.ssh/config") - ) + entrees = self._ssh_config_entries(os.path.expanduser("~/.ssh/config")) if not entrees: print(f"\n{t('No SSH hosts found in ~/.ssh/config')}") return None @@ -171,6 +173,45 @@ class ProxmoxMenuMixin: or "no ed25519 host key is known" in bas ) + @staticmethod + def _pve_clean_output(sortie): + """Les lignes de la sortie qui APPRENNENT quelque chose. + + « Warning: Permanently added … to the list of known hosts » arrive sur + stderr à chaque connexion d'un hôte en UserKnownHostsFile=/dev/null. + Affichée comme preuve d'un échec, elle envoyait chercher du côté de la + clé d'hôte un problème qui n'avait rien à voir — rapporté. + """ + gardees = [] + for ligne in (sortie or "").splitlines(): + nue = ligne.strip() + if not nue or nue.startswith("Warning: Permanently added"): + continue + gardees.append(nue) + return gardees + + def _pve_ssh_alive(self, host): + """(ssh passe-t-il ?, ce qu'il a dit) — sans rien exiger de la machine. + + C'est la question qu'il fallait poser AVANT de conclure : une machine + qui répond mais n'a pas Proxmox n'est pas « injoignable », et les deux + pannes ne se corrigent pas du même côté.""" + from script.proxmox import proxmox_deploy as pve + + code, out = pve.run(host, "true", timeout=20) + lignes = self._pve_clean_output(out) + return code == 0, (lignes[0] if lignes else t("no answer")) + + def _pve_install_hint(self, host): + """La commande qui poserait Proxmox VE sur cette machine. + + Le script du dépôt, poussé par le tube : il est autonome, donc + « bash -s » suffit et il n'y a rien à copier d'abord.""" + return ( + f"cat {self.PVE_INSTALL_SCRIPT} | " + f"ssh {host['target']} sudo bash -s" + ) + def _pve_add_hostkey(self, host): """Enregistre la clé d'hôte, après accord explicite. @@ -204,7 +245,9 @@ class ProxmoxMenuMixin: chemin = os.path.expanduser("~/.ssh/known_hosts") os.makedirs(os.path.dirname(chemin), exist_ok=True) with open(chemin, "a", encoding="utf-8") as fh: - fh.write(res.stdout if res.stdout.endswith("\n") else res.stdout + "\n") + fh.write( + res.stdout if res.stdout.endswith("\n") else res.stdout + "\n" + ) lignes = len(res.stdout.strip().splitlines()) print(f" ✓ {lignes} {t('key(s) recorded in ~/.ssh/known_hosts')}") return True @@ -229,10 +272,31 @@ class ProxmoxMenuMixin: code, out = pve.run(host, "pveversion", timeout=30) version = pve.parse_pveversion(out) if not version: - print(f" ✗ {t('Not a Proxmox host (or unreachable):')}") - premiere = (out or "").strip().splitlines() - print(f" {premiere[0] if premiere else t('no answer')}") - print(f" → {t('Check the address, the SSH access and pveversion.')}") + # Un seul message confondait deux pannes : « ou il est + # injoignable » envoyait vérifier le réseau alors que la machine + # répondait, et la seule ligne montrée était l'avertissement de + # ssh sur la clé d'hôte. On demande donc à ssh s'il passe. + joignable, detail = self._pve_ssh_alive(host) + # Ce que « pveversion » a répondu, et non ce que la sonde a dit : + # « command not found » est LA preuve utile. + dit = self._pve_clean_output(out) + if joignable: + print(f" ✗ {t('Reachable, but Proxmox VE is not there:')}") + print( + f" ssh {host['target']} : ok — pveversion : " + f"{dit[0] if dit else t('absent')}" + ) + print(f" → {t('Install it:')}") + print(f" {self._pve_install_hint(host)}") + print( + f" → {t('Or redeploy the VM with the hypervisor profile.')}" + ) + else: + print(f" ✗ {t('SSH does not get through:')}") + print(f" {detail}") + print( + f" → {t('Check the address, the SSH access and pveversion.')}" + ) return None # « qm » exige les privilèges. La voie « VM QEMU locale » donne # l'accès d'erplibre, pas de root : il faut donc sudo, et il faut le @@ -243,7 +307,9 @@ class ProxmoxMenuMixin: if qui.strip() != "0": code, _o = pve.run(host, "sudo -n true", timeout=20) if code: - print(f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}") + print( + f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}" + ) print(f" → {t('Connect as root@, or allow NOPASSWD sudo.')}") return None prefixe = "sudo " @@ -294,9 +360,7 @@ class ProxmoxMenuMixin: return [] if multiple else None print(f"\n{titre or t('VMs on this host:')}") for i, vm in enumerate(vms, 1): - print( - f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}" - ) + print(f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}") if multiple: print(f" [all] {t('select all')}") brut = input(t("Selection (number): ")).strip() @@ -389,9 +453,7 @@ class ProxmoxMenuMixin: if not vm: return print(f"\n ⚠ {t('Proxmox can only GROW a disk, never shrink it.')}") - taille = input( - t("Size (+10G to add, 40G for a target): ") - ).strip() + taille = input(t("Size (+10G to add, 40G for a target): ")).strip() if not re.match(r"^\+?\d+[MGT]$", taille): print(t("Invalid selection!")) return @@ -484,7 +546,9 @@ class ProxmoxMenuMixin: """Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un FICHIER là-bas, pas une clé en ligne.""" try: - with open(os.path.expanduser(chemin_local), encoding="utf-8") as fh: + with open( + os.path.expanduser(chemin_local), encoding="utf-8" + ) as fh: cle = fh.read().strip() except OSError as exc: print(f" ⚠ {t('SSH key unreadable:')} {exc}") @@ -524,11 +588,11 @@ class ProxmoxMenuMixin: print(" address /24") print(" gateway ") print(" bridge-ports ") - print(f" ⚠ {t('This moves the host address: do it from a console.')}") + print( + f" ⚠ {t('This moves the host address: do it from a console.')}" + ) return "" - _c, sortie = self._pve_show( - "ip -o -4 route show default", quiet=True - ) + _c, sortie = self._pve_show("ip -o -4 route show default", quiet=True) uplink = "" parts = (sortie or "").split() if "dev" in parts: @@ -628,12 +692,26 @@ class ProxmoxMenuMixin: "next_vmid": pve.next_vmid(vms), "storages": [s["name"] for s in stockages if s.get("actif")], "storage": pve.pick_storage(stockages), + # La place libre par stockage, en octets : « pvesm status » la + # donne dans la même sortie, donc l'écran peut dire si le plan + # rentre sans un aller-retour de plus vers l'hôte. + "storage_avail": { + s["name"]: s.get("avail") or 0 for s in stockages + }, "bridges": ponts, "bridge": pve.pick_bridge(ponts), "ipconfig": ipconfig, "build_command": build_command, "branches": self._qemu_branch_list() or ["master"], "install_profiles": self._qemu_install_profiles(), + # Même règle qu'en QEMU/KVM : un système peut IMPOSER ce qu'on + # installe dessus. Un Proxmox imbriqué recevait sinon ERPLibre et + # Odoo 18, comme l'écran d'à côté avant correction. + "distro_profiles": { + d: self._qemu_distro_profile(d) + for d in self._QEMU_DISTRO_PROFILE + if self._qemu_distro_profile(d) + }, "ssh_key": self._qemu_default_ssh_key(), "cpu_presets": self._QEMU_CPU_PRESETS, "ram_presets": self._QEMU_RAM_PRESETS, @@ -706,6 +784,9 @@ class ProxmoxMenuMixin: from script.proxmox import proxmox_deploy as pve from script.todo.deploy_form_lib import run_deploy_progress + if not dry_run and not self._pve_confirm_spec(host, spec): + print(t("Cancelled.")) + return cle_locale = spec.get("ssh_key") or self._qemu_default_ssh_key() if cle_locale and not dry_run: if self._pve_push_key(cle_locale): @@ -738,6 +819,8 @@ class ProxmoxMenuMixin: return if spec["existing"]: print(f" ⏭ {t('already there')} : {', '.join(spec['existing'])}") + if not travaux: + return resultats = run_deploy_progress(travaux, spec.get("parallelism") or 1) reussies = [nom for nom, code, _o, _d in resultats if code == 0] for nom, code, sortie, _duree in resultats: @@ -748,6 +831,32 @@ class ProxmoxMenuMixin: return self._pve_after_create(host, spec, reussies, cle_locale) + def _pve_confirm_spec(self, host, spec): + """Récapitulatif puis confirmation, dans le TERMINAL. + + L'écran a montré le plan, mais c'est ici que ça devient réel — et sur + une machine qui n'est pas la nôtre. La ligne dit donc où, quoi, et + combien, avant le mot de passe sudo que l'hôte va demander.""" + print(f"\n {t('Proxmox host')} : {self._pve_label(host)}") + print( + f" {t('storage')} {spec['storage']} " + f"{t('bridge')} {spec['bridge']} [{spec['res_label']}]" + ) + for vm in spec["vms"]: + print( + f" {vm['name']:32} {t('VMID')} {vm['vmid']} " + f"{vm['vcpus']} vCPU {vm['ram']} Mo {vm['disk']} " + f"{(vm.get('ipconfig') or '').replace('ip=', '')}" + ) + if spec.get("install"): + print( + f" ERPLibre : {spec['install'].get('label') or ''}" + f" ({spec['install'].get('branch')})" + ) + return self._is_yes_default_yes( + input(f"\n{t('Deploy this VM now? (Y/n): ')}") + ) + def _pve_after_create(self, host, spec, reussies, cle_locale): """Ce qui suit la création : l'adresse, ~/.ssh/config, l'installation. @@ -765,7 +874,9 @@ class ProxmoxMenuMixin: print(f"\n {t('Waiting for the VM address…')} {vm['name']}") ip = self._pve_guest_ip(vm["vmid"]) if not ip: - print(f" ⚠ {vm['name']} : {t('No address yet. Try [6] later.')}") + print( + f" ⚠ {vm['name']} : {t('No address yet. Try [6] later.')}" + ) continue print(f" ✓ {vm['name']} : {ip}") if spec.get("add_ssh_config"): @@ -783,11 +894,18 @@ class ProxmoxMenuMixin: return noms = [vm["name"] for vm in joignables] print(f" {install.get('label') or ''}") + # Une commande PAR VM dès qu'elles diffèrent : un Proxmox imbriqué + # installe son hyperviseur, ses voisines ERPLibre. Une commande + # unique en aurait imposé une aux deux. + commun = install.get("cmd") or "" + cartes = { + vm["name"]: (vm.get("install_cmd") or commun) for vm in joignables + } self._qemu_install_erplibre_monitored( noms, install.get("branch") or "master", {n: n for n in noms}, - install.get("cmd") or "", + cartes if self._qemu_per_vm(cartes, commun) else commun, ) def _pve_deploy_prompts(self, dry_run=False): @@ -807,9 +925,10 @@ class ProxmoxMenuMixin: distro = self._qemu_prompt_distro() version = self._qemu_prompt_version(distro) arch = "amd64" - nom = input( - t("VM name (default: erplibre-): ") - ).strip() or f"erplibre-{distro}" + nom = ( + input(t("VM name (default: erplibre-): ")).strip() + or f"erplibre-{distro}" + ) memoire = ( self._qemu_ask_ram(t("RAM in MB, blank = 4096"), 4096) or 4096 ) @@ -852,7 +971,9 @@ class ProxmoxMenuMixin: # l'afficher avant de l'avoir choisi ne pouvait pas marcher. vmid = pve.next_vmid(self._pve_vms()) ipconfig = pve.ipconfig_for(infos_ponts.get(pont, {}), vmid) - print(f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})") + print( + f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})" + ) print(f" {t('bridge')} : {pont}") print(f" {t('address')} {ipconfig}") print(f" VMID : {vmid}") @@ -957,7 +1078,9 @@ class ProxmoxMenuMixin: identity_file=cle, proxy_jump=host["target"], ) - print(f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})") + print( + f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})" + ) def _pve_test_vm(self): """Ouvre Odoo (:8069) d'une VM Proxmox dans un navigateur en ligne. @@ -975,7 +1098,9 @@ class ProxmoxMenuMixin: return if not self._qemu_ip_reachable(ip, port=8069, timeout=3): print(f"\n ⚠ {ip}:8069 {t('unreachable from here.')}") - print(f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}") + print( + f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}" + ) return navigateur = self._qemu_choose_cli_browser() if not navigateur: @@ -1000,7 +1125,9 @@ class ProxmoxMenuMixin: "sshkey_path": "/root/.ssh/erplibre-deploy.pub", } print(f"\n── {t('Example: demo-vm, Debian 13, on a Proxmox host')} ──") - print(f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}") + print( + f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}" + ) for cmd in pve.create_cmds(101, spec): print(f" {cmd}") @@ -1059,8 +1186,16 @@ class ProxmoxMenuMixin: "SSH configuration (~/.ssh/config, ProxyJump)" ) }, - {"prompt_description": t("Remote desktop tunnel (VNC/RDP over SSH)")}, - {"prompt_description": t("Android emulator (start, tunnel, scrcpy)")}, + { + "prompt_description": t( + "Remote desktop tunnel (VNC/RDP over SSH)" + ) + }, + { + "prompt_description": t( + "Android emulator (start, tunnel, scrcpy)" + ) + }, {"section": t("Catalog")}, {"prompt_description": t("List available images and their specs")}, {"prompt_description": t("Proxmox - example sequence (dry-run)")}, diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index dbfa4d5..270d0b3 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -4,11 +4,11 @@ """Menu QEMU/KVM : d\u00e9cider et lancer un d\u00e9ploiement.\n\nLe chemin complet d'une cr\u00e9ation : les ressources (pr\u00e9r\u00e9glages vCPU/RAM/disque\net saisie libre), le plan et son r\u00e9capitulatif, les v\u00e9rifications de l'h\u00f4te\n(groupe libvirt, KVM), le contexte du formulaire TUI, la collecte en ligne, et\nl'ex\u00e9cution d'une spec \u2014 la M\u00caME structure quelle que soit l'interface, ce qui\npermet aux invites et au formulaire de partager tout le reste.\n\nFronti\u00e8re claire : ici on d\u00e9cide ; dans qemu_install.py on \u00e9crit ce qui sera\nex\u00e9cut\u00e9 dans l'invit\u00e9.""" import getpass +import grp import json import os import re import shlex -import grp import subprocess import time @@ -787,6 +787,17 @@ class QemuDeployMixin: input(f"{t('Continue despite these collisions? (y/N): ')}") ) + @staticmethod + def _qemu_per_vm(carte, commun): + """Faut-il une valeur PAR VM, ou le choix commun suffit-il ? + + « len(set) > 1 » ne suffisait pas : UNE seule VM qui porte sa propre + valeur donne un ensemble d'un élément, et tout le parc retombait alors + sur le choix commun. Déployée seule, une VM Proxmox recevait ainsi + ERPLibre et Odoo 18 — le défaut qu'on venait de corriger dans le + formulaire, réintroduit à l'exécution. Même piège pour la branche.""" + return bool(carte) and set(carte.values()) != {commun} + def _qemu_print_recap(self, spec, existing): """État final soumis à approbation : tout ce qui va changer sur l'hôte, y compris ce qui ne changera PAS (VM existantes).""" @@ -798,7 +809,12 @@ class QemuDeployMixin: # Le disque annoncé est celui qui sera réellement créé : ERPLibre # ajoute ERPLIBRE_EXTRA_DISK_GB à la demande initiale. gigs = self._parse_disk_gb(vm["disk"]) + ( - self.ERPLIBRE_EXTRA_DISK_GB if branch else 0 + self.ERPLIBRE_EXTRA_DISK_GB + if self._qemu_installs_erplibre( + branch, + vm.get("install_cmd") or (install or {}).get("cmd") or "", + ) + else 0 ) # Ce qui S'ECARTE du choix commun se dit sur la ligne de la VM. # Sans cela le sommaire annoncait le profil general pour tout le @@ -850,11 +866,11 @@ class QemuDeployMixin: br_txt = used_br.pop() if len(used_br) == 1 else varies lb_txt = used_lb.pop() if len(used_lb) == 1 else varies print( - f" {t('ERPLibre install:')} {t('branch')} {br_txt}, " + f" {t('Install:')} {t('branch')} {br_txt}, " f"{t('profile')} {lb_txt}, {env}" ) else: - print(f" {t('ERPLibre install:')} {t('no')}") + print(f" {t('Install:')} {t('no')}") flavour = spec.get("desktop") if flavour: label = (self._QEMU_DESKTOP.get(flavour) or {}).get( @@ -942,13 +958,14 @@ class QemuDeployMixin: # avec la section ERPLibre seulement là où ERPLibre sera installé — une # VM déployée nue n'annonce pas un dépôt qui n'existe pas. parts += ["--lang", get_lang()] - if branch: + pose_erplibre = self._qemu_installs_erplibre(branch, install_cmd) + if pose_erplibre: parts += ["--erplibre-dir", self._qemu_guide_dir(prod)] target = self._qemu_make_target(install_cmd) if target: parts += ["--erplibre-make", target] extra = 0 - if branch: + if pose_erplibre: # ERPLibre dépasse le minimum : +5 Go de disque. extra += self.ERPLIBRE_EXTRA_DISK_GB if desktop: @@ -960,8 +977,11 @@ class QemuDeployMixin: # ici plutôt qu'au petit bonheur, sinon l'installation se termine sur un # disque plein après une heure. extra += self._qemu_tools_disk_gb(vm_tools, arch, desktop, d) - if extra: - bigger = self._parse_disk_gb(disk) + extra + # TOUJOURS, même sans supplément : sans le drapeau, deploy_qemu.py + # reprend la taille par défaut du catalogue. Une VM réglée à 60 G mais + # sans rien à installer repartait donc à 20 G, en silence. + bigger = self._parse_disk_gb(disk) + extra + if bigger: parts += ["--disk-size", f"{bigger}G"] parts.append("--dry-run" if dry_run else "-y") return parts @@ -997,7 +1017,13 @@ class QemuDeployMixin: # Les deux servent au guide de connexion : où ERPLibre sera posé, et # quelle cible make le remettra à jour. prod=bool(install and install.get("prod")), - install_cmd=(install or {}).get("cmd") or "", + # La commande DE CETTE VM, pas seulement celle du formulaire : + # elle décide de la marge disque et de ce que le guide annonce. + # Une VM Proxmox recevait sinon les cinq gigaoctets d'ERPLibre et + # une cible make qu'elle n'aurait jamais. + install_cmd=( + vm.get("install_cmd") or (install or {}).get("cmd") or "" + ), vm_tools=spec.get("vm_tools") or (), ) @@ -1215,6 +1241,7 @@ class QemuDeployMixin: catalog[a] = entries print(f"\n{t('Loading (VM list, branches)...')}") + disque_libre, disque_total = self._host_disk_gb() return { "catalog": catalog, "arches": arches, @@ -1222,10 +1249,23 @@ class QemuDeployMixin: "domains": self._qemu_list_domains(), "branches": self._qemu_branch_list() or ["master"], "install_profiles": self._qemu_install_profiles(), + # Les systèmes qui IMPOSENT ce qu'on installe dessus. Sans cette + # table, le formulaire posait ERPLibre + Odoo 18 sur une VM + # Proxmox — l'invite en ligne, elle, savait déjà l'éviter. + "distro_profiles": { + d: self._qemu_distro_profile(d) + for d in self._QEMU_DISTRO_PROFILE + if self._qemu_distro_profile(d) + }, "ssh_key": self._qemu_default_ssh_key(), "timezone": self._qemu_host_timezone(), "host_cpu": os.cpu_count() or 2, "free_ram": self._host_free_ram_mb(), + # La place du système de fichiers qui portera les qcow2. Mesurée + # ICI, comme le reste : une lecture disque pendant que Textual + # affiche n'a pas sa place. + "free_disk": disque_libre, + "total_disk": disque_total, "base_vcpus": self._QEMU_BASE_VCPUS, "cpu_presets": self._QEMU_CPU_PRESETS, "ram_presets": self._QEMU_RAM_PRESETS, @@ -1248,6 +1288,13 @@ class QemuDeployMixin: "vm_tool_desktops": { k: v["desktops"] for k, v in self._QEMU_VM_TOOLS.items() }, + # « after » = l'outil vit DANS le dépôt ERPLibre (compilation + # mobile, AVD, script Forgejo) : sans installation, il n'existe + # pas, et la commande distante le saute en le nommant. + "vm_tool_phases": { + k: v.get("phase", "before") + for k, v in self._QEMU_VM_TOOLS.items() + }, "vm_tool_needs_desktop": { k: v["needs_desktop"] for k, v in self._QEMU_VM_TOOLS.items() }, @@ -1924,14 +1971,14 @@ class QemuDeployMixin: } for _n in deployed: branch_map.setdefault(_n, install_branch or "") - branch_multi = len(set(branch_map.values())) > 1 + branch_multi = self._qemu_per_vm(branch_map, install_branch or "") base_cmd = install["cmd"] if install else None cmd_map = { vm["name"]: (vm.get("install_cmd") or base_cmd) for vm in pending } for _n in deployed: cmd_map.setdefault(_n, base_cmd) - cmd_multi = len(set(cmd_map.values())) > 1 + cmd_multi = self._qemu_per_vm(cmd_map, base_cmd) ssh_key = spec.get("ssh_key") add_ssh_config = spec["add_ssh_config"] parallelism = spec["parallelism"] diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 4f807c0..71b9e1d 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -47,9 +47,11 @@ from script.todo.deploy_form_lib import ( # noqa: F401 clip_payload, copy_name, disk_gb, + disk_note, entry_key, expand_copies, fmt_dur, + gib, parse_disk, parse_ram, plan_rows, @@ -104,9 +106,21 @@ def run_deploy_form(ctx, run_app: bool = True): arches = ctx["arches"] domains = set(ctx.get("domains") or []) profiles = ctx.get("install_profiles") or [] + # {système: (libellé, commande)} — ce qu'un système impose d'installer. + distro_profiles = ctx.get("distro_profiles") or {} + # Les commandes qui ne posent PAS ERPLibre : sa marge disque ne les suit + # pas. DÉDUITES des profils imposés — une seconde clé de contexte à tenir + # en accord avec la première aurait fini par en différer, et la marge + # serait revenue sans qu'on le voie. Jugé sur la commande effective de la + # rangée : un choix explicite compte donc autant que la règle du système. + no_erplibre = { + impose[1].strip() for impose in distro_profiles.values() if impose + } branches = ctx.get("branches") or ["master"] host_cpu = ctx.get("host_cpu") or 2 free_ram = ctx.get("free_ram") or 0 + free_disk = ctx.get("free_disk") or 0 + total_disk = ctx.get("total_disk") or 0 base_vcpus = ctx.get("base_vcpus") or 2 extra_disk = ctx.get("extra_disk_gb") or 0 desktop_disk = ctx.get("desktop_disk_gb") or 0 @@ -120,6 +134,9 @@ def run_deploy_form(ctx, run_app: bool = True): # que les afficher et rendre les cases cochées. vm_tools = list(ctx.get("vm_tools") or []) tool_disk = dict(ctx.get("vm_tool_disk") or {}) + # « after » = l'outil vit DANS le dépôt ERPLibre : sans installation, il + # n'a rien où s'installer, bureau ou pas. + tool_phases = dict(ctx.get("vm_tool_phases") or {}) tool_arches = dict(ctx.get("vm_tool_arches") or {}) tool_desktops = dict(ctx.get("vm_tool_desktops") or {}) tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {}) @@ -297,31 +314,19 @@ def run_deploy_form(ctx, run_app: bool = True): f"{t('Graphical (server + desktop):')} {label}", value=defaults.get("desktop", "") == key, ) - if app_stores: - yield Static( - t("Application store:"), classes="grouptitle" - ) - with RadioSet(id="f_store"): - for i, (_k, label) in enumerate(app_stores): - yield RadioButton(label, value=i == 0) - yield Static("", id="storewarn") - if vm_tools: - # Une case par outil, et non une liste déroulante : ils - # sont indépendants, et chacun se prend ou se laisse. - yield Static( - t("Development tools:"), classes="grouptitle" - ) - for key, label, hint in vm_tools: - gb = tool_disk.get(key, 0) - yield Checkbox( - f"{label} +{gb} Go — {hint}", - value=key in (defaults.get("tools") or ()), - id=f"f_tool_{key}", - ) - yield Static("", id="toolwarn") - yield Static("ERPLibre", classes="grouptitle") + # La case commande TOUTE installation — ERPLibre, Odoo, mais + # aussi l'hyperviseur Proxmox VE. Nommée « ERPLibre », elle + # laissait croire qu'un système Proxmox s'installerait + # quand même : rapporté, une VM Proxmox est restée une + # Debian nue. Placée SOUS le type de VM, juste avant les + # sections qu'elle commande. + yield Static( + t("Installation"), + id="t_install", + classes="grouptitle", + ) yield Checkbox( - t("Install ERPLibre"), + t("Install software in the VM"), value=defaults.get("install", True), id="f_install", ) @@ -344,11 +349,32 @@ def run_deploy_form(ctx, run_app: bool = True): value=defaults.get("prod", False), id="f_prod", ) - yield Checkbox( - t("Monitoring dashboard"), - value=defaults.get("monitor", True), - id="f_monitor", - ) + if app_stores: + yield Static( + t("Application store:"), + id="t_store", + classes="grouptitle", + ) + with RadioSet(id="f_store"): + for i, (_k, label) in enumerate(app_stores): + yield RadioButton(label, value=i == 0) + yield Static("", id="storewarn") + if vm_tools: + # Une case par outil, et non une liste déroulante : ils + # sont indépendants, et chacun se prend ou se laisse. + yield Static( + t("Development tools:"), + id="t_tools", + classes="grouptitle", + ) + for key, label, hint in vm_tools: + gb = tool_disk.get(key, 0) + yield Checkbox( + f"{label} +{gb} Go — {hint}", + value=key in (defaults.get("tools") or ()), + id=f"f_tool_{key}", + ) + yield Static("", id="toolwarn") yield Static(t("Timezone"), classes="grouptitle") # Une liste plutôt qu'une saisie : un nom IANA mal # orthographié n'est pas refusé par cloud-init, il est @@ -383,7 +409,9 @@ def run_deploy_form(ctx, run_app: bool = True): # Grisé quand AUCUNE des VM retenues n'est sur une # architecture que mise sert. yield Static( - t("Python interpreter:"), classes="grouptitle" + t("Python interpreter:"), + id="t_python", + classes="grouptitle", ) with RadioSet(id="f_python"): yield RadioButton( @@ -391,7 +419,25 @@ def run_deploy_form(ctx, run_app: bool = True): ) yield RadioButton(t("pyenv (compiles from source)")) yield Static("", id="miswarn") - yield Static(t("Parallelism"), classes="grouptitle") + # Hors de la section « Installation » : le suivi + # regarde la VM ARRIVER, même quand rien ne s'installe. + # Rangé dans cette section, il se serait grisé avec elle — + # et décocher ERPLibre avait déjà fait disparaître le + # tableau de bord une fois. + yield Static( + t("Monitoring and parallelism"), + id="t_deploy", + classes="grouptitle", + ) + yield Checkbox( + t("Monitoring dashboard"), + value=defaults.get("monitor", True), + id="f_monitor", + ) + # Le parallélisme reste dans « Déploiement » : c'est le + # nombre de VM menées de front, pas une option + # d'installation. + yield Static(f" {t('Parallelism')}") # Cochée, la case donne une exécution PAR installation : # le plafond du nombre de CPU ne s'applique plus. Décochée, # le nombre reprend la main, et son défaut suit l'hôte — @@ -419,6 +465,7 @@ def run_deploy_form(ctx, run_app: bool = True): def on_mount(self) -> None: self.title = t("Deploy ERPLibre VM(s)!") self._reload_catalog(first_load=True) + self._sync_install_deps() # -- catalogue et recalcul ------------------------------------- # def _entries(self): @@ -493,7 +540,9 @@ def run_deploy_form(ctx, run_app: bool = True): "disk": vm["disk"], "desktop": vm.get("desktop") or "", "branch": vm.get("branch") or self._branch(), - "install_cmd": vm.get("install_cmd") or self._profile_cmd(), + "install_cmd": ( + vm.get("install_cmd") or self._row_default_cmd(index) + ), "install_label": ( vm.get("install_label") or ( @@ -516,16 +565,33 @@ def run_deploy_form(ctx, run_app: bool = True): self._default_desktop(), desktop_suffixes, ) + # Ce qu'un système IMPOSE d'installer, posé sur le MODÈLE et pas + # seulement à l'écran : le déploiement lit « install_cmd » VM par + # VM, et une VM Proxmox laissée à vide recevait la commande + # commune — donc ERPLibre et Odoo 18 sur un hyperviseur. + for vm in self.vms: + impose = distro_profiles.get(vm["distro"]) + if impose and not vm.get("install_cmd"): + vm["install_label"], vm["install_cmd"] = ( + impose[0], + impose[1], + ) + self.rows = plan_rows(self.vms, domains) # ERPLibre et GNOME pèsent chacun sur le disque, et se cumulent. - grow = 0 - if self.query_one("#f_install", Checkbox).value: - grow += extra_disk - self.rows = plan_rows(self.vms, domains, grow) + # Le supplément d'ERPLibre ne vaut que pour les VM qui l'auront + # VRAIMENT : l'ajouter à une VM Proxmox gonflait son disque de + # cinq gigaoctets pour un dépôt qu'elle ne clonera pas. + installe = self.query_one("#f_install", Checkbox).value # Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle # seule : un supplément commun mentait dès que les types # différaient d'une machine à l'autre. tools = self._vm_tools() - for row in self.rows: + for i, row in enumerate(self.rows): + cmd_vm = ( + row["vm"].get("install_cmd") or self._profile_cmd() or "" + ) + if installe and cmd_vm.strip() not in no_erplibre: + row["disk_gb"] += extra_disk if row["vm"].get("desktop"): row["disk_gb"] += desktop_disk # Même règle pour les outils, et pour la même raison : ils ne @@ -596,10 +662,16 @@ def run_deploy_form(ctx, run_app: bool = True): évite de le découvrir dans le journal d'installation.""" if not vm_tools: return + installe, quelque_chose = self._install_state() for key, _label, _hint in vm_tools: usable = any(self._tools_for_vm(vm, (key,)) for vm in self.vms) - self.query_one(f"#f_tool_{key}", Checkbox).disabled = ( - not usable + offert = ( + installe + if tool_phases.get(key) == "after" + else quelque_chose + ) + self.query_one(f"#f_tool_{key}", Checkbox).disabled = not ( + usable and offert ) picked = self._vm_tools() skipped = sorted( @@ -621,7 +693,10 @@ def run_deploy_form(ctx, run_app: bool = True): """Grise le choix quand aucune VM retenue n'est servie par mise, et nomme les architectures qui retomberont sur pyenv.""" usable = self._mise_usable() - self.query_one("#f_python", RadioSet).disabled = not usable + installe, _quelque_chose = self._install_state() + self.query_one("#f_python", RadioSet).disabled = not ( + usable and installe + ) skipped = sorted( { vm["arch"] @@ -674,13 +749,25 @@ def run_deploy_form(ctx, run_app: bool = True): if not app_stores: return needed = self._app_store_needed() - self.query_one("#f_store", RadioSet).disabled = not needed + _installe, quelque_chose = self._install_state() + self.query_one("#f_store", RadioSet).disabled = not ( + needed and quelque_chose + ) self.query_one("#storewarn", Static).update( "" if needed else f" {t('No graphical VM on a snap-based distro.')}" ) + def _install_state(self): + """(une installation ?, quelque chose à installer ?). + + Deux réponses et non une : sans installation mais avec un bureau, + il se pose encore des paquets — le magasin d'applications et les + outils de la phase « avant » gardent un effet.""" + installe = self.query_one("#f_install", Checkbox).value + return installe, bool(installe or self._default_desktop()) + def _mise_usable(self): return any(vm["arch"] in mise_arches for vm in self.vms) @@ -691,12 +778,25 @@ def run_deploy_form(ctx, run_app: bool = True): index = self.query_one("#f_profile_install", Select).value return profiles[index if isinstance(index, int) else 0][1] + def _row_default_cmd(self, i): + """Commande qu'une rangée prend d'elle-même : celle que son + système impose, sinon le choix commun d'en haut. + + C'est le défaut CONTRE LEQUEL on compare une saisie : sur une VM + Proxmox, choisir Odoo 18 est une vraie surcharge même quand c'est + aussi la valeur commune.""" + if i < len(self.rows): + impose = distro_profiles.get(self.rows[i]["vm"]["distro"]) + if impose: + return impose[1] + return self._profile_cmd() + def _row_profile_index(self, i): """Rang du profil que la rangée doit AFFICHER.""" cmd = "" if i < len(self.rows): cmd = self.rows[i]["vm"].get("install_cmd") or "" - cmd = cmd or self._profile_cmd() + cmd = cmd or self._row_default_cmd(i) for k, (_lbl, c) in enumerate(profiles): if c == cmd: return k @@ -847,6 +947,7 @@ def run_deploy_form(ctx, run_app: bool = True): self.call_after_refresh(self._after_mount_rows) def _after_mount_rows(self) -> None: + self._sync_install_deps() self._sync_free_inputs() self._syncing = False @@ -895,6 +996,52 @@ def run_deploy_form(ctx, run_app: bool = True): pass self._sync_free_inputs() + def _sync_install_deps(self) -> None: + """Grise ce que le choix d'installation rend SANS EFFET. + + Trois états et non deux, parce que la commande distante en a + trois : rien du tout, un bureau seul, ou une installation + complète. Sans installation MAIS avec un bureau, le magasin + d'applications et les outils de la phase « avant » servent encore + — les griser mentirait autant que de laisser actif ce qui ne fait + rien. La branche, le profil et l'interpréteur Python, eux, ne + servent qu'à l'installation. + + Le type de VM et le suivi ne sont jamais grisés : le premier est + l'autre moitié de la décision, le second regarde la VM arriver + même quand rien ne s'installe.""" + installe, quelque_chose = self._install_state() + for cible, actif in ( + ("#f_branch", installe), + ("#f_profile_install", installe), + ("#f_prod", quelque_chose), + ): + try: + self.query_one(cible).disabled = not actif + except Exception: + pass + # Le magasin, les outils et l'interpréteur Python ont leur PROPRE + # raison de se griser (architecture, bureau, distribution) : ils + # composent les deux dans « _render_* », qui a le dernier mot. + # Le titre suit ses champs : une section entière se lit inactive + # d'un coup d'œil, au lieu de se déduire de trois widgets ternes. + for cible, actif in ( + ("#t_store", quelque_chose), + ("#t_tools", quelque_chose), + ("#t_python", installe), + ): + try: + self.query_one(cible).set_class(not actif, "off") + except Exception: + pass + # Les rangées portent les mêmes choix, par VM. + for i in range(len(self.rows)): + for cible in (f"#v{i}_branch", f"#v{i}_prof"): + try: + self.query_one(cible).disabled = not installe + except Exception: + pass + def _render_plan(self): # Le JEU de VM a-t-il changé ? Si oui on remonte les widgets, sinon # on se contente des titres : remonter à chaque frappe volerait le @@ -919,11 +1066,18 @@ def run_deploy_form(ctx, run_app: bool = True): ) return n, cpus, ram, disk = plan_totals(self.rows) - warn = "" + # Une liste et non un seul avertissement : la RAM, les cœurs et le + # disque sont trois limites distinctes, et n'en montrer qu'une + # cachait les autres — on corrigeait la première pour découvrir la + # suivante au déploiement. + alertes = [] if free_ram and ram > free_ram: - warn = f" ⚠ {t('> host free RAM')}" - elif cpus > host_cpu: - warn = f" ⚠ {t('> host cores')} ({host_cpu})" + alertes.append(t("> host free RAM")) + if free_disk and disk > free_disk: + alertes.append(t("> host free disk")) + if cpus > host_cpu: + alertes.append(f"{t('> host cores')} ({host_cpu})") + warn = f" ⚠ {' · '.join(alertes)}" if alertes else "" dupes = len({vm["name"] for vm in self.vms}) != len(self.vms) dup_txt = ( f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}" @@ -942,7 +1096,8 @@ def run_deploy_form(ctx, run_app: bool = True): else "" ) self.query_one("#totals", Static).update( - f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G" + f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · " + f"{disk_note(disk, free_disk, total_disk)}" f"{skip_txt}{warn}{dup_txt}" ) @@ -953,6 +1108,9 @@ def run_deploy_form(ctx, run_app: bool = True): self._reload_catalog() elif event.radio_set.id == "f_type": self._clear_overrides(("desktop",)) + # Le type est l'autre moitié de la décision : un bureau seul + # garde le magasin d'applications et les outils utiles. + self._sync_install_deps() # Recalcul : le disque annonce inclut le bureau, et la # colonne Statut affiche le type de VM. self._recompute() @@ -1007,10 +1165,11 @@ def run_deploy_form(ctx, run_app: bool = True): if field == "prof": label, cmd = profiles[event.value] if cmd == ( - vm_now.get("install_cmd") or self._profile_cmd() + vm_now.get("install_cmd") + or self._row_default_cmd(index) ): return - same = cmd == self._profile_cmd() + same = cmd == self._row_default_cmd(index) self._set_override( index, "install_cmd", "" if same else cmd ) @@ -1114,9 +1273,15 @@ def run_deploy_form(ctx, run_app: bool = True): return if row: index, field = int(row.group(1)), row.group(2) - self._set_override( - index, field, self._read_row_free(index, field) - ) + valeur = self._read_row_free(index, field) + # Même règle que pour les listes : poser « value= » au montage + # émet un Changed. L'écrire comme surcharge marquait ✎ une + # rangée que personne n'avait touchée — visible dès qu'une + # entrée du catalogue porte une taille absente des + # préréglages, comme les 32 G de Proxmox VE. + if self._row_echo(index, field, valeur): + return + self._set_override(index, field, valeur) self._recompute() return field = INPUT_TO_FIELD.get(event.input.id) @@ -1144,6 +1309,7 @@ def run_deploy_form(ctx, run_app: bool = True): def on_checkbox_changed(self, event) -> None: if event.checkbox.id == "f_install": + self._sync_install_deps() self._recompute() # le disque annoncé inclut le +5 G ERPLibre elif event.checkbox.id == "f_par_all": self.query_one("#f_par", Select).disabled = event.value diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index 464bcf7..4c474e2 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -69,18 +69,64 @@ class QemuInstallMixin: ] return profiles + # Un système qui IMPOSE ce qu'on installe dessus. Choisir « Proxmox VE » + # comme système, c'est demander qu'il soit installé : ni ERPLibre, ni + # Odoo n'ont leur place sur un hyperviseur, et les y poser par défaut + # était le contraire de ce que le choix exprimait. + _QEMU_DISTRO_PROFILE = {"proxmox": "Proxmox VE hypervisor (no Odoo)"} + + def _qemu_distro_profile(self, distro): + """(libellé, commande) du profil qu'un système impose, ou None. + + Une seule règle, lue par l'invite en ligne comme par le formulaire : + chacun la redisait, et le formulaire l'avait justement oubliée.""" + voulu = self._QEMU_DISTRO_PROFILE.get(distro) + if not voulu: + return None + cible = t(voulu) + for entree in self._qemu_install_profiles(): + if entree[0] == cible: + return entree + return None + + def _qemu_no_erplibre_cmds(self): + """Les commandes d'installation qui NE posent pas ERPLibre. + + Déduites de la table des systèmes imposés : le profil hyperviseur + Proxmox n'installe ni ERPLibre ni Odoo. Rien n'est écrit en dur ici, + pour qu'ajouter un système à la table suffise.""" + cmds = set() + for distro in self._QEMU_DISTRO_PROFILE: + impose = self._qemu_distro_profile(distro) + if impose: + cmds.add(impose[1]) + return cmds + + def _qemu_installs_erplibre(self, branch, install_cmd=""): + """Cette VM va-t-elle VRAIMENT poser ERPLibre ? + + Décidé sur la COMMANDE, pas sur la case : elle seule sait si le dépôt + sera cloné. C'est ce qui règle les cinq gigaoctets de marge et la + section ERPLibre du guide affiché à la connexion. Sans commande + connue, on répond oui : mieux vaut cinq gigaoctets de trop qu'une + installation qui remplit le disque.""" + if not branch: + return False + if not install_cmd: + return True + return install_cmd.strip() not in self._qemu_no_erplibre_cmds() + def _qemu_pick_install_profile(self, distro=""): """Choix de CE QU'ON installe sur la VM. Renvoie (label, commande finale exécutée dans ~/git/erplibre). - Sur une VM Proxmox, le profil hyperviseur passe en tête : choisir - « Proxmox VE » comme système, c'est demander qu'il soit installé, et - laisser Odoo 18 en défaut ferait poser un ERP sur un hyperviseur. + Le profil qu'un système impose passe en tête, et devient donc le + défaut de la réponse vide. """ profiles = self._qemu_install_profiles() - if distro == "proxmox": - pve = t("Proxmox VE hypervisor (no Odoo)") - profiles.sort(key=lambda p: p[0] != pve) + impose = self._qemu_distro_profile(distro) + if impose: + profiles.sort(key=lambda p: p[0] != impose[0]) print(f"\n{t('What to install on the VM(s)?')}") for i, (label, _cmd) in enumerate(profiles, 1): print(f" [{i}] {label}{' *' if i == 1 else ''}") diff --git a/script/todo/qemu_menu.py b/script/todo/qemu_menu.py index e90e74c..28db42f 100644 --- a/script/todo/qemu_menu.py +++ b/script/todo/qemu_menu.py @@ -5,11 +5,12 @@ import os import shutil - from datetime import datetime -from script.todo.todo_i18n import t + import click +from script.todo.todo_i18n import t + 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.""" diff --git a/script/todo/todo.py b/script/todo/todo.py index 2a0a9fb..9487319 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5,8 +5,6 @@ import ast import configparser import datetime -import getpass -import grp import inspect import json import logging @@ -1516,6 +1514,28 @@ class TODO( invites « défaut oui » où tout sauf « non » vaut oui.""" return ans.strip().lower() in ("n", "no", "non") + @staticmethod + def _host_disk_gb(path="/var/lib/libvirt/images"): + """(libre, total) en Go du système de fichiers qui portera les disques. + + On remonte vers le premier parent qui existe : le répertoire d'images + n'est créé qu'au premier déploiement, et « /var/lib/libvirt » ou « / » + répondent de la même partition dans la quasi-totalité des cas. (0, 0) + si rien ne répond — la place libre s'affiche alors comme inconnue + plutôt qu'inventée. + """ + chemin = path + while chemin and not os.path.isdir(chemin): + parent = os.path.dirname(chemin) + if parent == chemin: + break + chemin = parent + try: + usage = shutil.disk_usage(chemin or "/") + except OSError: + return 0, 0 + return usage.free // (1 << 30), usage.total // (1 << 30) + @staticmethod def _host_free_ram_mb(): """RAM disponible de l'hôte en Mo (MemAvailable), 0 si inconnu.""" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6327a7a..63a1a1f 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1253,6 +1253,14 @@ TRANSLATIONS = { "fr": "> RAM libre de l'hôte", "en": "> host free RAM", }, + "> host free disk": { + "fr": "> disque libre de l'hôte", + "en": "> host free disk", + }, + "free of": { + "fr": "libres sur", + "en": "free of", + }, "total RAM": { "fr": "RAM totale", "en": "total RAM", @@ -3148,8 +3156,11 @@ TRANSLATIONS = { "en": "Watch the VMs start (no install)", }, "Proxmox VE - Deploy a VM on a remote host": { - "fr": "Proxmox VE - Déployer une VM sur un hôte distant", - "en": "Proxmox VE - Deploy a VM on a remote host", + # 🗄 et non 💻 : l'entrée d'à côté déploie sur CETTE machine, celle-ci + # sur une baie ailleurs. L'icône est ce qui distingue les deux d'un + # coup d'œil dans le menu. + "fr": "🗄 Proxmox VE - Déployer une VM sur un hôte distant", + "en": "🗄 Proxmox VE - Deploy a VM on a remote host", }, "Deploy a virtual machine on Proxmox VE!": { "fr": "Déployer une machine virtuelle sur Proxmox VE !", @@ -3195,6 +3206,22 @@ TRANSLATIONS = { "fr": "Ce n'est pas un hôte Proxmox (ou il est injoignable) :", "en": "Not a Proxmox host (or unreachable):", }, + "Reachable, but Proxmox VE is not there:": { + "fr": "Machine joignable, mais Proxmox VE n'y est pas :", + "en": "Reachable, but Proxmox VE is not there:", + }, + "SSH does not get through:": { + "fr": "SSH ne passe pas :", + "en": "SSH does not get through:", + }, + "Install it:": { + "fr": "Pour l'installer :", + "en": "Install it:", + }, + "Or redeploy the VM with the hypervisor profile.": { + "fr": "Ou redéployer la VM avec le profil hyperviseur.", + "en": "Or redeploy the VM with the hypervisor profile.", + }, "Check the address, the SSH access and pveversion.": { "fr": "Vérifier l'adresse, l'accès SSH et pveversion.", "en": "Check the address, the SSH access and pveversion.", @@ -3299,6 +3326,99 @@ TRANSLATIONS = { "fr": "Aucun pont réseau sur cet hôte.", "en": "No network bridge on this host.", }, + # --- Écran de déploiement Proxmox VE (formulaire TUI) --- + "Deploy one or more ERPLibre VMs on Proxmox VE!": { + "fr": "Déployer une ou plusieurs VM ERPLibre sur Proxmox VE !", + "en": "Deploy one or more ERPLibre VMs on Proxmox VE!", + }, + "Proxmox host": { + "fr": "Hôte Proxmox", + "en": "Proxmox host", + }, + "node": { + "fr": "nœud", + "en": "node", + }, + "Proxmox VE": { + "fr": "Proxmox VE", + "en": "Proxmox VE", + }, + "Bridge": { + "fr": "Pont", + "en": "Bridge", + }, + "First VMID": { + "fr": "Premier VMID", + "en": "First VMID", + }, + "Access": { + "fr": "Accès", + "en": "Access", + }, + "SSH public key": { + "fr": "Clé publique SSH", + "en": "SSH public key", + }, + "Start the VM after creating it": { + "fr": "Démarrer la VM après sa création", + "en": "Start the VM after creating it", + }, + "Add an entry to ~/.ssh/config": { + "fr": "Ajouter une entrée à ~/.ssh/config", + "en": "Add an entry to ~/.ssh/config", + }, + "Follow the installation (dashboard)": { + "fr": "Suivre l'installation (tableau de bord)", + "en": "Follow the installation (dashboard)", + }, + "Text prompts": { + "fr": "Questions texte", + "en": "Text prompts", + }, + "Nothing to deploy.": { + "fr": "Rien à déployer.", + "en": "Nothing to deploy.", + }, + "No bridge on the host.": { + "fr": "Aucun pont sur l'hôte.", + "en": "No bridge on the host.", + }, + "more disk than the storage has free": { + "fr": "plus de disque que le stockage n'en a de libre", + "en": "more disk than the storage has free", + }, + "more RAM than the host has free": { + "fr": "plus de RAM que l'hôte n'en a de libre", + "en": "more RAM than the host has free", + }, + "Loading (host, storage, bridges, VMs)...": { + "fr": "Chargement (hôte, stockage, ponts, VM)…", + "en": "Loading (host, storage, bridges, VMs)...", + }, + "TUI unavailable": { + "fr": "Interface graphique indisponible", + "en": "TUI unavailable", + }, + "VM": { + "fr": "VM", + "en": "VM", + }, + "VMID": { + "fr": "VMID", + "en": "VMID", + }, + "address": { + "fr": "adresse", + "en": "address", + }, + "storage": { + "fr": "stockage", + "en": "storage", + }, + "other…": { + "fr": "autre…", + "en": "other…", + }, "offered": { "fr": "proposés", "en": "offered", @@ -3998,9 +4118,23 @@ TRANSLATIONS = { "fr": "Existantes, laissées intactes :", "en": "Existing, left untouched:", }, - "ERPLibre install:": { - "fr": "Installation ERPLibre :", - "en": "ERPLibre install:", + # Renommée : la case commande TOUTE installation — ERPLibre, Odoo, mais + # aussi l'hyperviseur Proxmox VE, qui n'installe ni l'un ni l'autre. + "Install:": { + "fr": "Installation :", + "en": "Install:", + }, + "Install software in the VM": { + "fr": "Installer un logiciel dans la VM", + "en": "Install software in the VM", + }, + "Installation": { + "fr": "Installation", + "en": "Installation", + }, + "Monitoring and parallelism": { + "fr": "Suivi et parallélisme", + "en": "Monitoring and parallelism", }, "profile": { "fr": "profil", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 6f3cdbe..0211c88 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -36,6 +36,12 @@ from script.todo.todo import TODO # noqa: E402 PVEVERSION = ( "pve-manager/9.2.11/f6997e698c7933ea (running kernel: 7.0.14-12-pve)" ) +# Ce que ssh écrit sur stderr à chaque connexion d'un hôte en +# UserKnownHostsFile=/dev/null. Ce n'est pas un diagnostic. +AVERTISSEMENT = ( + "Warning: Permanently added '192.168.123.227' (ED25519) to the list " + "of known hosts.\n" +) QM_LIST = """ VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID 100 vm-essai running 2048 16.00 2726 101 avec un espace stopped 4096 32.00 0 @@ -334,10 +340,62 @@ class TestChoixDeLHote(unittest.TestCase): return host, out.getvalue() def test_a_non_proxmox_host_is_refused_with_what_was_seen(self): - host, sortie = self._confirm([(127, "bash: pveversion: not found")]) + # Deux appels : « pveversion », puis la sonde qui demande à ssh s'il + # passe — c'est elle qui distingue les deux pannes. + host, sortie = self._confirm( + [(127, "bash: pveversion: not found"), (0, "")] + ) self.assertIsNone(host) self.assertIn("pveversion", sortie) + def test_a_reachable_machine_without_proxmox_says_exactly_that(self): + """Le cas rapporté : « je n'arrive pas à me connecter, pourtant il est + accessible ». La machine répondait ; c'est Proxmox qui manquait, et le + message parlait d'injoignabilité.""" + host, sortie = self._confirm( + [ + (127, AVERTISSEMENT + "bash: pveversion: command not found"), + (0, AVERTISSEMENT), + ] + ) + self.assertIsNone(host) + self.assertIn("joignable", sortie.lower()) + self.assertIn("install_proxmox.sh", sortie) + # Et surtout : ne plus envoyer chercher un problème de réseau. + self.assertNotIn("SSH ne passe pas", sortie) + + def test_an_unreachable_machine_says_ssh_does_not_get_through(self): + panne = "ssh: connect to host 10.0.0.9 port 22: No route to host" + host, sortie = self._confirm([(255, panne), (255, panne)]) + self.assertIsNone(host) + self.assertIn("No route to host", sortie) + self.assertNotIn("install_proxmox.sh", sortie) + + def test_the_ssh_key_warning_is_never_shown_as_the_error(self): + # Affichée comme preuve, elle envoyait chercher un problème de clé + # d'hôte qui n'existait pas — c'est ce qu'on voyait dans le rapport. + host, sortie = self._confirm( + [(127, AVERTISSEMENT), (0, AVERTISSEMENT)] + ) + self.assertIsNone(host) + self.assertNotIn("Permanently added", sortie) + + def test_only_the_lines_that_teach_something_are_kept(self): + self.assertEqual( + TODO._pve_clean_output( + AVERTISSEMENT + "\nbash: pveversion: command not found\n" + ), + ["bash: pveversion: command not found"], + ) + self.assertEqual(TODO._pve_clean_output(AVERTISSEMENT), []) + self.assertEqual(TODO._pve_clean_output(""), []) + + def test_the_install_hint_pipes_the_repo_script(self): + # Le script est autonome : « bash -s » suffit, rien à copier d'abord. + indice = self._todo()._pve_install_hint({"target": "pve1"}) + self.assertIn(TODO.PVE_INSTALL_SCRIPT, indice) + self.assertIn("ssh pve1 sudo bash -s", indice) + def test_a_non_root_access_gets_sudo(self): """C'est le cas de la voie « VM QEMU locale » : cloud-init crée erplibre, pas root.""" diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py new file mode 100644 index 0000000..0b17cbf --- /dev/null +++ b/test/test_proxmox_form.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L'écran de déploiement Proxmox VE. + +Ce que Proxmox a en plus de QEMU/KVM, et qui doit donc être éprouvé ici : le +VMID choisi AVANT le téléchargement de l'image (l'hôte ne dit « déjà pris » +qu'après), l'adresse qui s'en déduit sur un pont interne, le stockage et le +pont lus sur l'hôte, et une VM qui existe déjà et qu'on ne doit surtout pas +écraser. + +Le rendu est vérifié sans terminal (`run_test`), sur un contexte synthétique : +aucun hôte Proxmox n'est joint. +""" + +import asyncio +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.proxmox_deploy_form import ( # noqa: E402 + assign_vmids, + build_spec, + res_label, + run_proxmox_form, +) + +try: + import textual # noqa: F401 + + TEXTUAL = True +except Exception: # pragma: no cover - dépend de l'environnement + TEXTUAL = False + + +def rangee(nom, etat="new"): + return { + "vm": { + "name": nom, + "distro": "debian", + "version": "13", + "arch": "amd64", + "vcpus": 2, + "ram": 2048, + "disk": "32G", + }, + "state": etat, + "note": "", + "disk_gb": 32, + } + + +class TestVmid(unittest.TestCase): + """Proxmox refuse un VMID déjà pris, et il le dit APRÈS avoir téléchargé + l'image : le choix se fait donc avant, d'après ce que l'hôte déclare.""" + + def test_taken_ids_are_skipped(self): + rows = [rangee("a"), rangee("b")] + assign_vmids(rows, [100, 101, 103], 100, lambda v: "ip=dhcp") + self.assertEqual([r["vm"]["vmid"] for r in rows], [102, 104]) + + def test_an_existing_vm_keeps_its_own(self): + rows = [rangee("a", "exists"), rangee("b")] + assign_vmids(rows, [], 100, lambda v: "ip=dhcp") + self.assertNotIn("vmid", rows[0]["vm"]) + self.assertEqual(rows[1]["vm"]["vmid"], 100) + + def test_the_first_vmid_is_honoured(self): + rows = [rangee("a")] + assign_vmids(rows, [], 250, lambda v: "ip=dhcp") + self.assertEqual(rows[0]["vm"]["vmid"], 250) + + def test_a_vmid_never_goes_below_100(self): + # Proxmox réserve les VMID sous 100. + rows = [rangee("a")] + assign_vmids(rows, [], 7, lambda v: "ip=dhcp") + self.assertEqual(rows[0]["vm"]["vmid"], 100) + + def test_the_address_is_derived_from_the_vmid(self): + rows = [rangee("a"), rangee("b")] + assign_vmids(rows, [], 100, lambda v: f"ip=10.10.10.{50 + v % 200}/24") + self.assertEqual(rows[0]["vm"]["ipconfig"], "ip=10.10.10.150/24") + self.assertEqual(rows[1]["vm"]["ipconfig"], "ip=10.10.10.151/24") + + def test_without_a_bridge_rule_it_falls_back_to_dhcp(self): + rows = [rangee("a")] + assign_vmids(rows, [], 100, None) + self.assertEqual(rows[0]["vm"]["ipconfig"], "ip=dhcp") + + +class TestSpec(unittest.TestCase): + def _form(self, **extra): + base = { + "host": {"target": "erplibre@10.0.0.5"}, + "storage": "local-lvm", + "bridge": "vmbr0", + "res_label": "x1", + "ssh_key": "/home/x/.ssh/id_ed25519.pub", + "start": True, + "add_ssh_config": True, + "install": { + "branch": "develop", + "label": "Odoo 18", + "cmd": "make", + }, + "monitor": True, + "parallelism": 2, + } + base.update(extra) + return base + + def test_an_existing_vm_is_never_recreated(self): + vms = [{"name": "a"}, {"name": "b"}] + spec = build_spec(vms, ["b"], self._form()) + self.assertEqual([v["name"] for v in spec["vms"]], ["a"]) + self.assertEqual(spec["existing"], ["b"]) + + def test_the_user_defaults_to_erplibre(self): + spec = build_spec([], [], self._form()) + self.assertEqual(spec["user"], "erplibre") + + def test_the_monitor_choice_reaches_the_spec(self): + # Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans + # ERPLibre se suit aussi. + spec = build_spec([], [], self._form(install=None, monitor=True)) + self.assertIsNone(spec["install"]) + self.assertTrue(spec["monitor"]) + + def test_the_resource_label_names_the_common_setting(self): + self.assertEqual(res_label("3"), "x3") + self.assertNotEqual(res_label("custom"), "xcustom") + + +def contexte(): + def entree(distro, version, arch="amd64"): + return { + "name": f"erplibre-{distro}-{version}", + "distro": distro, + "version": version, + "arch": arch, + "ram": 2048, + "disk": "32G", + } + + return { + "host": { + "target": "erplibre@10.0.0.5", + "sudo": "sudo ", + "label": "pve", + }, + "node": "pve1", + "catalog": { + "amd64": [ + entree("ubuntu", "26.04"), + entree("debian", "13"), + entree("fedora", "44"), + entree("proxmox", "9"), + ], + "arm64": [entree("debian", "13", "arm64")], + }, + "arches": ["amd64", "arm64"], + "native": "amd64", + "names": ["erplibre-debian-13"], + "vmids": [100, 101], + "next_vmid": 102, + "storages": ["local-lvm", "local"], + "storage": "local-lvm", + "storage_avail": { + "local-lvm": 90 * (1 << 30), + "local": 12 * (1 << 30), + }, + "bridges": ["vmbr0"], + "bridge": "vmbr0", + "ipconfig": lambda pont, vmid: f"ip=10.10.10.{50 + vmid % 200}/24", + "build_command": lambda vm, spec: [f"qm create {vm['vmid']}"], + "branches": ["develop", "master"], + "install_profiles": [("ERPLibre + Odoo 18", "make install_odoo_18")], + "distro_profiles": { + "proxmox": ( + "Hyperviseur Proxmox VE (sans Odoo)", + "./script/proxmox/install_proxmox.sh", + ) + }, + "ssh_key": "/home/x/.ssh/id_ed25519.pub", + "cpu_presets": [2, 4, 8], + "ram_presets": [2048, 4096, 8192], + "disk_presets": ["32G", "64G"], + "base_vcpus": 2, + "host_cpu": 8, + "free_ram": 12000, + "extra_disk_gb": 10, + } + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestEcran(unittest.TestCase): + """Le formulaire, monté sans terminal.""" + + def _rendu(self, gestes): + ctx = contexte() + resultat = {} + + async def scenario(): + from textual.widgets import SelectionList + + app = run_proxmox_form(ctx, run_app=False) + async with app.run_test(size=(200, 50)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + for i in range(3): + liste.select(liste.get_option_at_index(i).value) + await pilote.pause() + await pilote.pause() + await gestes(app, pilote) + # Relevé AVANT la sortie du contexte : `run_test` démonte + # l'écran, et « #totals » n'existe plus après. + from textual.widgets import Static + + widget = app.query_one("#totals", Static) + app.ligne_totaux = str( + getattr(widget, "_content", "") or widget.render() + ) + resultat["app"] = app + + asyncio.run(scenario()) + return resultat["app"] + + def test_the_plan_shows_a_row_per_selected_system(self): + async def rien(app, pilote): + pass + + app = self._rendu(rien) + self.assertEqual(len(app.rows), 3) + + def test_the_head_line_carries_the_vmid_and_the_address(self): + async def rien(app, pilote): + pass + + app = self._rendu(rien) + tete = app._row_head(0, app.rows[0]) + self.assertIn("VMID", tete) + self.assertIn("10.10.10.", tete) + + def test_an_existing_vm_is_marked_and_gets_no_vmid(self): + async def rien(app, pilote): + pass + + app = self._rendu(rien) + deja = [r for r in app.rows if r["state"] == "exists"] + self.assertEqual(len(deja), 1) + self.assertNotIn("VMID", app._row_head(1, deja[0])) + + def test_mounting_does_not_mark_every_row_as_custom(self): + # Poser « value= » sur un Select fait émettre un Changed : pris pour + # une saisie, il surchargeait les trois champs de CHAQUE VM et toutes + # les rangées portaient la marque ✎ avant qu'on ne touche à rien. + async def rien(app, pilote): + pass + + app = self._rendu(rien) + self.assertEqual(app.overrides, {}) + self.assertNotIn("✎", app._row_head(0, app.rows[0])) + + def test_a_lock_survives_a_common_setting(self): + async def gestes(app, pilote): + app._set_lock(0, True) + await pilote.pause() + app.custom["ram"] = 8192 + app.profile = "custom" + app._clear_overrides(("ram",)) + app._recompute() + await pilote.pause() + + app = self._rendu(gestes) + self.assertEqual(app.rows[0]["vm"]["ram"], 2048) + + def test_a_copy_adds_a_vm_with_its_own_vmid(self): + async def gestes(app, pilote): + app._add_copy(0, 1) + await pilote.pause() + + app = self._rendu(gestes) + self.assertEqual(len(app.rows), 4) + vmids = [r["vm"]["vmid"] for r in app.rows if r["state"] != "exists"] + self.assertEqual(len(vmids), len(set(vmids))) + + def test_deploying_yields_a_spec_the_engine_can_run(self): + async def gestes(app, pilote): + app.action_deploy() + + app = self._rendu(gestes) + spec = app.result + self.assertEqual(len(spec["vms"]), 2) + self.assertEqual(spec["existing"], ["erplibre-debian-13"]) + self.assertEqual(spec["storage"], "local-lvm") + self.assertEqual(spec["bridge"], "vmbr0") + for vm in spec["vms"]: + self.assertIn("vmid", vm) + self.assertIn("ip=", vm["ipconfig"]) + self.assertEqual(spec["install"]["branch"], "develop") + + def _totaux(self, app): + return app.ligne_totaux + + def test_the_totals_line_shows_the_room_left_on_the_storage(self): + async def rien(app, pilote): + pass + + ligne = self._totaux(self._rendu(rien)) + # « pvesm status » donne déjà la place : la demande du plan s'affiche + # donc à côté d'elle, sans un aller-retour de plus vers l'hôte. + self.assertIn("/ 90 G", ligne) + self.assertIn("local-lvm", ligne) + + def test_changing_the_storage_changes_the_room(self): + # La marque de génération ne vaut que pour les widgets de RANGÉE : + # l'exiger des widgets globaux faisait taire tous les réglages + # communs, stockage compris. + async def gestes(app, pilote): + from textual.widgets import Select + + app.query_one("#f_storage", Select).value = "local" + await pilote.pause() + await pilote.pause() + + ligne = self._totaux(self._rendu(gestes)) + self.assertIn("/ 12 G", ligne) + self.assertIn("local", ligne) + + def test_a_plan_bigger_than_the_storage_is_flagged(self): + async def gestes(app, pilote): + from textual.widgets import Select + + app.query_one("#f_storage", Select).value = "local" + await pilote.pause() + await pilote.pause() + + self.assertIn("⚠", self._totaux(self._rendu(gestes))) + + def test_a_common_setting_reaches_every_vm(self): + async def gestes(app, pilote): + # « value = True » sur le bouton : action_next_button() ne + # déplace que la surbrillance et n'émet aucun message. + list(app.query("#f_profile RadioButton"))[2].value = True + await pilote.pause() + await pilote.pause() + + app = self._rendu(gestes) + self.assertEqual(app.profile, "3") + self.assertTrue(all(r["vm"]["ram"] == 6144 for r in app.rows)) + + def test_the_resource_label_survives_the_markup(self): + # « [x1] » se faisait manger : Static lit le balisage Rich, et une + # balise inconnue disparaît avec son contenu. + async def rien(app, pilote): + pass + + self.assertIn("x1", self._totaux(self._rendu(rien))) + + def test_a_nested_proxmox_guest_installs_its_hypervisor(self): + # Même défaut que sur l'écran QEMU/KVM avant correction : un Proxmox + # imbriqué recevait ERPLibre et Odoo 18. + async def gestes(app, pilote): + from textual.widgets import SelectionList + + liste = app.query_one(SelectionList) + liste.select(liste.get_option_at_index(3).value) + await pilote.pause() + await pilote.pause() + + app = self._rendu(gestes) + par = {r["vm"]["distro"]: r for r in app.rows} + self.assertEqual( + par["proxmox"]["vm"]["install_cmd"], + "./script/proxmox/install_proxmox.sh", + ) + # Et ses voisines gardent le choix commun. + self.assertEqual(par["ubuntu"]["vm"]["install_cmd"], "") + # Cinq gigaoctets pour un dépôt qu'elle ne clonera pas. + self.assertEqual(par["proxmox"]["disk_gb"], 32) + self.assertEqual(par["ubuntu"]["disk_gb"], 42) + + def test_text_prompts_are_not_a_cancellation(self): + # {} n'est pas None : l'appelant distingue « annulé » de + # « pose-moi les questions à l'ancienne ». + async def gestes(app, pilote): + from textual.widgets import Button + + app.on_button_pressed( + type("E", (), {"button": Button("x", id="prompts")})() + ) + + app = self._rendu(gestes) + self.assertEqual(app.result, {}) + + def test_cancelling_yields_nothing(self): + async def gestes(app, pilote): + app.action_cancel() + + self.assertIsNone(self._rendu(gestes).result) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_qemu_deploy_form_disk.py b/test/test_qemu_deploy_form_disk.py new file mode 100644 index 0000000..b39decf --- /dev/null +++ b/test/test_qemu_deploy_form_disk.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La ligne de totaux du formulaire QEMU/KVM : la place, pas seulement la +demande. + +La ligne disait « ~126 G » sans dire sur quoi : on découvrait au déploiement +qu'il ne restait pas la place. Elle dit maintenant la demande, ce qui reste et +la capacité — et elle prévient quand ça ne rentre pas, pour les trois limites +(RAM, disque, cœurs) au lieu d'une seule à la fois. + +La sonde disque est vérifiée à part : elle doit tomber sur une partition qui +existe même quand le répertoire d'images n'a jamais été créé. +""" + +import asyncio +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +try: + import textual # noqa: F401 + + TEXTUAL = True +except Exception: # pragma: no cover - dépend de l'environnement + TEXTUAL = False + + +class TestSondeDisque(unittest.TestCase): + def test_it_measures_the_filesystem_that_will_hold_the_disks(self): + libre, total = TODO._host_disk_gb() + self.assertGreater(total, 0) + self.assertLessEqual(libre, total) + + def test_a_missing_directory_falls_back_to_a_parent(self): + # /var/lib/libvirt/images n'existe qu'après le premier déploiement : + # sans repli, la place s'afficherait comme inconnue sur une machine + # neuve, là où elle est justement la plus utile. + libre, total = TODO._host_disk_gb("/n/existe/pas/du/tout") + self.assertGreater(total, 0) + + def test_it_never_raises(self): + self.assertEqual(len(TODO._host_disk_gb("")), 2) + + +def contexte(): + """Contexte minimal du formulaire, avec des mesures CHOISIES : la ligne + doit se lire pareil quelle que soit la machine qui lance le test.""" + todo = TODO.__new__(TODO) + mod = todo._qemu_import_module() + todo._qemu_list_domains = lambda: [] + todo._qemu_branch_list = lambda: ["develop", "master"] + ctx = todo._qemu_form_context(mod) + ctx["free_disk"] = 500 + ctx["total_disk"] = 900 + ctx["free_ram"] = 64000 + ctx["host_cpu"] = 64 + return ctx + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLigneDeTotaux(unittest.TestCase): + def _ligne(self, combien, ctx=None): + from script.todo.qemu_deploy_form import run_deploy_form + + ctx = ctx or contexte() + vu = {} + + async def scenario(): + from textual.widgets import SelectionList, Static + + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 50)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + for i in range(combien): + liste.select(liste.get_option_at_index(i).value) + await pilote.pause() + await pilote.pause() + widget = app.query_one("#totals", Static) + vu["ligne"] = str( + getattr(widget, "_content", "") or widget.render() + ) + vu["vms"] = len(app.vms) + + asyncio.run(scenario()) + return vu + + def test_nothing_ticked_says_how_to_fill_the_list(self): + # Un total à zéro n'apprend rien. + self.assertNotIn("~", self._ligne(0)["ligne"]) + + def test_it_shows_the_demand_the_room_and_the_capacity(self): + ligne = self._ligne(2)["ligne"] + self.assertIn("~", ligne) + self.assertIn("500 G", ligne) + self.assertIn("900 G", ligne) + + def test_a_plan_bigger_than_the_room_is_flagged(self): + ctx = contexte() + ctx["free_disk"] = 1 + ctx["total_disk"] = 900 + self.assertIn("⚠", self._ligne(2, ctx)["ligne"]) + + def test_the_three_limits_are_reported_together(self): + # N'en montrer qu'une cachait les autres : on corrigeait la première + # pour découvrir la suivante au déploiement. + ctx = contexte() + ctx["free_disk"] = 1 + ctx["free_ram"] = 1 + ctx["host_cpu"] = 1 + ligne = self._ligne(2, ctx)["ligne"] + self.assertEqual(ligne.count("⚠"), 1) + for morceau in ("RAM", "disque", "cœurs"): + self.assertIn(morceau, ligne, ligne) + + def test_an_unknown_measure_shows_no_room_at_all(self): + ctx = contexte() + ctx["free_disk"] = 0 + ctx["total_disk"] = 0 + ligne = self._ligne(2, ctx)["ligne"] + self.assertIn("~", ligne) + self.assertNotIn("/", ligne) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_qemu_deploy_form_install.py b/test/test_qemu_deploy_form_install.py new file mode 100644 index 0000000..6f78063 --- /dev/null +++ b/test/test_qemu_deploy_form_install.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La case d'installation : son nom, sa place, et ce qu'elle grise. + +Elle s'appelait « Installer ERPLibre » alors qu'elle commande TOUTE +installation — l'hyperviseur Proxmox VE compris. Rapporté après coup : une VM +Proxmox déployée avec la case décochée est restée une Debian nue, et rien ne +disait que la case l'expliquait. + +Elle est donc renommée, placée juste sous le type de VM (les sections qu'elle +commande viennent après), et ce qu'elle rend sans effet se grise. Trois états +et non deux : sans installation mais avec un bureau, il se pose encore des +paquets — griser le magasin d'applications mentirait autant que de laisser +actif ce qui ne fait rien. +""" + +import asyncio +import re +import sys +import unittest +from pathlib import Path + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +FORM = ( + Path(__file__).resolve().parent.parent / "script/todo/qemu_deploy_form.py" +) + +try: + import textual # noqa: F401 + + TEXTUAL = True +except Exception: # pragma: no cover - dépend de l'environnement + TEXTUAL = False + + +class TestSaPlace(unittest.TestCase): + """Lu dans la source : l'ordre du panneau est une décision, pas un hasard.""" + + @classmethod + def setUpClass(cls): + cls.src = FORM.read_text(encoding="utf-8") + + def _rang(self, motif): + m = re.search(re.escape(motif), self.src) + self.assertIsNotNone(m, motif) + return m.start() + + def test_the_install_section_sits_under_the_vm_type(self): + self.assertLess( + self._rang('with RadioSet(id="f_type")'), + self._rang('id="t_install"'), + ) + + def test_and_before_the_sections_it_commands(self): + # Magasin d'applications, outils : ils dépendent d'elle, donc ils + # viennent après. + for apres in ('id="t_store"', 'id="t_tools"'): + self.assertLess(self._rang('id="t_install"'), self._rang(apres)) + + def test_the_checkbox_no_longer_claims_to_be_about_erplibre(self): + self.assertIn('t("Install software in the VM")', self.src) + self.assertNotIn('t("Install ERPLibre")', self.src) + + def test_the_monitor_left_the_install_section(self): + # Rangé dedans, il se serait grisé avec elle — et décocher ERPLibre + # avait déjà fait disparaître le tableau de bord une fois. + self.assertLess( + self._rang('id="t_deploy"'), self._rang('id="f_monitor"') + ) + + +def contexte(): + todo = TODO.__new__(TODO) + mod = todo._qemu_import_module() + todo._qemu_list_domains = lambda: [] + todo._qemu_branch_list = lambda: ["develop", "master"] + return todo._qemu_form_context(mod) + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestCeQuElleGrise(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.ctx = contexte() + + def _etats(self): + """Relève l'état des champs dans les trois situations.""" + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + + async def scenario(): + from textual.widgets import Checkbox, SelectionList + + app = run_deploy_form(self.ctx, run_app=False) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + liste.select(liste.get_option_at_index(0).value) + await pilote.pause() + await pilote.pause() + + def relever(): + etat = {} + for cible in ( + "f_branch", + "f_profile_install", + "f_python", + "f_prod", + "f_store", + "f_type", + "f_monitor", + "v0_branch", + "v0_prof", + ): + try: + etat[cible] = app.query_one(f"#{cible}").disabled + except Exception: + etat[cible] = None + etat["outils"] = { + w.id[7:]: w.disabled + for w in app.query(Checkbox) + if str(w.id or "").startswith("f_tool_") + } + etat["titres"] = { + w.id + for w in app.query("#fields Static") + if "off" in w.classes + } + return etat + + vu["installe"] = relever() + app.query_one("#f_install", Checkbox).value = False + await pilote.pause() + await pilote.pause() + vu["rien"] = relever() + list(app.query("#f_type RadioButton"))[1].value = True + await pilote.pause() + await pilote.pause() + vu["bureau_seul"] = relever() + # Retour au SERVEUR avant de recocher : le magasin + # d'applications a sa propre raison de se griser (aucune VM + # graphique sur une distribution à snap), et comparer deux + # états de type différent ne dirait rien de la case. + list(app.query("#f_type RadioButton"))[0].value = True + app.query_one("#f_install", Checkbox).value = True + await pilote.pause() + await pilote.pause() + vu["recoche"] = relever() + + asyncio.run(scenario()) + return vu + + @classmethod + def etats(cls): + if not hasattr(cls, "_vu"): + cls._vu = cls()._etats() + return cls._vu + + def test_the_branch_and_the_profile_only_serve_an_install(self): + vu = self.etats() + for champ in ("f_branch", "f_profile_install", "v0_branch", "v0_prof"): + self.assertFalse(vu["installe"][champ], champ) + self.assertTrue(vu["rien"][champ], champ) + # Même avec un bureau : ils ne servent QU'à ERPLibre. + self.assertTrue(vu["bureau_seul"][champ], champ) + + def test_a_desktop_only_install_keeps_what_it_really_uses(self): + # Le magasin d'applications et « production » servent encore : la + # commande distante pose des paquets et coupe les mises à jour. + vu = self.etats() + self.assertTrue(vu["rien"]["f_store"]) + self.assertFalse(vu["bureau_seul"]["f_store"]) + self.assertTrue(vu["rien"]["f_prod"]) + self.assertFalse(vu["bureau_seul"]["f_prod"]) + + def test_the_tools_that_live_in_the_repository_need_the_install(self): + # « after » = dans le dépôt ERPLibre. Sans installation, ils n'ont + # rien où s'installer, bureau ou pas — la commande distante les saute. + vu = self.etats() + phases = self.ctx["vm_tool_phases"] + apres = [k for k, v in phases.items() if v == "after"] + avant = [k for k, v in phases.items() if v != "after"] + self.assertTrue(apres and avant, phases) + for k in apres: + self.assertTrue(vu["bureau_seul"]["outils"][k], k) + for k in avant: + self.assertFalse(vu["bureau_seul"]["outils"][k], k) + for k in phases: + self.assertTrue(vu["rien"]["outils"][k], k) + + def test_the_vm_type_and_the_dashboard_are_never_greyed(self): + # Le type est l'AUTRE moitié de la décision ; le suivi regarde la VM + # arriver même quand rien ne s'installe. + vu = self.etats() + for cas in ("installe", "rien", "bureau_seul"): + self.assertFalse(vu[cas]["f_type"], cas) + self.assertFalse(vu[cas]["f_monitor"], cas) + + def test_a_greyed_section_reads_as_inactive(self): + vu = self.etats() + self.assertEqual(vu["installe"]["titres"], set()) + self.assertIn("t_store", vu["rien"]["titres"]) + self.assertIn("t_tools", vu["rien"]["titres"]) + + def test_ticking_it_back_restores_everything(self): + vu = self.etats() + for champ, valeur in vu["recoche"].items(): + if champ in ("outils", "titres"): + continue + self.assertEqual(valeur, vu["installe"][champ], champ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_qemu_install_profile.py b/test/test_qemu_install_profile.py new file mode 100644 index 0000000..bcef88b --- /dev/null +++ b/test/test_qemu_install_profile.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'un système IMPOSE d'installer sur la VM. + +Choisir « Proxmox VE » comme système, c'est demander qu'il soit installé : ni +ERPLibre ni Odoo n'ont leur place sur un hyperviseur. L'invite en ligne le +savait déjà — elle remontait le profil hyperviseur en tête — mais le +formulaire, lui, posait « ERPLibre + Odoo 18 » par défaut, et lui ajoutait +même les cinq gigaoctets réservés au dépôt ERPLibre. + +La règle vit maintenant en un seul endroit et les deux chemins la lisent. Ces +tests gardent les deux, plus le fait qu'un choix explicite l'emporte toujours +sur elle. +""" + +import asyncio +import builtins +import contextlib +import io +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +try: + import textual # noqa: F401 + + TEXTUAL = True +except Exception: # pragma: no cover - dépend de l'environnement + TEXTUAL = False + +PVE_CMD = "./script/proxmox/install_proxmox.sh" + + +class TestLaRegle(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_proxmox_imposes_the_hypervisor_profile(self): + label, cmd = self.todo._qemu_distro_profile("proxmox") + self.assertEqual(cmd, PVE_CMD) + self.assertIn("Proxmox", label) + + def test_an_ordinary_system_imposes_nothing(self): + for distro in ("ubuntu", "debian", "fedora", "arch"): + self.assertIsNone(self.todo._qemu_distro_profile(distro)) + + def test_every_mapped_label_still_exists_in_the_list(self): + # La table désigne un profil PAR SON LIBELLÉ : renommer le profil sans + # toucher la table ferait disparaître la règle en silence. + for distro in self.todo._QEMU_DISTRO_PROFILE: + self.assertIsNotNone( + self.todo._qemu_distro_profile(distro), distro + ) + + def test_the_prompt_offers_it_first(self): + # Réponse vide = premier de la liste : c'est ce qui rend le défaut. + with contextlib.redirect_stdout(io.StringIO()): + vrai_input, builtins.input = builtins.input, lambda _p="": "" + try: + label, cmd = self.todo._qemu_pick_install_profile("proxmox") + ordinaire = self.todo._qemu_pick_install_profile("ubuntu") + finally: + builtins.input = vrai_input + self.assertEqual(cmd, PVE_CMD) + self.assertIn("odoo_18", ordinaire[1]) + + +class TestUneSeuleVM(unittest.TestCase): + """Le piège du parc d'UNE machine. + + L'exécution ne passait à une commande par VM que si DEUX VM différaient + entre elles. Déployée seule, une VM Proxmox donnait un ensemble d'un seul + élément : tout retombait sur le choix commun, et l'hyperviseur recevait + ERPLibre et Odoo 18 — le défaut corrigé dans le formulaire, réintroduit à + l'exécution.""" + + def test_a_lone_vm_with_its_own_command_needs_the_map(self): + self.assertTrue( + TODO._qemu_per_vm({"a": PVE_CMD}, "make install_odoo_18") + ) + + def test_a_lone_vm_on_the_common_choice_does_not(self): + self.assertFalse( + TODO._qemu_per_vm( + {"a": "make install_odoo_18"}, "make install_odoo_18" + ) + ) + + def test_two_identical_vms_do_not(self): + self.assertFalse(TODO._qemu_per_vm({"a": "x", "b": "x"}, "x")) + + def test_two_different_vms_do(self): + self.assertTrue(TODO._qemu_per_vm({"a": "x", "b": "y"}, "x")) + + def test_an_empty_plan_never_asks_for_a_map(self): + # Une carte vide passée à l'installateur y serait prise pour une + # commande : le repli sur le choix commun est le seul sûr. + self.assertFalse(TODO._qemu_per_vm({}, "x")) + + def test_the_same_trap_held_for_the_branch(self): + self.assertTrue(TODO._qemu_per_vm({"a": "master"}, "develop")) + + +class TestLaCommandeConstruite(unittest.TestCase): + """La commande deploy_qemu.py doit dire la même chose que le plan.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def _parts(self, distro, disque, cmd_vm="", installe=True): + vm = { + "name": "essai", + "distro": distro, + "version": "24.04" if distro == "ubuntu" else "9", + "arch": "amd64", + "vcpus": 2, + "ram": 2048, + "disk": disque, + "desktop": "", + "branch": "", + "install_cmd": cmd_vm, + "install_label": "", + } + spec = { + "vms": [vm], + "existing": [], + "ssh_key": "", + "timezone": "", + "desktop": "", + "vm_tools": (), + "python_provider": "", + "app_store": "deb", + "install": ( + { + "branch": "develop", + "prod": False, + "label": "x", + "monitor": True, + "cmd": "make install_os && make install_odoo_18", + } + if installe + else None + ), + "monitor": True, + "add_ssh_config": True, + "parallelism": 1, + "res_label": "x1", + } + return self.todo._qemu_deploy_parts_for(vm, spec, dry_run=True) + + def _taille(self, parts): + return parts[parts.index("--disk-size") + 1] + + def test_a_proxmox_vm_gets_no_erplibre_margin(self): + parts = self._parts("proxmox", "32G", PVE_CMD) + self.assertEqual(self._taille(parts), "32G") + + def test_an_ordinary_vm_keeps_its_margin(self): + parts = self._parts("ubuntu", "20G") + self.assertEqual(self._taille(parts), "25G") + + def test_a_proxmox_vm_advertises_no_erplibre_in_its_guide(self): + # Le guide affiché à la connexion SSH ne doit pas annoncer un dépôt + # qui n'existera pas sur cette VM. + parts = self._parts("proxmox", "32G", PVE_CMD) + self.assertNotIn("--erplibre-make", parts) + self.assertNotIn("--erplibre-dir", parts) + self.assertIn("--erplibre-dir", self._parts("ubuntu", "20G")) + + def test_a_chosen_size_is_never_dropped(self): + # Sans le drapeau, deploy_qemu.py reprend la taille du catalogue : une + # VM réglée à 60 G mais sans rien à installer repartait à 20 G. + parts = self._parts("ubuntu", "60G", installe=False) + self.assertEqual(self._taille(parts), "60G") + + +def contexte(): + todo = TODO.__new__(TODO) + mod = todo._qemu_import_module() + todo._qemu_list_domains = lambda: [] + todo._qemu_branch_list = lambda: ["develop", "master"] + return todo._qemu_form_context(mod) + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLeFormulaire(unittest.TestCase): + """Le formulaire doit appliquer la règle AU MODÈLE : le déploiement lit + « install_cmd » VM par VM, pas ce que la liste affiche.""" + + @classmethod + def setUpClass(cls): + cls.ctx = contexte() + cls.rangs = {} + for i, e in enumerate(cls.ctx["catalog"]["amd64"]): + cls.rangs.setdefault(e["distro"], i) + + def _plan(self, distros, gestes=None): + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + + async def scenario(): + from textual.widgets import SelectionList + + app = run_deploy_form(self.ctx, run_app=False) + async with app.run_test(size=(200, 50)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + for d in distros: + liste.select( + liste.get_option_at_index(self.rangs[d]).value + ) + await pilote.pause() + await pilote.pause() + if gestes: + await gestes(app, pilote) + vu["app"] = app + vu["par_distro"] = {r["vm"]["distro"]: r for r in app.rows} + + asyncio.run(scenario()) + return vu + + def test_a_proxmox_vm_installs_the_hypervisor_not_erplibre(self): + r = self._plan(["proxmox"])["par_distro"]["proxmox"] + self.assertEqual(r["vm"]["install_cmd"], PVE_CMD) + self.assertIn("Proxmox", r["vm"]["install_label"]) + + def test_an_ordinary_vm_still_follows_the_common_choice(self): + # « » sur une VM veut dire « le choix du formulaire » : le déploiement + # y met alors la commande commune. + r = self._plan(["ubuntu"])["par_distro"]["ubuntu"] + self.assertEqual(r["vm"]["install_cmd"], "") + + def test_a_mixed_plan_gives_each_vm_its_own(self): + par = self._plan(["proxmox", "ubuntu"])["par_distro"] + self.assertEqual(par["proxmox"]["vm"]["install_cmd"], PVE_CMD) + self.assertEqual(par["ubuntu"]["vm"]["install_cmd"], "") + + def test_the_erplibre_disk_supplement_skips_it(self): + # Cinq gigaoctets pour un dépôt qu'une VM Proxmox ne clonera pas. + par = self._plan(["proxmox", "ubuntu"])["par_distro"] + pve, ubu = par["proxmox"], par["ubuntu"] + self.assertEqual(pve["disk_gb"], int(pve["vm"]["disk"].rstrip("G"))) + self.assertGreater(ubu["disk_gb"], int(ubu["vm"]["disk"].rstrip("G"))) + + def test_mounting_marks_nothing_as_customised(self): + # Le disque de Proxmox (32 G) n'est pas dans les préréglages : sa + # saisie libre émettait un Changed au montage, écrit comme une + # surcharge, et la rangée s'affichait ✎ sans qu'on l'ait touchée. + app = self._plan(["proxmox"])["app"] + self.assertEqual(app.overrides, {}) + self.assertNotIn("✎", app._row_head(0, app.rows[0])) + + def test_an_explicit_choice_beats_the_rule(self): + async def gestes(app, pilote): + from textual.widgets import Select + + app.query_one("#v0_prof", Select).value = 0 # ERPLibre + Odoo 18 + await pilote.pause() + await pilote.pause() + + r = self._plan(["proxmox"], gestes)["par_distro"]["proxmox"] + self.assertIn("odoo_18", r["vm"]["install_cmd"]) + + def test_resetting_the_row_returns_to_the_rule(self): + async def gestes(app, pilote): + from textual.widgets import Select + + app.query_one("#v0_prof", Select).value = 0 + await pilote.pause() + app.query_one("#v0_prof", Select).focus() + await pilote.pause() + app.action_clear_vm() + await pilote.pause() + await pilote.pause() + + r = self._plan(["proxmox"], gestes)["par_distro"]["proxmox"] + self.assertEqual(r["vm"]["install_cmd"], PVE_CMD) + + def test_a_real_keystroke_still_lands(self): + # La règle de l'écho ne doit pas avaler une VRAIE saisie. + async def gestes(app, pilote): + from textual.widgets import Input + + app.query_one("#c0_disk", Input).value = "60G" + await pilote.pause() + await pilote.pause() + + r = self._plan(["proxmox"], gestes)["par_distro"]["proxmox"] + self.assertEqual(r["vm"]["disk"], "60G") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_todo_deploy_form_lib.py b/test/test_todo_deploy_form_lib.py new file mode 100644 index 0000000..60af5da --- /dev/null +++ b/test/test_todo_deploy_form_lib.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le socle commun aux deux formulaires de déploiement. + +Le formulaire QEMU/KVM et le formulaire Proxmox posent le même travail : +choisir des systèmes, régler des ressources, vérifier le plan, lancer. Ce qui +est commun vit dans `deploy_form_lib` (logique pure, socle CSS, fabrique des +ressources) et `deploy_form_plan` (surcharges, verrous, exemplaires, +renommage). Ces tests gardent DEUX propriétés : + +* le socle fait ce qu'il dit — une ressource libre, un verrou, un écho de + montage pris pour une saisie ; +* les deux formulaires s'en servent VRAIMENT, au lieu de le redire chacun de + son côté. C'est la seule chose qui empêche l'architecture de retomber en + deux copies qui divergent. +""" + +import ast +import pathlib +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo import deploy_form_lib as lib # noqa: E402 +from script.todo import deploy_form_plan as plan # noqa: E402 + +RACINE = pathlib.Path(__file__).resolve().parent.parent +TODO_DIR = RACINE / "script" / "todo" + + +class TestRessourcesLibres(unittest.TestCase): + """« autre… » ne vaut pas zéro : il révèle une saisie.""" + + def test_the_free_sentinel_closes_the_list(self): + choix = lib.res_choices([2, 4, 8]) + self.assertEqual(choix[-1][1], lib.FREE) + self.assertEqual([v for _l, v in choix[:-1]], ["2", "4", "8"]) + + def test_a_formatter_shapes_the_labels(self): + choix = lib.res_choices([2048, 4096], fmt=lambda v: f"{v // 1024}G") + self.assertEqual([lbl for lbl, _v in choix[:-1]], ["2G", "4G"]) + + def test_free_without_typing_keeps_the_default(self): + # Le piège payé : valider un formulaire à peine ouvert rétrécissait + # les machines à zéro. + self.assertEqual(lib.res_value(lib.FREE, "", 4096), 4096) + self.assertEqual(lib.res_value(None, " ", 2), 2) + + def test_a_typed_value_wins(self): + self.assertEqual(lib.res_value(lib.FREE, "12", 2), 12) + self.assertEqual(lib.res_value("8", "", 2), 8) + + +class TestPlaceDisque(unittest.TestCase): + """La demande du plan ne dit pas si ça rentre : il faut la place à côté.""" + + def test_it_shows_the_demand_the_room_and_the_capacity(self): + self.assertEqual( + lib.disk_note(50, 20, 270), "~50 G / 20 G libres sur 270 G" + ) + + def test_without_a_capacity_it_still_shows_the_room(self): + self.assertEqual(lib.disk_note(50, 20), "~50 G / 20 G libres") + + def test_without_a_measure_it_invents_nothing(self): + # Une place inconnue ne doit pas se lire comme « 0 Go libres ». + self.assertEqual(lib.disk_note(50, 0, 0), "~50 G") + self.assertEqual(lib.disk_note(50, 0, 270), "~50 G") + + def test_bytes_become_whole_gibibytes(self): + self.assertEqual(lib.gib(90 * (1 << 30)), 90) + self.assertEqual(lib.gib(0), 0) + # Une mesure absente ou illisible vaut zéro, pas une exception : la + # ligne de totaux doit s'afficher même quand l'hôte n'a rien répondu. + self.assertEqual(lib.gib(None), 0) + self.assertEqual(lib.gib("x"), 0) + + +class TestEtatDuNom(unittest.TestCase): + """Un nom déjà pris, un disque resté seul : deux gravités différentes.""" + + def test_a_defined_vm_is_skipped(self): + etat, note = lib.vm_status( + "erplibre-debian-13", ["erplibre-debian-13"] + ) + self.assertEqual(etat, "exists") + self.assertTrue(note) + + def test_the_orphan_probe_is_replaceable(self): + # Proxmox distant : ses disques vivent dans un stockage que seul + # l'hôte connaît, jamais dans /var/lib/libvirt. + etat, _n = lib.vm_status("x", [], orphelin=lambda _n: False) + self.assertEqual(etat, "new") + etat, _n = lib.vm_status("x", [], orphelin=lambda _n: True) + self.assertEqual(etat, "orphan") + + def test_plan_rows_carries_the_probe(self): + vms = [{"name": "x", "vcpus": 2, "ram": 2048, "disk": "32G"}] + rows = lib.plan_rows(vms, [], 0, orphelin=lambda _n: True) + self.assertEqual(rows[0]["state"], "orphan") + + +class FauxPlan(plan.PlanMixin): + """Le strict contrat du mixin, sans Textual : de quoi éprouver ce qui ne + touche pas à l'écran.""" + + def __init__(self, entrees): + self.entrees = entrees + self.copies = {} + self.overrides = {} + self.locked = set() + self.custom = {} + self.rows = [] + self._gen = 0 + self._shown_ids = () + + def _selected_entries(self): + return self.entrees + + +def entree(distro, version, arch="amd64"): + return { + "name": f"erplibre-{distro}-{version}", + "distro": distro, + "version": version, + "arch": arch, + "ram": 2048, + "disk": "32G", + } + + +class TestSocleDuPlan(unittest.TestCase): + def setUp(self): + self.app = FauxPlan( + [entree("debian", "13"), entree("ubuntu", "26.04")] + ) + self.app.rows = [ + { + "vm": { + "name": e["name"], + "distro": e["distro"], + "version": e["version"], + "arch": e["arch"], + "vcpus": 2, + "ram": 2048, + "disk": "32G", + }, + "state": "new", + "note": "", + "disk_gb": 32, + } + for e in self.app.entrees + ] + + def test_an_override_is_written_then_dropped(self): + self.app._set_override(0, "ram", 8192) + cle = self.app._row_key(0) + self.assertEqual(self.app.overrides[cle]["ram"], 8192) + # Une saisie vidée RETIRE la surcharge : écrire zéro donnerait une VM + # à 0 vCPU. + self.app._set_override(0, "ram", 0) + self.assertNotIn(cle, self.app.overrides) + + def test_a_common_setting_reclaims_the_unlocked_rows(self): + self.app._set_override(0, "ram", 8192) + self.app._set_override(1, "ram", 8192) + self.app.locked.add(self.app._row_key(1)) + self.app._clear_overrides(("ram",)) + self.assertNotIn(self.app._row_key(0), self.app.overrides) + self.assertIn(self.app._row_key(1), self.app.overrides) + + def test_clearing_is_per_field(self): + self.app._set_override(0, "ram", 8192) + self.app._set_override(0, "disk", "64G") + self.app._clear_overrides(("ram",)) + self.assertEqual( + self.app.overrides[self.app._row_key(0)], {"disk": "64G"} + ) + + def test_a_command_drags_its_label_along(self): + cle = self.app._row_key(0) + self.app.overrides[cle] = { + "install_cmd": "make install_odoo_18", + "install_label": "Odoo 18", + } + self.app._clear_overrides(("install_cmd",)) + self.assertNotIn(cle, self.app.overrides) + + def test_the_mount_echo_is_not_input(self): + # La valeur que le modèle porte déjà : c'est l'écho du montage. + self.assertTrue(self.app._row_echo(0, "ram", 2048)) + self.assertFalse(self.app._row_echo(0, "ram", 4096)) + # Rang hors plan : rien à appliquer. + self.assertTrue(self.app._row_echo(99, "ram", 1)) + + def test_copies_expand_the_selection(self): + self.app.copies[("debian", "13", "amd64")] = 1 + entrees = self.app._plan_entries() + self.assertEqual(len(entrees), 3) + self.assertEqual(len(set(lib.entry_key(e) for e in entrees)), 3) + + def test_the_head_line_names_what_is_special(self): + self.app.rows[0]["locked"] = True + self.assertIn("🔒", self.app._row_head(0, self.app.rows[0])) + self.app.rows[1]["custom"] = True + self.assertIn("✎", self.app._row_head(1, self.app.rows[1])) + self.assertIn("debian", self.app._row_head(0, self.app.rows[0])) + + def test_locking_copies_the_resources(self): + gele = self.app._lock_fields(0) + self.assertEqual(gele["ram"], 2048) + self.assertEqual(gele["disk"], "32G") + self.assertEqual(gele["vcpus"], 2) + + +def membres(chemin, classe=None): + """Noms des fonctions/méthodes définies dans un fichier (ou une classe).""" + arbre = ast.parse(pathlib.Path(chemin).read_text(encoding="utf-8")) + trouves = set() + for n in ast.walk(arbre): + if isinstance(n, ast.ClassDef) and ( + classe is None or n.name == classe + ): + trouves |= { + m.name for m in n.body if isinstance(m, ast.FunctionDef) + } + if classe is None and isinstance(n, ast.FunctionDef): + trouves.add(n.name) + return trouves + + +class TestPasDeDoublon(unittest.TestCase): + """L'architecture demandée : un seul endroit par idée. + + Ces tests échouent le jour où quelqu'un recopie un geste du plan dans un + formulaire plutôt que de l'appeler. C'est exactement ce qu'ils gardent. + """ + + PARTAGES = { + "_plan_entries", + "_row_ids", + "_row_key", + "_is_current", + "_focused_row", + "_set_override", + "_clear_overrides", + "_set_lock", + "_add_copy", + "_rename", + "_row_free", + "_read_row_free", + "_show_free", + "_apply_free", + "_sync_free_inputs", + "_row_echo", + } + + def test_the_mixin_owns_the_plan_gestures(self): + self.assertTrue( + self.PARTAGES <= membres(TODO_DIR / "deploy_form_plan.py") + ) + + def test_neither_form_redefines_them(self): + for fichier in ("qemu_deploy_form.py", "proxmox_deploy_form.py"): + redits = self.PARTAGES & membres(TODO_DIR / fichier) + self.assertEqual( + redits, + set(), + f"{fichier} redit ce que le socle porte déjà : {redits}", + ) + + def test_both_forms_inherit_the_mixin(self): + for fichier in ("qemu_deploy_form.py", "proxmox_deploy_form.py"): + src = pathlib.Path(TODO_DIR / fichier).read_text(encoding="utf-8") + self.assertIn("PlanMixin, App", src, fichier) + + def test_the_pure_logic_lives_in_one_place(self): + pures = { + "parse_ram", + "parse_disk", + "positive_int", + "plan_rows", + "plan_totals", + "build_vms", + "apply_profile", + "apply_overrides", + "expand_copies", + "entry_key", + "vm_name", + "clean_hostname", + "clip_payload", + "run_deploy_progress", + } + self.assertTrue(pures <= membres(TODO_DIR / "deploy_form_lib.py")) + # Réexportées par l'ancien module : les appelants historiques (et les + # tests déjà écrits) importent encore ces noms LÀ. + from script.todo import qemu_deploy_form as ancien + + for nom in pures: + self.assertTrue(hasattr(ancien, nom), nom) + + def test_the_row_factory_is_shared(self): + for fichier in ("qemu_deploy_form.py", "proxmox_deploy_form.py"): + src = pathlib.Path(TODO_DIR / fichier).read_text(encoding="utf-8") + self.assertIn("res_row_widgets(", src, fichier) + + def test_the_css_base_is_shared(self): + self.assertIn("#plan", lib.CSS_BASE) + self.assertIn("PreviewScreen", lib.CSS_BASE) + for fichier in ("qemu_deploy_form.py", "proxmox_deploy_form.py"): + src = pathlib.Path(TODO_DIR / fichier).read_text(encoding="utf-8") + self.assertIn("CSS_BASE", src, fichier) + # Les règles communes ne doivent PAS être recopiées à côté. + self.assertNotIn("#totals { height: auto;", src, fichier) + + +if __name__ == "__main__": + unittest.main(verbosity=2)