From c0d6b114afa22fb4afcc3ca728af712cf9a32c16 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 01:34:10 -0400 Subject: [PATCH] [FIX] qemu : ne pas poser ERPLibre sur une VM Proxmox VE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choisir « Proxmox VE » comme système, c'est demander qu'il soit installé. L'invite en ligne le savait ; le formulaire posait « ERPLibre + Odoo 18 », lui ajoutait les 5 Go réservés au dépôt et annonçait une cible make dans le guide de la VM. La règle vit maintenant en un seul endroit, les deux chemins la lisent, et un choix explicite l'emporte toujours sur elle. Trois défauts trouvés derrière. Une commande par VM n'était retenue que si DEUX VM différaient : déployée seule, la VM Proxmox retombait sur la commande commune. Le disque choisi à la main disparaissait quand rien n'était installé — 60 G demandés, 20 G créés. Et une taille absente des préréglages marquait la rangée ✎ dès son montage. --- EN --- Choosing "Proxmox VE" as the system means asking for it to be installed. The command-line prompt knew that; the form set "ERPLibre + Odoo 18", added the 5 GB meant for the repository and advertised a make target in the VM's guide. The rule now lives in one place, both paths read it, and an explicit choice always wins over it. Three defects behind it. A per-VM command was only used when TWO VMs differed: deployed alone, the Proxmox VM fell back to the common command. A hand-picked disk size vanished when nothing was installed — 60 G asked, 20 G created. And a size absent from the presets marked its row ✎ on mount. Assisted-by: Claude Opus 5 --- script/todo/proxmox_deploy_form.py | 35 +++- script/todo/proxmox_menu.py | 17 +- script/todo/qemu_deploy.py | 50 ++++- script/todo/qemu_deploy_form.py | 73 +++++-- script/todo/qemu_install.py | 58 +++++- test/test_proxmox_form.py | 30 +++ test/test_qemu_install_profile.py | 297 +++++++++++++++++++++++++++++ 7 files changed, 526 insertions(+), 34 deletions(-) create mode 100644 test/test_qemu_install_profile.py diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index 9b447f9..3815d21 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -122,6 +122,16 @@ def run_proxmox_form(ctx, run_app: bool = True): 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']}" @@ -344,14 +354,23 @@ def run_proxmox_form(ctx, run_app: bool = True): self.custom, self.overrides, ) - grow = ( - ctx.get("extra_disk_gb", 0) - if self.query_one("#f_install", Checkbox).value - else 0 - ) + # 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, grow, orphelin=lambda _n: False + 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)) @@ -551,7 +570,9 @@ def run_proxmox_form(ctx, run_app: bool = True): # 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): + 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() diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 66c7a97..6e171db 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -643,6 +643,14 @@ class ProxmoxMenuMixin: "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, @@ -825,11 +833,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): diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 5419578..399961d 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -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 @@ -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 (), ) @@ -1223,6 +1249,14 @@ 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, @@ -1930,14 +1964,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 c049733..4fe3b15 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -106,6 +106,16 @@ 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 @@ -497,7 +507,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 ( @@ -520,16 +532,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 @@ -695,12 +724,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 @@ -1019,10 +1061,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 ) @@ -1126,9 +1169,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) 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/test/test_proxmox_form.py b/test/test_proxmox_form.py index 7860f88..0b17cbf 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -154,6 +154,7 @@ def contexte(): entree("ubuntu", "26.04"), entree("debian", "13"), entree("fedora", "44"), + entree("proxmox", "9"), ], "arm64": [entree("debian", "13", "arm64")], }, @@ -174,6 +175,12 @@ def contexte(): "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], @@ -350,6 +357,29 @@ class TestEcran(unittest.TestCase): 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 ». 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)