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)