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/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py new file mode 100644 index 0000000..d3cb875 --- /dev/null +++ b/script/todo/proxmox_deploy_form.py @@ -0,0 +1,687 @@ +#!/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 + +from script.todo.deploy_form_lib import ( + CSS_BASE, + FREE, + RES_FIELDS, + SELECT_TO_FIELD, + build_vms, + entry_key, + 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 [] + + 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", + ) + yield Static("ERPLibre", classes="grouptitle") + yield Checkbox( + t("Install ERPLibre"), 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", + ) + yield Checkbox( + t("Follow the installation (dashboard)"), + value=True, + id="f_monitor", + ) + yield Static(t("Parallelism"), classes="grouptitle") + 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() + + # ---------------------------------------------------------------- # + # 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, + ) + grow = ( + ctx.get("extra_disk_gb", 0) + if self.query_one("#f_install", Checkbox).value + else 0 + ) + self.rows = plan_rows( + self.vms, noms_pris, grow, orphelin=lambda _n: False + ) + 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 + alerte = ( + f" ⚠ {t('more RAM than the host has free')}" + if libre and ram > libre + else "" + ) + self.query_one("#totals", Static).update( + f" {n} {t('VM')} {cpu} vCPU {ram} Mo RAM " + f"{disque} Go [{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 on_checkbox_changed(self, event) -> None: + if event.checkbox.id == "f_install": + # Le disque d'ERPLibre entre — ou sort — du total. + 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 or not self._is_current(event.select): + return + ident = event.select.id or "" + 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/todo_i18n.py b/script/todo/todo_i18n.py index 6327a7a..699a6b2 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3148,8 +3148,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 !", @@ -3299,6 +3302,95 @@ 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 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", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py new file mode 100644 index 0000000..3f76996 --- /dev/null +++ b/test/test_proxmox_form.py @@ -0,0 +1,300 @@ +#!/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"), + ], + "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", + "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")], + "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) + 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 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_todo_deploy_form_lib.py b/test/test_todo_deploy_form_lib.py new file mode 100644 index 0000000..eec56e3 --- /dev/null +++ b/test/test_todo_deploy_form_lib.py @@ -0,0 +1,288 @@ +#!/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 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)