diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index f5979f4..6aadf94 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -15,6 +15,13 @@ # l'annonce donc avant de commencer. # # ./script/test/run_unit_test.sh [fichiers...] +# +# NOMMER UN NOUVEAU FICHIER : sans argument, ce script ne prend que les +# préfixes listés plus bas (test_qemu_, test_todo_, test_proxmox_…) — le reste +# de test/ demande une base de données. Un fichier hors préfixe tombe donc dans +# le même silence qu'un fichier absent : douze tests écrits, jamais lancés, et +# un total qui n'a pas bougé. Choisir le préfixe de sa famille, ou l'ajouter +# ici. set -uo pipefail Red='\033[0;31m' diff --git a/script/todo/deploy_form_extras.py b/script/todo/deploy_form_extras.py new file mode 100644 index 0000000..c611a6b --- /dev/null +++ b/script/todo/deploy_form_extras.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce qu'on installe DANS la VM, et qui ne regarde pas l'hyperviseur. + +Type de VM, production, magasin d'applications, outils de développement, +fuseau horaire, interpréteur Python : six réglages qui décrivent le système +invité, pas la machine qui le porte. Ils valent donc mot pour mot sur libvirt +et sur Proxmox VE — et c'est exactement ce qui avait dérivé. L'écran QEMU/KVM +les portait tous, l'écran Proxmox trois : une VM créée là-bas naissait sans +bureau, sans outils et en UTC, sans que rien ne le dise. + +La duplication était le mécanisme de la dérive, pas son symptôme : chaque +correctif se posait sur un seul des deux écrans. Ce module met les six ici, +une seule fois, widgets ET logique. Un formulaire hérite d'`ExtrasMixin`, +appelle `extras_init(ctx)` puis pose les fragments de `compose_*` où sa mise +en page les veut. + +Le contrat côté formulaire, court exprès : l'attribut `vms` (les VM retenues) +et une case `#f_install`. Rien d'autre — ce module ne connaît ni stockage, ni +pont, ni domaine libvirt. + +Chaque accès aux widgets est gardé : un formulaire n'est pas tenu de poser +tous les fragments, et lire un widget absent ne doit pas casser l'écran. +""" + +from script.todo.deploy_form_lib import FREE, t + +# Ce que l'écran lit du contexte. Une seule liste, parce que les deux +# formulaires doivent en recevoir autant : c'est en fournissant un +# sous-ensemble que l'écran Proxmox avait perdu la moitié des réglages. +_TABLES = ( + ("desktops", list, "desktops"), + ("desktop_disk", int, "desktop_disk_gb"), + ("app_stores", list, "app_stores"), + ("snap_distros", set, "snap_distros"), + ("timezones", list, "timezones"), + ("timezone", str, "timezone"), + ("vm_tools", list, "vm_tools"), + ("tool_disk", dict, "vm_tool_disk"), + ("tool_phases", dict, "vm_tool_phases"), + ("tool_arches", dict, "vm_tool_arches"), + ("tool_desktops", dict, "vm_tool_desktops"), + ("tool_needs_desktop", dict, "vm_tool_needs_desktop"), + ("tool_families", dict, "vm_tool_families"), + ("distro_family", dict, "distro_family"), + ("mise_arches", set, "mise_arches"), + ("defaults", dict, "defaults"), +) + + +def extras_tables(ctx) -> dict: + """Les tables lues du contexte, chacune ramenée à son type. + + Ramenées, et non prises telles quelles : une clé absente rend un vide du + bon type, donc les prédicats plus bas n'ont aucun cas particulier à + porter. Un formulaire qui n'offre pas les outils passe simplement un + contexte sans « vm_tools ».""" + ctx = ctx or {} + return {nom: kind(ctx.get(cle) or kind()) for nom, kind, cle in _TABLES} + + +def tools_for_vm(vm, tools, tab) -> list: + """Outils qu'une VM donnée recevra VRAIMENT. + + Le même filtre que côté déploiement, et c'est le point : une VM ARM ne + verra jamais Android Studio, une VM Cinnamon jamais les extensions GNOME, + un serveur aucun des IDE — mais un serveur reçoit bien la compilation + mobile, qui n'a rien à afficher, et une distribution sans apt ne la + reçoit pas, son installateur n'existant que là. + + Filtrer ici plutôt que dans la commande distante permet d'annoncer + l'écart AVANT le déploiement, au lieu de le laisser découvrir dans un + journal d'installation d'une heure.""" + out = [] + for key in tools or (): + arches = tab["tool_arches"].get(key) or () + desks = tab["tool_desktops"].get(key) or () + fams = tab["tool_families"].get(key) or () + if tab["tool_needs_desktop"].get(key) and not vm.get("desktop"): + continue + if arches and vm.get("arch") not in arches: + continue + if desks and vm.get("desktop") not in desks: + continue + if fams and tab["distro_family"].get(vm.get("distro"), "") not in fams: + continue + out.append(key) + return out + + +def app_store_needed(vms, tab) -> bool: + """Le choix du magasin n'a de sens que pour une VM GRAPHIQUE sur une + distribution qui livre snapd. Ailleurs, rien ne tire de snap.""" + return any( + vm.get("desktop") and vm.get("distro") in tab["snap_distros"] + for vm in vms or () + ) + + +def mise_usable(vms, tab) -> bool: + """Au moins une VM retenue tourne sur une architecture que mise sert.""" + return any(vm.get("arch") in tab["mise_arches"] for vm in vms or ()) + + +def extras_disk_gb(vm, tools, tab) -> int: + """Go que le bureau et les outils ajoutent au disque de CETTE VM. + + De cette VM et d'elle seule : un supplément commun mentait dès que les + types différaient d'une machine à l'autre, et gonflait le disque d'un + serveur pour un bureau qu'il n'aurait pas.""" + gb = tab["desktop_disk"] if vm.get("desktop") else 0 + return gb + sum( + tab["tool_disk"].get(k, 0) for k in tools_for_vm(vm, tools, tab) + ) + + +class ExtrasMixin: + """Les six réglages du système invité, widgets et logique. + + Le formulaire hôte pose les fragments `compose_*` où il veut, appelle + `render_extras()` après chaque recalcul et `extras_values()` au moment de + bâtir sa spec.""" + + def extras_init(self, ctx) -> None: + """À appeler dans `__init__`, avant tout `compose`.""" + self._extras = extras_tables(ctx) + + # ------------------------------------------------------------------ # + # Les widgets + # ------------------------------------------------------------------ # + def compose_vm_type(self): + """Type de VM par défaut. Serveur d'abord : c'est ce que sert une + image cloud, et un bureau ajoute une à deux heures sur une + architecture émulée. Le plan annonce le surcoût disque.""" + from textual.widgets import RadioButton, RadioSet, Static + + tab = self._extras + if not tab["desktops"]: + return + yield Static(t("VM type (default):"), classes="grouptitle") + with RadioSet(id="f_type"): + yield RadioButton( + t("Server (no graphical interface)"), + value=not tab["defaults"].get("desktop", ""), + ) + for key, label in tab["desktops"]: + yield RadioButton( + f"{t('Graphical (server + desktop):')} {label}", + value=tab["defaults"].get("desktop", "") == key, + ) + + def compose_install_extras(self): + """Ce qui pend à la case « installer » : production, magasin, + outils. Posé DANS la section « Installation », après la branche et le + profil.""" + from textual.widgets import Checkbox, RadioButton, RadioSet, Static + + tab = self._extras + yield Checkbox( + t("Production (/opt, confined)"), + value=tab["defaults"].get("prod", False), + id="f_prod", + ) + if tab["app_stores"]: + yield Static( + t("Application store:"), id="t_store", classes="grouptitle" + ) + with RadioSet(id="f_store"): + for i, (_k, label) in enumerate(tab["app_stores"]): + yield RadioButton(label, value=i == 0) + yield Static("", id="storewarn") + if tab["vm_tools"]: + # Une case par outil, et non une liste déroulante : ils sont + # indépendants, et chacun se prend ou se laisse. + yield Static( + t("Development tools:"), id="t_tools", classes="grouptitle" + ) + for key, label, hint in tab["vm_tools"]: + gb = tab["tool_disk"].get(key, 0) + yield Checkbox( + f"{label} +{gb} Go — {hint}", + value=key in (tab["defaults"].get("tools") or ()), + id=f"f_tool_{key}", + ) + yield Static("", id="toolwarn") + + def compose_timezone(self): + """Une liste plutôt qu'une saisie : un nom IANA mal orthographié + n'est pas refusé par cloud-init, il est IGNORÉ — la VM reste en UTC et + on ne s'en aperçoit qu'aux horodatages. « libre… » garde la porte + ouverte aux six cents autres fuseaux de la base.""" + from textual.widgets import Input, Select, Static + + tab = self._extras + if not tab["timezones"]: + return + null = getattr(Select, "NULL", Select.BLANK) + yield Static(t("Timezone"), classes="grouptitle") + yield Select( + [(z, z) for z in tab["timezones"]] + [(t("free value…"), FREE)], + value=(tab["timezones"][0] if tab["timezones"] else null), + allow_blank=False, + id="f_tz_sel", + ) + yield Input( + value=tab["timezone"], + placeholder=t("Timezone for the VMs"), + id="f_tz", + classes="freeval", + ) + + def compose_python(self): + """mise pose un CPython PRÉCOMPILÉ, pyenv le COMPILE. Grisé quand + aucune des VM retenues n'est sur une architecture que mise sert.""" + from textual.widgets import RadioButton, RadioSet, Static + + yield Static( + t("Python interpreter:"), id="t_python", classes="grouptitle" + ) + with RadioSet(id="f_python"): + yield RadioButton(t("mise (precompiled, faster)"), value=True) + yield RadioButton(t("pyenv (compiles from source)")) + yield Static("", id="miswarn") + + # ------------------------------------------------------------------ # + # Lire les widgets + # ------------------------------------------------------------------ # + def _widget(self, selector): + """Le widget, ou None s'il n'est pas de cet écran.""" + try: + return self.query_one(selector) + except Exception: + return None + + def _default_desktop(self) -> str: + """« » pour un serveur, sinon la clé de la saveur choisie. Chaque + rangée du plan peut s'en écarter.""" + desktops = self._extras["desktops"] + widget = self._widget("#f_type") + if widget is None: + return "" + index = widget.pressed_index + if index is None or index < 1 or index > len(desktops): + return "" + return desktops[index - 1][0] + + def _install_state(self): + """(une installation ?, quelque chose à installer ?). + + Deux réponses et non une : sans installation mais avec un bureau, il + se pose encore des paquets — le magasin d'applications et les outils + de la phase « avant » gardent un effet.""" + widget = self._widget("#f_install") + installe = True if widget is None else bool(widget.value) + return installe, bool(installe or self._default_desktop()) + + def _vm_tools(self) -> tuple: + """Clés des outils cochés, dans l'ordre de la liste.""" + picked = [] + for key, _label, _hint in self._extras["vm_tools"]: + widget = self._widget(f"#f_tool_{key}") + if widget is not None and widget.value: + picked.append(key) + return tuple(picked) + + def _tools_for_vm(self, vm, tools) -> list: + return tools_for_vm(vm, tools, self._extras) + + def _app_store_needed(self) -> bool: + return app_store_needed(self.vms, self._extras) + + def _app_store(self) -> str: + """Magasin retenu. Sans VM concernée, la réponse est « deb » : elle + ne change rien, et laisser passer « snap » réactiverait snapd pour + rien.""" + stores = self._extras["app_stores"] + widget = self._widget("#f_store") + if not stores or widget is None or not self._app_store_needed(): + return "deb" + index = widget.pressed_index + if index is None or not (0 <= index < len(stores)): + return stores[0][0] + return stores[index][0] + + def _mise_usable(self) -> bool: + return mise_usable(self.vms, self._extras) + + def _python_provider(self) -> str: + """« mise », « pyenv », ou rien — c'est-à-dire « automatique ». + + Rien, et surtout pas « pyenv », quand mise n'est servi par aucune + architecture retenue. « mise est indisponible » ne veut pas dire + « l'utilisateur exige pyenv » : la nuance décide de tout, puisqu'un + choix EXPLICITE écarte le Python de la distribution. Sur s390x, + renvoyer « pyenv » forçait la compilation de CPython — celle dont gcc + 15.2 ne revient pas.""" + widget = self._widget("#f_python") + if widget is None or not self._mise_usable(): + return "" + return "pyenv" if widget.pressed_index == 1 else "mise" + + def _timezone(self) -> str: + """Le fuseau des VM. Un champ vidé retombe sur celui de l'hôte plutôt + que sur rien : sans valeur, la VM démarrerait en UTC.""" + widget = self._widget("#f_tz") + saisi = widget.value.strip() if widget is not None else "" + return saisi or self._extras["timezone"] + + def _extras_disk_gb(self, vm, tools) -> int: + return extras_disk_gb(vm, tools, self._extras) + + # ------------------------------------------------------------------ # + # Redessiner + # ------------------------------------------------------------------ # + def _render_store(self) -> None: + """Grise le choix quand aucune VM ne le concerne, et dit pourquoi.""" + widget = self._widget("#f_store") + if widget is None: + return + needed = self._app_store_needed() + _installe, quelque_chose = self._install_state() + widget.disabled = not (needed and quelque_chose) + note = self._widget("#storewarn") + if note is not None: + note.update( + "" + if needed + else f" {t('No graphical VM on a snap-based distro.')}" + ) + + def _render_tools(self) -> None: + """Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et NOMME + ce qui sera écarté. + + Une case par outil, et non un blocage en bloc : sur un parc de + serveurs les IDE se grisent, la compilation mobile reste offerte. + Cocher Android Studio sur un parc ARM ne produit rien — le dire ici + évite de le découvrir dans le journal d'installation.""" + tab = self._extras + if not tab["vm_tools"]: + return + installe, quelque_chose = self._install_state() + for key, _label, _hint in tab["vm_tools"]: + widget = self._widget(f"#f_tool_{key}") + if widget is None: + continue + usable = any(self._tools_for_vm(vm, (key,)) for vm in self.vms) + # Un outil de la phase « après » vit DANS le dépôt ERPLibre : + # sans installation, il n'a rien où s'installer, bureau ou pas. + offert = ( + installe + if tab["tool_phases"].get(key) == "after" + else quelque_chose + ) + widget.disabled = not (usable and offert) + picked = self._vm_tools() + skipped = sorted( + { + vm["name"] + for vm in self.vms + for k in picked + if k not in self._tools_for_vm(vm, picked) + } + ) + note = self._widget("#toolwarn") + if note is not None: + note.update( + f" ⚠ {t('Partly skipped (arch or desktop):')} " + f"{', '.join(skipped)}" + if skipped + else "" + ) + + def _render_mise(self) -> None: + """Grise le choix quand aucune VM retenue n'est servie par mise, ou + quand rien ne s'installe, et NOMME les architectures qui retomberont + sur pyenv.""" + widget = self._widget("#f_python") + if widget is None: + return + installe, _quelque_chose = self._install_state() + widget.disabled = not (self._mise_usable() and installe) + ecartees = sorted( + { + vm["arch"] + for vm in self.vms + if vm.get("arch") not in self._extras["mise_arches"] + } + ) + note = self._widget("#miswarn") + if note is not None: + note.update( + f" ⚠ {t('mise has no binary for:')} " + f"{', '.join(ecartees)} — {t('those VMs use pyenv')}" + if ecartees + else "" + ) + + def render_extras(self) -> None: + """Les trois, dans l'ordre. Appelée à la fin de chaque recalcul.""" + self._render_mise() + self._render_store() + self._render_tools() + + def _sync_install_deps(self) -> None: + """Grise ce que le choix d'installation rend SANS EFFET. + + Trois états et non deux, parce que la commande distante en a trois : + rien du tout, un bureau seul, ou une installation complète. Sans + installation MAIS avec un bureau, le magasin d'applications et les + outils de la phase « avant » servent encore — les griser mentirait + autant que de laisser actif ce qui ne fait rien. La branche, le + profil et l'interpréteur Python, eux, ne servent qu'à l'installation. + + Le type de VM et le suivi ne sont jamais grisés : le premier est + l'autre moitié de la décision, le second regarde la VM arriver même + quand rien ne s'installe.""" + installe, quelque_chose = self._install_state() + for cible, actif in ( + ("#f_branch", installe), + ("#f_profile_install", installe), + ("#f_prod", quelque_chose), + ): + widget = self._widget(cible) + if widget is not None: + widget.disabled = not actif + # Le magasin, les outils et l'interpréteur Python ont leur PROPRE + # raison de se griser (architecture, bureau, distribution) : ils + # composent les deux dans « _render_* », qui a le dernier mot. + # Le titre suit ses champs : une section entière se lit inactive d'un + # coup d'œil, au lieu de se déduire de trois widgets ternes. + for cible, actif in ( + ("#t_store", quelque_chose), + ("#t_tools", quelque_chose), + ("#t_python", installe), + ): + widget = self._widget(cible) + if widget is not None: + widget.set_class(not actif, "off") + # Les rangées portent les mêmes choix, par VM. + for i in range(len(getattr(self, "rows", ()))): + for cible in (f"#v{i}_branch", f"#v{i}_prof"): + widget = self._widget(cible) + if widget is not None: + widget.disabled = not installe + + # ------------------------------------------------------------------ # + # Les messages, et la spec + # ------------------------------------------------------------------ # + def extras_on_select(self, event) -> bool: + """Traite le sélecteur de fuseau. Rend True quand c'est fait, pour + que l'appelant s'arrête là. + + « libre… » révèle la saisie ; un fuseau choisi la referme et y + recopie le nom, seule valeur que lit `extras_values` — un seul + endroit porte la réponse.""" + if getattr(event.select, "id", "") != "f_tz_sel": + return False + free = event.value is FREE + field = self._widget("#f_tz") + if field is None: + return True + field.display = free + field.disabled = not free + if free: + field.focus() + elif isinstance(event.value, str): + field.value = event.value + return True + + def extras_values(self) -> dict: + """Le fragment de spec que ces réglages produisent.""" + return { + "timezone": self._timezone(), + "desktop": self._default_desktop(), + "vm_tools": self._vm_tools(), + "python_provider": self._python_provider(), + "app_store": self._app_store(), + } diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index fbacb0f..5b57363 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -39,6 +39,7 @@ from script.todo.deploy_form_lib import ( res_row_widgets, t, ) +from script.todo.deploy_form_extras import ExtrasMixin from script.todo.deploy_form_plan import PlanMixin, preview_screen # Aucun disque orphelin à craindre : les disques d'un Proxmox distant vivent @@ -100,6 +101,14 @@ def build_spec(vms, existants, form): "add_ssh_config": form["add_ssh_config"], "install": form["install"], "python_provider": form.get("python_provider") or "", + # Ce qui décrit le SYSTÈME INVITÉ, et non l'hyperviseur : il valait + # déjà sur libvirt, il vaut ici. Sans ces cinq clés, une VM créée sur + # Proxmox naissait sans bureau, sans outils et en UTC. + "timezone": form.get("timezone") or "", + "desktop": form.get("desktop") or "", + "vm_tools": tuple(form.get("vm_tools") or ()), + "app_store": form.get("app_store") or "deb", + "prod": bool((form.get("install") or {}).get("prod")), "monitor": form["monitor"], "parallelism": form["parallelism"], } @@ -136,6 +145,8 @@ def run_proxmox_form(ctx, run_app: bool = True): ctx.get("branches") or ["master"], ctx.get("branch_current") ) profiles = ctx.get("install_profiles") or [] + # {clé de saveur: suffixe de nom} — décrit par todo.py, source unique. + desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) stockages = ctx.get("storages") or [] ponts = ctx.get("bridges") or [] # {système: (libellé, commande)} — ce qu'un système impose d'installer. @@ -152,7 +163,7 @@ def run_proxmox_form(ctx, run_app: bool = True): def entry_label(e): return f"{e['distro']} {e['version']} [{e['arch']}] {e['name']}" - class ProxmoxForm(PlanMixin, App): + class ProxmoxForm(ExtrasMixin, PlanMixin, App): TITLE = t("Deploy one or more ERPLibre VMs on Proxmox VE!") BINDINGS = [ ("f5", "deploy", t("Deploy")), @@ -192,6 +203,7 @@ def run_proxmox_form(ctx, run_app: bool = True): self._syncing = False # La liste des ponts GRANDIT : l'écran sait en créer un. self._ponts = list(ponts) + self.extras_init(ctx) # ---------------------------------------------------------------- # # L'écran @@ -295,6 +307,7 @@ def run_proxmox_form(ctx, run_app: bool = True): value=True, id="f_sshcfg", ) + yield from self.compose_vm_type() # La case commande TOUTE installation — ERPLibre, Odoo, # mais aussi l'hyperviseur Proxmox VE d'une VM imbriquée. # Nommée « ERPLibre », elle laissait croire qu'un système @@ -323,22 +336,9 @@ def run_proxmox_form(ctx, run_app: bool = True): allow_blank=False, id="f_branch", ) - # Le même choix qu'en QEMU/KVM : mise pose un CPython - # PRÉCOMPILÉ, pyenv le COMPILE. Sans cette section, - # l'écran envoyait toujours « automatique » — et comme - # mise n'est jamais installé d'office, c'était pyenv, donc - # une compilation de Python. Rapporté sur une VM Arch. - yield Static( - t("Python interpreter:"), - id="t_python", - classes="grouptitle", - ) - with RadioSet(id="f_python"): - yield RadioButton( - t("mise (precompiled, faster)"), value=True - ) - yield RadioButton(t("pyenv (compiles from source)")) - yield Static("", id="miswarn") + yield from self.compose_install_extras() + yield from self.compose_timezone() + yield from self.compose_python() # Hors de la section « Installation » : le suivi regarde la # VM ARRIVER, même quand rien ne s'installe. Rangé dedans, # il se serait grisé avec elle. @@ -352,10 +352,21 @@ def run_proxmox_form(ctx, run_app: bool = True): id="f_monitor", ) yield Static(f" {t('Parallelism')}") + # Cochée, la case donne une exécution PAR installation : + # le plafond du nombre de CPU ne s'applique plus. + # Décochée, le nombre reprend la main, et son défaut suit + # l'HÔTE PROXMOX — l'écran restait figé à quatre choix et + # en proposait un, quel que soit le nombre de cœurs. + yield Checkbox( + t("One run per install"), + value=True, + id="f_par_all", + ) yield Select( - [(str(n), n) for n in (1, 2, 3, 4)], - value=1, + [(str(n), n) for n in range(1, ctx["host_cpu"] + 1)], + value=ctx["host_cpu"], allow_blank=False, + disabled=True, id="f_par", ) with Vertical(id="right"): @@ -458,6 +469,8 @@ def run_proxmox_form(ctx, run_app: bool = True): ctx["host_cpu"], self.custom, self.overrides, + self._default_desktop(), + desktop_suffixes, ) # Ce qu'un système IMPOSE d'installer, posé sur le MODÈLE : le # déploiement lit « install_cmd » VM par VM. @@ -470,12 +483,17 @@ def run_proxmox_form(ctx, run_app: bool = True): ) # 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) + installe = self.query_one("#f_install", Checkbox).value + commun = (self._install() or {}).get("cmd") or "" + outils = self._vm_tools() + for row in self.rows: + cmd_vm = row["vm"].get("install_cmd") or commun + if installe and cmd_vm.strip() not in no_erplibre: + row["disk_gb"] += ctx.get("extra_disk_gb", 0) + # Le bureau et les outils ne pèsent que sur les VM qui les + # reçoivent RÉELLEMENT : Android Studio n'existe qu'en + # x86_64, les extensions GNOME n'ont de sens que sous GNOME. + row["disk_gb"] += self._extras_disk_gb(row["vm"], outils) for row, entry in zip(self.rows, entries): cle = entry_key(entry) row["custom"] = bool(self.overrides.get(cle)) @@ -489,7 +507,18 @@ def run_proxmox_form(ctx, run_app: bool = True): ), ) self._render_plan() - self._render_mise() + self.render_extras() + + def _auto_name(self, index): + """Le nom du catalogue, suffixé du bureau : ce que la VM + reprendrait si on effaçait le sien.""" + from script.todo.deploy_form_lib import vm_name + + return vm_name( + self._plan_entries()[index]["name"], + self.rows[index]["vm"].get("desktop"), + desktop_suffixes, + ) def _vmid_start(self): brut = self.query_one("#f_vmid", Input).value.strip() @@ -634,6 +663,10 @@ def run_proxmox_form(ctx, run_app: bool = True): self.arch = arches[event.index] self._reload_catalog() return + if event.radio_set.id in ("f_type", "f_store"): + # Le bureau pèse sur le disque et change le nom des VM. + self._refresh_after(remonter=True) + return if event.radio_set.id == "f_profile": choix = ("1", "2", "3", "4", "custom")[event.index] self.profile = choix @@ -649,28 +682,17 @@ def run_proxmox_form(ctx, run_app: bool = True): self._clear_overrides(tuple(RES_FIELDS)) self._refresh_after(remonter=True) - def _sync_install_deps(self) -> None: - """Grise ce que la case rend sans effet : la branche et le profil. - - Le suivi n'en fait pas partie — il regarde la VM arriver même - quand rien ne s'installe.""" - installe = self.query_one("#f_install", Checkbox).value - self._render_mise() - for cible in ("#f_profile_install", "#f_branch"): - try: - self.query_one(cible).disabled = not installe - except Exception: - pass - try: - self.query_one("#t_install").set_class(not installe, "off") - except Exception: - pass - def on_checkbox_changed(self, event) -> None: if event.checkbox.id == "f_install": # Le disque d'ERPLibre entre — ou sort — du total. self._sync_install_deps() self._refresh_after() + elif event.checkbox.id == "f_par_all": + self.query_one("#f_par", Select).disabled = event.value + elif str(event.checkbox.id or "").startswith("f_tool_"): + # Un IDE de plus, c'est un disque plus grand : le plan doit + # le montrer AVANT de déployer, pas après une heure. + self._refresh_after() def on_input_changed(self, event) -> None: if event.input.id == "f_vmid": @@ -693,6 +715,8 @@ def run_proxmox_form(ctx, run_app: bool = True): def on_select_changed(self, event) -> None: if self._syncing: return + if self.extras_on_select(event): + return ident = event.select.id or "" # La marque de génération ne concerne QUE les widgets de rangée : # un widget global n'en porte pas. L'exiger de tous revenait à @@ -767,53 +791,6 @@ def run_proxmox_form(ctx, run_app: bool = True): # ---------------------------------------------------------------- # # Les actions # ---------------------------------------------------------------- # - def _mise_usable(self): - """Au moins une VM retenue tourne sur une architecture servie par - mise. Sinon le choix ne veut rien dire.""" - servies = ctx.get("mise_arches") or () - return any(vm["arch"] in servies for vm in self.vms) - - def _python_provider(self): - """« mise », « pyenv », ou rien — c'est-à-dire « automatique ». - - Rien, et surtout pas « pyenv », quand mise n'est servi par aucune - architecture retenue : « mise indisponible » ne veut pas dire - « l'utilisateur exige pyenv », et un choix EXPLICITE écarte le - Python de la distribution.""" - if not self._mise_usable(): - return "" - try: - index = self.query_one("#f_python", RadioSet).pressed_index - except Exception: - return "" - return "pyenv" if index == 1 else "mise" - - def _render_mise(self): - """Grise le choix quand aucune VM retenue n'est servie par mise, ou - quand rien ne s'installe, et NOMME les architectures qui - retomberont sur pyenv.""" - servies = ctx.get("mise_arches") or () - try: - installe = self.query_one("#f_install", Checkbox).value - self.query_one("#f_python", RadioSet).disabled = not ( - self._mise_usable() and installe - ) - ecartees = sorted( - { - vm["arch"] - for vm in self.vms - if vm["arch"] not in servies - } - ) - self.query_one("#miswarn", Static).update( - f" ⚠ {t('mise has no binary for:')} " - f"{', '.join(ecartees)} — {t('those VMs use pyenv')}" - if ecartees - else "" - ) - except Exception: - pass - def _install(self): if not self.query_one("#f_install", Checkbox).value: return None @@ -825,6 +802,9 @@ def run_proxmox_form(ctx, run_app: bool = True): ) return { "branch": self.query_one("#f_branch", Select).value, + # /opt et service confiné, comme en QEMU/KVM : le choix ne + # regarde pas l'hyperviseur, il regarde le système invité. + "prod": self.query_one("#f_prod", Checkbox).value, "label": label, "cmd": cmd, } @@ -841,11 +821,18 @@ def run_proxmox_form(ctx, run_app: bool = True): "start": self.query_one("#f_start", Checkbox).value, "add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value, "install": self._install(), + **self.extras_values(), # Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans # ERPLibre se suit aussi (cloud-init, puis relevé système). - "python_provider": self._python_provider(), "monitor": self.query_one("#f_monitor", Checkbox).value, - "parallelism": self.query_one("#f_par", Select).value, + # Une exécution par installation : le nombre de VM retenues + # fait foi. Le déploiement le borne ensuite à ce même nombre, + # donc une valeur haute ne crée aucun travailleur inutile. + "parallelism": ( + max(1, len(self.vms)) + if self.query_one("#f_par_all", Checkbox).value + else self.query_one("#f_par", Select).value + ), } def action_deploy(self) -> None: diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 3a0ff9a..294fef0 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -833,12 +833,11 @@ class ProxmoxMenuMixin: # La branche du dépôt : c'est elle qu'on déploie le plus souvent. "branch_current": self._qemu_repo_branch(), "install_profiles": self._qemu_install_profiles(), - # Les architectures pour lesquelles mise publie un CPython - # précompilé : sans ce choix, l'écran Proxmox envoyait toujours - # « automatique », donc pyenv, qui COMPILE Python (1 à 3 min sur - # une machine récente, bien plus sous émulation) — rapporté sur - # une VM Arch. - "mise_arches": self.QEMU_MISE_ARCHES, + # Type de VM, magasin d'applications, outils, fuseau, + # interpréteur Python : les réglages du système INVITÉ, qui ne + # regardent pas l'hyperviseur. Cet écran n'en portait que trois — + # une VM créée ici naissait sans bureau, sans outils et en UTC. + **self._qemu_guest_context(), # 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. @@ -887,15 +886,30 @@ class ProxmoxMenuMixin: la demande initiale quand ERPLibre s'installe. Ici la marge se perdait entre l'écran et « qm resize ». """ + from script.todo.deploy_form_extras import ( + extras_disk_gb, + extras_tables, + ) + demande = vm.get("disk") or "" - install = spec.get("install") or {} - cmd = vm.get("install_cmd") or install.get("cmd") or "" - if not self._qemu_installs_erplibre(install.get("branch"), cmd): - return demande gigs = self._parse_disk_gb(demande) if not gigs: return demande - return f"{gigs + self.ERPLIBRE_EXTRA_DISK_GB}G" + install = spec.get("install") or {} + cmd = vm.get("install_cmd") or install.get("cmd") or "" + marge = 0 + if self._qemu_installs_erplibre(install.get("branch"), cmd): + marge += self.ERPLIBRE_EXTRA_DISK_GB + # Le bureau et les outils pèsent aussi, et sur la VM QUI LES REÇOIT : + # une VM ARM n'aura pas Android Studio, un serveur aucun des IDE. Le + # plan les additionne déjà à l'écran ; sans eux ici, la VM naissait + # avec le disque d'un serveur nu et GNOME le remplissait. + marge += extras_disk_gb( + dict(vm, desktop=vm.get("desktop") or spec.get("desktop") or ""), + spec.get("vm_tools") or (), + extras_tables(self._qemu_guest_context()), + ) + return f"{gigs + marge}G" if marge else demande def _pve_vm_commands(self, mod, vm, spec): """Les commandes de création d'UNE VM, dans l'ordre : l'image puis @@ -1107,6 +1121,33 @@ class ProxmoxMenuMixin: fh.write("\n".join(entete) + "\n") return chemin + def _pve_set_timezone(self, cible, spec): + """Pose le fuseau DANS la VM, par ssh. + + La voie libvirt le donne à cloud-init, qui écrit /etc/timezone au + premier démarrage. « qm set » n'a pas d'équivalent : le cloud-init de + Proxmox ne règle que l'utilisateur, la clé et le réseau. Une VM créée + ici restait donc en UTC — et on ne s'en aperçoit qu'aux horodatages, + parfois des jours plus tard. + + AVANT l'installation, pour que le journal porte déjà la bonne heure. + Un nom IANA, jamais un décalage : « UTC-5 » ne dit rien de l'heure + d'été, et timedatectl le refuse. + """ + fuseau = (spec.get("timezone") or "").strip() + if not fuseau: + return False + code, sortie = self._pve_ssh( + cible, f"sudo timedatectl set-timezone {shlex.quote(fuseau)}" + ) + if code: + # Nommé et non tu : la VM reste en UTC, et c'est une surprise + # qu'on veut avoir maintenant plutôt qu'au premier journal. + print(f" ⚠ {t('timezone not set')} : {fuseau} ({code})") + return False + print(f" ✓ {t('Timezone')} : {fuseau}") + return True + def _pve_write_guide(self, cible, vm, spec, mod): """Pose le guide de connexion et l'identité git DANS la VM. @@ -1331,6 +1372,8 @@ class ProxmoxMenuMixin: # s'installe, et l'installation ne le touche pas. if vm["alias"] and mod_qemu: self._pve_write_guide(vm["alias"], vm, spec, mod_qemu) + if vm["alias"]: + self._pve_set_timezone(vm["alias"], spec) joignables.append(vm) install = spec.get("install") # Rendu à l'appelant pour son sommaire : lui seul sait ce qui a été @@ -1383,8 +1426,24 @@ class ProxmoxMenuMixin: branche, {n: alias.get(n, n) for n in noms}, finale, + # Les réglages du système invité, qui n'atteignaient pas la + # commande distante : la VM naissait serveur nu, sans outils. + prod=bool(spec.get("prod")), + desktop=spec.get("desktop") or "", python_provider=spec.get("python_provider") or "", + app_store=spec.get("app_store") or "deb", + vm_tools=spec.get("vm_tools") or (), pve=cartes_pve, + # Ce que sont ces VM, pris de la SPEC. Le suivi le demandait + # à virsh, qui ne connaît que les domaines d'ici. + meta={ + vm["name"]: ( + vm.get("distro"), + vm.get("version"), + vm.get("arch") or "amd64", + ) + for vm in joignables + }, ) return resultat # Sans suivi mais avec quelque chose à installer : en série, sortie à @@ -1397,7 +1456,11 @@ class ProxmoxMenuMixin: branche, alias.get(vm["name"], vm["name"]), vm.get("install_cmd") or commun, - False, + bool(spec.get("prod")), + desktop=spec.get("desktop") or "", + python_provider=spec.get("python_provider") or "", + app_store=spec.get("app_store") or "deb", + vm_tools=spec.get("vm_tools") or (), ) return resultat @@ -1564,6 +1627,10 @@ class ProxmoxMenuMixin: "install": install, "monitor": True, "python_provider": "", + # La voie par questions ne demande pas le fuseau — l'écran le + # fait. Sans ce défaut, elle laissait la VM en UTC, alors que la + # voie libvirt reprend le fuseau de l'hôte depuis toujours. + "timezone": self._qemu_host_timezone(), } joignables = self._pve_after_create( host, spec_finale, [nom], cle_locale diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index cf5c849..4d8c95a 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -271,6 +271,7 @@ class QemuDeployMixin: app_store="deb", vm_tools=(), pve=None, + meta=None, ): """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre le dashboard Textual. Quitter le dashboard n'arrête pas les installs. @@ -279,7 +280,10 @@ class QemuDeployMixin: `prod` : install /opt/erplibre + service SELinux confiné. `vm_tools` : outils cochés pour tout le parc, filtrés machine par machine (Android Studio n'existe qu'en x86_64, les extensions GNOME - n'ont pas de sens sous Cinnamon).""" + n'ont pas de sens sous Cinnamon). + `meta` : {nom: (distro, version, arch)} quand l'appelant SAIT ce que + sont ces VM. Sans elle, on le demande à virsh — juste ici, donc faux + pour une VM qui vit sur un Proxmox distant.""" from script.todo.qemu_install_monitor import ( launch_installs, run_monitor, @@ -316,7 +320,13 @@ class QemuDeployMixin: for name in names: ip = ip_map.get(name) if ip: - d, v, a = ( + # Ce que l'appelant sait d'abord. Sinon virsh — mais virsh + # ne connaît QUE les domaines d'ici : sur une VM posée sur un + # Proxmox distant il ne répond rien, ou pire, il répond pour + # un domaine local qui porte le même nom. L'architecture + # décide des outils installés : une VM ARM prise pour x86_64 + # recevait Android Studio, qui n'existe pas pour elle. + d, v, a = (meta or {}).get(name) or ( self._qemu_vm_meta(name, mod) if mod else (None, None, None) @@ -1266,7 +1276,6 @@ class QemuDeployMixin: if self._qemu_distro_profile(d) }, "ssh_key": self._qemu_default_ssh_key(), - "timezone": self._qemu_host_timezone(), "host_cpu": os.cpu_count() or 2, "free_ram": self._host_free_ram_mb(), # La place du système de fichiers qui portera les qcow2. Mesurée @@ -1279,47 +1288,7 @@ class QemuDeployMixin: "ram_presets": self._QEMU_RAM_PRESETS, "disk_presets": self._QEMU_DISK_PRESETS, "extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB, - "desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB, - "mise_arches": self.QEMU_MISE_ARCHES, - "app_stores": [(k, t(lbl)) for k, lbl in self.QEMU_APP_STORES], - "timezones": self._qemu_timezone_choices( - self._qemu_host_timezone() - ), - "snap_distros": self.QEMU_SNAP_DISTROS, - "vm_tools": self._qemu_vm_tool_choices(), - "vm_tool_disk": { - k: v["disk_gb"] for k, v in self._QEMU_VM_TOOLS.items() - }, - "vm_tool_arches": { - k: v["arches"] for k, v in self._QEMU_VM_TOOLS.items() - }, - "vm_tool_desktops": { - k: v["desktops"] for k, v in self._QEMU_VM_TOOLS.items() - }, - # « after » = l'outil vit DANS le dépôt ERPLibre (compilation - # mobile, AVD, script Forgejo) : sans installation, il n'existe - # pas, et la commande distante le saute en le nommant. - "vm_tool_phases": { - k: v.get("phase", "before") - for k, v in self._QEMU_VM_TOOLS.items() - }, - "vm_tool_needs_desktop": { - k: v["needs_desktop"] for k, v in self._QEMU_VM_TOOLS.items() - }, - "vm_tool_families": { - k: v["families"] for k, v in self._QEMU_VM_TOOLS.items() - }, - "distro_family": dict(self._QEMU_DISTRO_FAMILY), - "desktop_suffixes": self._qemu_desktop_suffixes(), - "desktops": [ - (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() - ], - "defaults": { - "install": True, - "add_ssh_config": True, - "monitor": True, - "prod": False, - }, + **self._qemu_guest_context(), # L'aperçu passe par le MÊME constructeur que le déploiement. "build_command": lambda vm, spec, dry: " ".join( shlex.quote(p) diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index d0008d3..55c23a0 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -70,6 +70,7 @@ from script.todo.deploy_form_lib import ( # noqa: F401 # Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté # tel quel : les appelants historiques importent encore ces noms ICI. +from script.todo.deploy_form_extras import ExtrasMixin from script.todo.deploy_form_plan import ( # noqa: F401 PlanMixin, preview_screen, @@ -127,33 +128,11 @@ def run_deploy_form(ctx, run_app: bool = True): total_disk = ctx.get("total_disk") or 0 base_vcpus = ctx.get("base_vcpus") or 2 extra_disk = ctx.get("extra_disk_gb") or 0 - desktop_disk = ctx.get("desktop_disk_gb") or 0 # [(clé, libellé)] — la liste vient de todo.py, source unique. desktops = list(ctx.get("desktops") or []) # {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les # saveurs — on ne le redéfinit pas ici. desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) - # Outils de développement d'une VM graphique : [(clé, libellé, indice)] et - # leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait - # que les afficher et rendre les cases cochées. - vm_tools = list(ctx.get("vm_tools") or []) - tool_disk = dict(ctx.get("vm_tool_disk") or {}) - # « after » = l'outil vit DANS le dépôt ERPLibre : sans installation, il - # n'a rien où s'installer, bureau ou pas. - tool_phases = dict(ctx.get("vm_tool_phases") or {}) - tool_arches = dict(ctx.get("vm_tool_arches") or {}) - tool_desktops = dict(ctx.get("vm_tool_desktops") or {}) - tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {}) - tool_families = dict(ctx.get("vm_tool_families") or {}) - distro_family = dict(ctx.get("distro_family") or {}) - # Architectures pour lesquelles mise publie un binaire. - mise_arches = set(ctx.get("mise_arches") or ()) - # [(clé, libellé)] des magasins d'applications, et les distributions qui - # livrent snapd — la question n'a de sens que pour celles-là, graphiques. - app_stores = list(ctx.get("app_stores") or []) - snap_distros = set(ctx.get("snap_distros") or ()) - # Fuseaux proposés, celui de l'hôte en tête (voir todo.py). - timezones = list(ctx.get("timezones") or []) defaults = ctx.get("defaults") or {} result = {"spec": None} @@ -169,7 +148,7 @@ def run_deploy_form(ctx, run_app: bool = True): f"RAM≥{e['ram']}Mo {e['disk']}" ) - class DeployForm(PlanMixin, App): + class DeployForm(ExtrasMixin, PlanMixin, App): # Le socle porte la mise en page et les modales ; ne reste ici que ce # qui nomme les widgets propres à QEMU/KVM. CSS = ( @@ -215,6 +194,7 @@ def run_deploy_form(ctx, run_app: bool = True): self._syncing = False # Jeu de VM actuellement monté dans le panneau droit. self._shown_ids = () + self.extras_init(ctx) # Génération du jeu de rangées monté. Les identifiants de widgets # portent un RANG, et le rang se décale quand on coche ou décoche # une entrée : un événement émis par un widget déjà détruit @@ -304,20 +284,7 @@ def run_deploy_form(ctx, run_app: bool = True): f"\n {t('A VM edited on the right (marked) keeps its own.')}", id="scopetarget", ) - # Serveur par défaut : c'est ce que sert une image cloud, - # et GNOME ajoute une à deux heures sur une architecture - # émulée. Le plan annonce le surcoût disque. - yield Static(t("VM type (default):"), classes="grouptitle") - with RadioSet(id="f_type"): - yield RadioButton( - t("Server (no graphical interface)"), - value=not defaults.get("desktop", ""), - ) - for key, label in desktops: - yield RadioButton( - f"{t('Graphical (server + desktop):')} {label}", - value=defaults.get("desktop", "") == key, - ) + yield from self.compose_vm_type() # La case commande TOUTE installation — ERPLibre, Odoo, mais # aussi l'hyperviseur Proxmox VE. Nommée « ERPLibre », elle # laissait croire qu'un système Proxmox s'installerait @@ -348,56 +315,8 @@ def run_deploy_form(ctx, run_app: bool = True): allow_blank=not profiles, id="f_profile_install", ) - yield Checkbox( - t("Production (/opt, confined)"), - value=defaults.get("prod", False), - id="f_prod", - ) - if app_stores: - yield Static( - t("Application store:"), - id="t_store", - classes="grouptitle", - ) - with RadioSet(id="f_store"): - for i, (_k, label) in enumerate(app_stores): - yield RadioButton(label, value=i == 0) - yield Static("", id="storewarn") - if vm_tools: - # Une case par outil, et non une liste déroulante : ils - # sont indépendants, et chacun se prend ou se laisse. - yield Static( - t("Development tools:"), - id="t_tools", - classes="grouptitle", - ) - for key, label, hint in vm_tools: - gb = tool_disk.get(key, 0) - yield Checkbox( - f"{label} +{gb} Go — {hint}", - value=key in (defaults.get("tools") or ()), - id=f"f_tool_{key}", - ) - yield Static("", id="toolwarn") - yield Static(t("Timezone"), classes="grouptitle") - # Une liste plutôt qu'une saisie : un nom IANA mal - # orthographié n'est pas refusé par cloud-init, il est - # IGNORÉ — la VM reste en UTC et on ne s'en aperçoit - # qu'aux horodatages. « libre… » garde la porte ouverte - # aux six cents autres fuseaux de la base. - yield Select( - [(z, z) for z in timezones] - + [(t("free value…"), FREE)], - value=(timezones[0] if timezones else SELECT_NULL), - allow_blank=False, - id="f_tz_sel", - ) - yield Input( - value=ctx.get("timezone") or "", - placeholder=t("Timezone for the VMs"), - id="f_tz", - classes="freeval", - ) + yield from self.compose_install_extras() + yield from self.compose_timezone() yield Static("SSH", classes="grouptitle") yield Input( value=ctx.get("ssh_key") or "", @@ -409,20 +328,7 @@ def run_deploy_form(ctx, run_app: bool = True): value=defaults.get("add_ssh_config", True), id="f_sshcfg", ) - # mise pose un CPython précompilé, pyenv le compile. - # Grisé quand AUCUNE des VM retenues n'est sur une - # architecture que mise sert. - yield Static( - t("Python interpreter:"), - id="t_python", - classes="grouptitle", - ) - with RadioSet(id="f_python"): - yield RadioButton( - t("mise (precompiled, faster)"), value=True - ) - yield RadioButton(t("pyenv (compiles from source)")) - yield Static("", id="miswarn") + yield from self.compose_python() # Hors de la section « Installation » : le suivi # regarde la VM ARRIVER, même quand rien ne s'installe. # Rangé dans cette section, il se serait grisé avec elle — @@ -596,17 +502,10 @@ def run_deploy_form(ctx, run_app: bool = True): ) 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 - # pèsent que sur les VM qui les reçoivent réellement. Android - # Studio n'existe qu'en x86_64, les extensions GNOME n'ont de - # sens que sous GNOME — une VM qui ne les aura pas ne doit pas - # se voir gonfler son disque. - row["disk_gb"] += sum( - tool_disk.get(k, 0) - for k in self._tools_for_vm(row["vm"], tools) - ) + # Le bureau et les outils ne pèsent que sur les VM qui + # les reçoivent RÉELLEMENT : Android Studio n'existe qu'en + # x86_64, les extensions GNOME n'ont de sens que sous GNOME. + row["disk_gb"] += self._extras_disk_gb(row["vm"], tools) # Le plan doit MONTRER qu'une VM a été personnalisée : sans marque, # deux lignes aux ressources différentes n'ont aucune explication à # l'écran, et la surcharge est oubliée à la relecture. Le drapeau @@ -617,163 +516,7 @@ def run_deploy_form(ctx, run_app: bool = True): row["custom"] = bool(self.overrides.get(key)) row["locked"] = key in self.locked self._render_plan() - self._render_mise() - self._render_store() - self._render_tools() - - def _vm_tools(self): - """Clés des outils cochés, dans l'ordre de la liste.""" - picked = [] - for key, _label, _hint in vm_tools: - try: - if self.query_one(f"#f_tool_{key}", Checkbox).value: - picked.append(key) - except Exception: - continue - return tuple(picked) - - def _tools_for_vm(self, vm, tools): - """Outils qu'une VM donnée recevra vraiment. - - Même filtre que todo.py côté déploiement : une VM ARM ne verra - jamais Android Studio, une VM Cinnamon jamais les extensions GNOME, - un serveur aucun des IDE — mais un serveur reçoit bien la - compilation mobile, qui n'a rien à afficher, et une distribution - sans apt ne la reçoit pas, son installateur n'existant que là.""" - out = [] - for key in tools: - arches = tool_arches.get(key) or () - desks = tool_desktops.get(key) or () - fams = tool_families.get(key) or () - if tool_needs_desktop.get(key) and not vm.get("desktop"): - continue - if arches and vm["arch"] not in arches: - continue - if desks and vm.get("desktop") not in desks: - continue - if fams and distro_family.get(vm["distro"], "") not in fams: - continue - out.append(key) - return out - - def _render_tools(self): - """Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et - NOMME ce qui sera écarté. - - Une case par outil, et non un blocage en bloc : sur un parc de - serveurs les IDE se grisent, la compilation mobile reste offerte. - Cocher Android Studio sur un parc ARM ne produit rien — le dire ici - évite de le découvrir dans le journal d'installation.""" - if not vm_tools: - return - installe, quelque_chose = self._install_state() - for key, _label, _hint in vm_tools: - usable = any(self._tools_for_vm(vm, (key,)) for vm in self.vms) - offert = ( - installe - if tool_phases.get(key) == "after" - else quelque_chose - ) - self.query_one(f"#f_tool_{key}", Checkbox).disabled = not ( - usable and offert - ) - picked = self._vm_tools() - skipped = sorted( - { - vm["name"] - for vm in self.vms - for k in picked - if k not in self._tools_for_vm(vm, picked) - } - ) - self.query_one("#toolwarn", Static).update( - f" ⚠ {t('Partly skipped (arch or desktop):')} " - f"{', '.join(skipped)}" - if skipped - else "" - ) - - def _render_mise(self): - """Grise le choix quand aucune VM retenue n'est servie par mise, - et nomme les architectures qui retomberont sur pyenv.""" - usable = self._mise_usable() - installe, _quelque_chose = self._install_state() - self.query_one("#f_python", RadioSet).disabled = not ( - usable and installe - ) - skipped = sorted( - { - vm["arch"] - for vm in self.vms - if vm["arch"] not in mise_arches - } - ) - msg = "" - if skipped: - msg = ( - f" ⚠ {t('mise has no binary for:')} " - f"{', '.join(skipped)} — {t('those VMs use pyenv')}" - ) - self.query_one("#miswarn", Static).update(msg) - - def _python_provider(self): - """« mise », « pyenv », ou rien — c'est-à-dire « automatique ». - - Rien, et surtout pas « pyenv », quand mise n'est servi par aucune - architecture retenue. « mise est indisponible » ne veut pas dire - « l'utilisateur exige pyenv » : la nuance décide de tout, puisqu'un - choix EXPLICITE écarte le Python de la distribution. Sur s390x, - renvoyer « pyenv » forçait la compilation de CPython — celle dont - gcc 15.2 ne revient pas.""" - if not self._mise_usable(): - return "" - index = self.query_one("#f_python", RadioSet).pressed_index - return "pyenv" if index == 1 else "mise" - - def _app_store(self): - """Magasin retenu. Sans VM concernée, la réponse est « deb » : - elle ne change rien, et laisser passer « snap » réactiverait snapd - pour rien.""" - if not app_stores or not self._app_store_needed(): - return "deb" - index = self.query_one("#f_store", RadioSet).pressed_index - if index is None or not (0 <= index < len(app_stores)): - return app_stores[0][0] - return app_stores[index][0] - - def _app_store_needed(self): - """Au moins une VM graphique sur une distribution qui livre snapd.""" - return any( - vm.get("desktop") and vm["distro"] in snap_distros - for vm in self.vms - ) - - def _render_store(self): - """Grise le choix quand aucune VM ne le concerne, et dit pourquoi.""" - if not app_stores: - return - needed = self._app_store_needed() - _installe, quelque_chose = self._install_state() - self.query_one("#f_store", RadioSet).disabled = not ( - needed and quelque_chose - ) - self.query_one("#storewarn", Static).update( - "" - if needed - else f" {t('No graphical VM on a snap-based distro.')}" - ) - - def _install_state(self): - """(une installation ?, quelque chose à installer ?). - - Deux réponses et non une : sans installation mais avec un bureau, - il se pose encore des paquets — le magasin d'applications et les - outils de la phase « avant » gardent un effet.""" - installe = self.query_one("#f_install", Checkbox).value - return installe, bool(installe or self._default_desktop()) - - def _mise_usable(self): - return any(vm["arch"] in mise_arches for vm in self.vms) + self.render_extras() def _profile_cmd(self): """Commande du profil choisi en haut : le défaut de chaque VM.""" @@ -811,14 +554,6 @@ def run_deploy_form(ctx, run_app: bool = True): value = self.query_one("#f_branch", Select).value return value if isinstance(value, str) else branches[0] - def _default_desktop(self): - """« » pour un serveur, sinon la clé de la saveur choisie.""" - """Type de VM par défaut. Chaque rangée peut s'en écarter.""" - index = self.query_one("#f_type", RadioSet).pressed_index - if index is None or index < 1 or index > len(desktops): - return "" - return desktops[index - 1][0] - # -- panneau droit : une rangée de widgets par VM ---------------- # def _type_options(self): return [(t("Server"), SERVER)] + [ @@ -1000,52 +735,6 @@ def run_deploy_form(ctx, run_app: bool = True): pass self._sync_free_inputs() - def _sync_install_deps(self) -> None: - """Grise ce que le choix d'installation rend SANS EFFET. - - Trois états et non deux, parce que la commande distante en a - trois : rien du tout, un bureau seul, ou une installation - complète. Sans installation MAIS avec un bureau, le magasin - d'applications et les outils de la phase « avant » servent encore - — les griser mentirait autant que de laisser actif ce qui ne fait - rien. La branche, le profil et l'interpréteur Python, eux, ne - servent qu'à l'installation. - - Le type de VM et le suivi ne sont jamais grisés : le premier est - l'autre moitié de la décision, le second regarde la VM arriver - même quand rien ne s'installe.""" - installe, quelque_chose = self._install_state() - for cible, actif in ( - ("#f_branch", installe), - ("#f_profile_install", installe), - ("#f_prod", quelque_chose), - ): - try: - self.query_one(cible).disabled = not actif - except Exception: - pass - # Le magasin, les outils et l'interpréteur Python ont leur PROPRE - # raison de se griser (architecture, bureau, distribution) : ils - # composent les deux dans « _render_* », qui a le dernier mot. - # Le titre suit ses champs : une section entière se lit inactive - # d'un coup d'œil, au lieu de se déduire de trois widgets ternes. - for cible, actif in ( - ("#t_store", quelque_chose), - ("#t_tools", quelque_chose), - ("#t_python", installe), - ): - try: - self.query_one(cible).set_class(not actif, "off") - except Exception: - pass - # Les rangées portent les mêmes choix, par VM. - for i in range(len(self.rows)): - for cible in (f"#v{i}_branch", f"#v{i}_prof"): - try: - self.query_one(cible).disabled = not installe - except Exception: - pass - def _render_plan(self): # Le JEU de VM a-t-il changé ? Si oui on remonte les widgets, sinon # on se contente des titres : remonter à chaque frappe volerait le @@ -1231,18 +920,7 @@ def run_deploy_form(ctx, run_app: bool = True): # ci-dessous sans rien recalculer, et les rangées restaient sur # l'ancienne version. Elles n'en gardent pas de copie — « » y # veut dire « celle du formulaire » — il suffit de redessiner. - if event.select.id == "f_tz_sel": - # « libre… » révèle la saisie ; un fuseau choisi la referme et - # y recopie le nom, seule valeur que lisent _form_values et la - # spec — un seul endroit porte la réponse. - free = event.value is FREE - field = self.query_one("#f_tz", Input) - field.display = free - field.disabled = not free - if free: - field.focus() - elif isinstance(event.value, str): - field.value = event.value + if self.extras_on_select(event): return if event.select.id in ("f_branch", "f_profile_install"): self._clear_overrides( @@ -1377,15 +1055,7 @@ def run_deploy_form(ctx, run_app: bool = True): else f"x{self.profile}" ), "ssh_key": os.path.expanduser(key) if key else "", - # Un champ vidé retombe sur le fuseau de l'hôte plutôt que sur - # rien : sans valeur, la VM démarrerait en UTC. - "timezone": self.query_one("#f_tz", Input).value.strip() - or ctx.get("timezone") - or "", - "desktop": self._default_desktop(), - "vm_tools": self._vm_tools(), - "python_provider": self._python_provider(), - "app_store": self._app_store(), + **self.extras_values(), "install": install, "add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value, # Une exécution par installation : le nombre de VM retenues diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index 4c474e2..d65b00b 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -828,6 +828,51 @@ class QemuInstallMixin: "arch": "pacman", } + def _qemu_guest_context(self): + """Ce que les DEUX écrans de déploiement doivent savoir du système + invité : type de VM, magasin d'applications, outils, fuseaux, Python. + + Une seule méthode et non deux blocs jumeaux dans les constructeurs de + contexte : c'est en n'en remplissant qu'un que l'écran Proxmox avait + perdu la moitié des réglages. Rien ici ne parle d'hyperviseur — c'est + exactement ce qui rend le bloc commun.""" + outils = self._QEMU_VM_TOOLS + return { + "desktops": [ + (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() + ], + "desktop_suffixes": self._qemu_desktop_suffixes(), + "desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB, + "app_stores": [(k, t(lbl)) for k, lbl in self.QEMU_APP_STORES], + "snap_distros": self.QEMU_SNAP_DISTROS, + "timezone": self._qemu_host_timezone(), + "timezones": self._qemu_timezone_choices( + self._qemu_host_timezone() + ), + "mise_arches": self.QEMU_MISE_ARCHES, + "vm_tools": self._qemu_vm_tool_choices(), + "vm_tool_disk": {k: v["disk_gb"] for k, v in outils.items()}, + "vm_tool_arches": {k: v["arches"] for k, v in outils.items()}, + "vm_tool_desktops": {k: v["desktops"] for k, v in outils.items()}, + # « after » = l'outil vit DANS le dépôt ERPLibre (compilation + # mobile, AVD, script Forgejo) : sans installation, il n'existe + # pas, et la commande distante le saute en le nommant. + "vm_tool_phases": { + k: v.get("phase", "before") for k, v in outils.items() + }, + "vm_tool_needs_desktop": { + k: v["needs_desktop"] for k, v in outils.items() + }, + "vm_tool_families": {k: v["families"] for k, v in outils.items()}, + "distro_family": dict(self._QEMU_DISTRO_FAMILY), + "defaults": { + "install": True, + "add_ssh_config": True, + "monitor": True, + "prod": False, + }, + } + @classmethod def _qemu_vm_tool_choices(cls): """[(clé, libellé, indice)] pour le formulaire et l'invite en ligne.""" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 985748e..447bfd6 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4523,6 +4523,10 @@ TRANSLATIONS = { "fr": "Chemin de la clé publique SSH", "en": "SSH public key path", }, + "timezone not set": { + "fr": "fuseau non posé", + "en": "timezone not set", + }, "Timezone": { "fr": "Fuseau horaire", "en": "Timezone", diff --git a/test/test_qemu_deploy_form_install.py b/test/test_qemu_deploy_form_install.py index 6f78063..9a55b1d 100644 --- a/test/test_qemu_deploy_form_install.py +++ b/test/test_qemu_deploy_form_install.py @@ -16,7 +16,6 @@ actif ce qui ne fait rien. """ import asyncio -import re import sys import unittest from pathlib import Path @@ -37,39 +36,63 @@ except Exception: # pragma: no cover - dépend de l'environnement class TestSaPlace(unittest.TestCase): - """Lu dans la source : l'ordre du panneau est une décision, pas un hasard.""" + """L'ordre du panneau est une décision, pas un hasard. + + Relevé sur l'ÉCRAN MONTÉ et non dans la source : depuis que les réglages + du système invité viennent d'un socle partagé, le fichier ne contient + plus qu'un « yield from » là où le test cherchait un identifiant. Il + passait au vert sur un écran qu'il ne lisait plus — puis au rouge sans + qu'aucun ordre ait bougé. Le DOM, lui, dit ce que l'utilisateur voit.""" @classmethod def setUpClass(cls): cls.src = FORM.read_text(encoding="utf-8") + cls.ordre = ordre_du_panneau(contexte()) if TEXTUAL else [] - def _rang(self, motif): - m = re.search(re.escape(motif), self.src) - self.assertIsNotNone(m, motif) - return m.start() + def _rang(self, ident): + self.assertIn(ident, self.ordre, ident) + return self.ordre.index(ident) + @unittest.skipUnless(TEXTUAL, "Textual absent") def test_the_install_section_sits_under_the_vm_type(self): - self.assertLess( - self._rang('with RadioSet(id="f_type")'), - self._rang('id="t_install"'), - ) + self.assertLess(self._rang("f_type"), self._rang("t_install")) + @unittest.skipUnless(TEXTUAL, "Textual absent") def test_and_before_the_sections_it_commands(self): # Magasin d'applications, outils : ils dépendent d'elle, donc ils # viennent après. - for apres in ('id="t_store"', 'id="t_tools"'): - self.assertLess(self._rang('id="t_install"'), self._rang(apres)) + for apres in ("t_store", "t_tools"): + self.assertLess(self._rang("t_install"), self._rang(apres)) def test_the_checkbox_no_longer_claims_to_be_about_erplibre(self): - self.assertIn('t("Install software in the VM")', self.src) self.assertNotIn('t("Install ERPLibre")', self.src) + @unittest.skipUnless(TEXTUAL, "Textual absent") def test_the_monitor_left_the_install_section(self): # Rangé dedans, il se serait grisé avec elle — et décocher ERPLibre # avait déjà fait disparaître le tableau de bord une fois. - self.assertLess( - self._rang('id="t_deploy"'), self._rang('id="f_monitor"') + self.assertLess(self._rang("t_deploy"), self._rang("f_monitor")) + + +def ordre_du_panneau(ctx, forme="qemu"): + """Les identifiants du panneau gauche, dans l'ordre où ils s'affichent.""" + from script.todo.proxmox_deploy_form import run_proxmox_form + from script.todo.qemu_deploy_form import run_deploy_form + + vu = [] + + async def scenario(): + app = ( + run_deploy_form(ctx, run_app=False) + if forme == "qemu" + else run_proxmox_form(ctx, run_app=False) ) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + vu.extend(w.id for w in app.query("#fields *") if w.id) + + asyncio.run(scenario()) + return vu def contexte(): diff --git a/test/test_todo_deploy_form_parity.py b/test/test_todo_deploy_form_parity.py new file mode 100644 index 0000000..01198fd --- /dev/null +++ b/test/test_todo_deploy_form_parity.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les deux écrans de déploiement offrent les MÊMES réglages du système invité. + +Type de VM, production, magasin d'applications, outils de développement, +fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas +la machine qui le porte. Ils valent donc mot pour mot sur libvirt et sur +Proxmox VE. + +C'est ce qui avait dérivé. L'écran QEMU/KVM les portait tous les six, l'écran +Proxmox trois : une VM créée là-bas naissait serveur nu, sans outils, en UTC — +et rien ne le disait. La duplication était le mécanisme de la dérive, pas son +symptôme : chaque correctif se posait sur un seul des deux écrans. + +Ce fichier teste donc la PARITÉ elle-même, et pas six comportements. Ajouter +un réglage à un seul écran le fait échouer, quel que soit ce réglage.""" + +import asyncio +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +try: + import textual # noqa: F401 + + TEXTUAL = True +except Exception: # pragma: no cover - dépend de l'environnement + TEXTUAL = False + +# Ce que porte le socle partagé. Les identifiants, parce qu'ils sont le +# contrat : c'est par eux que la spec est lue. +REGLAGES = ("f_type", "f_prod", "f_store", "f_tools", "f_tz", "f_python") + +CATALOGUE = [ + { + "distro": "ubuntu", + "version": "26.04", + "arch": "amd64", + "name": "erplibre-ubuntu-2604", + "ram": 2048, + "disk": "20G", + }, +] + + +def todo_muet(): + todo = TODO.__new__(TODO) + todo._qemu_list_domains = lambda: [] + todo._qemu_branch_list = lambda: ["develop", "master"] + return todo + + +def contexte_proxmox(todo): + """Le contexte de l'écran Proxmox, hôte simulé — aucune VM n'est créée.""" + return dict( + host={"target": "pve", "label": "pve"}, + node="pve", + arches=["amd64"], + native="amd64", + catalog={"amd64": list(CATALOGUE)}, + names=[], + vmids=[], + next_vmid=100, + storages=["local-lvm"], + storage="local-lvm", + storage_avail={"local-lvm": 500 << 30}, + bridges=["vmbr0"], + bridge="vmbr0", + ipconfig=lambda _p, _v: "ip=dhcp", + nameservers=["1.1.1.1"], + branches=["develop", "master"], + branch_current="develop", + install_profiles=[ + ("ERPLibre + Odoo 18", "make install_os && make install_odoo_18") + ], + distro_profiles={}, + ssh_key="~/.ssh/id_ed25519.pub", + cpu_presets=(1, 2, 4), + ram_presets=(2048, 4096), + disk_presets=(20, 40), + base_vcpus=2, + host_cpu=8, + free_ram=16000, + extra_disk_gb=5, + **todo._qemu_guest_context(), + ) + + +def releve(fabrique, ctx, gestes=None): + """Monte l'écran, choisit la première entrée, et relève ce qu'il porte.""" + vu = {} + + async def scenario(): + from textual.widgets import SelectionList + + app = fabrique(ctx, run_app=False) + async with app.run_test(size=(220, 70)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + liste.select(liste.get_option_at_index(0).value) + await pilote.pause() + await pilote.pause() + if gestes: + await gestes(app, pilote) + vu["ids"] = {w.id for w in app.query("#fields *") if w.id} + vu["spec"] = app._form_values() + vu["noms"] = [r["vm"]["name"] for r in app.rows] + vu["disques"] = [r["disk_gb"] for r in app.rows] + + asyncio.run(scenario()) + return vu + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLesDeuxEcrans(unittest.TestCase): + @classmethod + def setUpClass(cls): + from script.todo.proxmox_deploy_form import run_proxmox_form + from script.todo.qemu_deploy_form import run_deploy_form + + todo = todo_muet() + mod = todo._qemu_import_module() + cls.qemu = releve(run_deploy_form, todo._qemu_form_context(mod)) + cls.pve = releve(run_proxmox_form, contexte_proxmox(todo)) + + def _porte(self, vu, ident): + """Un réglage est là s'il a son widget — les outils sont une case par + outil, donc un préfixe.""" + if ident == "f_tools": + return any(i.startswith("f_tool_") for i in vu["ids"]) + return ident in vu["ids"] + + def test_both_screens_offer_the_same_guest_settings(self): + for ident in REGLAGES: + with self.subTest(reglage=ident): + self.assertTrue(self._porte(self.qemu, ident), "QEMU/KVM") + self.assertTrue(self._porte(self.pve, ident), "Proxmox") + + def test_both_specs_carry_the_same_guest_keys(self): + from script.todo.deploy_form_extras import ExtrasMixin + + attendues = set(ExtrasMixin.extras_values(self._faux())) + for nom, vu in (("QEMU/KVM", self.qemu), ("Proxmox", self.pve)): + with self.subTest(ecran=nom): + self.assertTrue(attendues <= set(vu["spec"]), nom) + + def _faux(self): + """Un porteur du socle sans écran : la liste des clés ne dépend pas + des widgets, et c'est justement ce qu'on veut vérifier.""" + from script.todo.deploy_form_extras import ExtrasMixin + + class Vide(ExtrasMixin): + vms = () + rows = () + + def query_one(self, _s, *_a): + raise LookupError + + vide = Vide() + vide.extras_init({}) + return vide + + def test_parallelism_follows_the_host_on_both_screens(self): + # L'écran Proxmox plafonnait à quatre choix et en proposait UN, quel + # que soit le nombre de cœurs de l'hôte. + for nom, vu in (("QEMU/KVM", self.qemu), ("Proxmox", self.pve)): + with self.subTest(ecran=nom): + self.assertIn("f_par_all", vu["ids"], nom) + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestCeQueLeBureauChange(unittest.TestCase): + """Choisir un bureau doit se voir sur les DEUX écrans, et de la même + façon : le nom prend un suffixe, le disque grossit.""" + + def _bureau(self): + async def gestes(app, pilote): + from textual.widgets import Checkbox + + list(app.query("#f_type RadioButton"))[1].value = True + await pilote.pause() + app.query_one("#f_tool_pycharm", Checkbox).value = True + await pilote.pause() + await pilote.pause() + + return gestes + + def test_the_name_gets_the_desktop_suffix(self): + from script.todo.proxmox_deploy_form import run_proxmox_form + + todo = todo_muet() + vu = releve( + run_proxmox_form, contexte_proxmox(todo), gestes=self._bureau() + ) + # Sans suffixe, une VM graphique et sa jumelle serveur portent le même + # nom : la seconde est signalée « existe déjà » et ignorée. + self.assertTrue(vu["noms"][0].endswith("-gnome"), vu["noms"]) + + def test_the_disk_grows_by_the_desktop_and_the_tools(self): + from script.todo.proxmox_deploy_form import run_proxmox_form + + todo = todo_muet() + ctx = contexte_proxmox(todo) + nu = releve(run_proxmox_form, ctx) + avec = releve(run_proxmox_form, ctx, gestes=self._bureau()) + attendu = ctx["desktop_disk_gb"] + ctx["vm_tool_disk"]["pycharm"] + self.assertEqual( + avec["disques"][0] - nu["disques"][0], + attendu, + "le plan doit annoncer le surcoût AVANT de déployer", + ) + + def test_the_created_disk_matches_what_the_plan_announced(self): + # C'est le pont entre l'écran et « qm resize » : le plan annonçait + # 36 Go et la commande en demandait 20. + todo = todo_muet() + taille = todo._pve_disk_with_margin( + {"disk": "20G", "arch": "amd64", "distro": "ubuntu"}, + { + "install": { + "branch": "develop", + "cmd": "make install_os && make install_odoo_18", + }, + "desktop": "gnome", + "vm_tools": ("pycharm",), + }, + ) + self.assertEqual(taille, "36G") + + +class TestCeQueSontCesVm(unittest.TestCase): + """L'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que + les domaines d'ICI. + + Elle décide des outils : une VM ARM prise pour x86_64 recevait Android + Studio, que Google ne publie pas pour elle — l'installation s'arrête, une + heure plus tard. Même famille que le « s » qui partait vers la machine + interne : on jugeait sur le nom.""" + + def _outils(self, meta): + vu = {} + todo = TODO.__new__(TODO) + todo._qemu_import_module = lambda: object() + todo._qemu_resolve_ips = lambda noms: {n: "10.0.0.1" for n in noms} + todo._qemu_vm_meta = lambda nom, mod: ("ubuntu", "26.04", "amd64") + todo._qemu_erplibre_remote_cmd = lambda *a, **kw: "" + + def capture(vms, branche, remote): + vu["archs"] = [v["arch"] for v in vms] + raise SystemExit + + import script.todo.qemu_install_monitor as mon + + vrai = mon.launch_installs + mon.launch_installs = capture + try: + todo._qemu_install_erplibre_monitored( + ["vm-arm"], "develop", {"vm-arm": "10.0.0.1"}, meta=meta + ) + except SystemExit: + pass + finally: + mon.launch_installs = vrai + return vu.get("archs") + + def test_the_caller_knows_better_than_virsh(self): + self.assertEqual( + self._outils({"vm-arm": ("ubuntu", "26.04", "arm64")}), ["arm64"] + ) + + def test_without_it_virsh_still_answers(self): + # La voie libvirt ne régresse pas : ses domaines sont bien ici. + self.assertEqual(self._outils(None), ["amd64"]) + + +class TestLeFuseauDUneVmProxmox(unittest.TestCase): + """« qm set » ne pose pas de fuseau : le cloud-init de Proxmox ne règle + que l'utilisateur, la clé et le réseau. Une VM créée là restait en UTC, et + on ne s'en aperçoit qu'aux horodatages.""" + + def _pose(self, spec): + vu = {} + todo = TODO.__new__(TODO) + todo._pve_ssh = lambda cible, cmd, **kw: ( + vu.setdefault("cmd", cmd), + (0, ""), + )[1] + todo._pve_set_timezone("vm", spec) + return vu.get("cmd", "") + + def test_the_timezone_reaches_the_vm(self): + self.assertIn( + "sudo timedatectl set-timezone America/Montreal", + self._pose({"timezone": "America/Montreal"}), + ) + + def test_a_free_value_is_quoted(self): + # La liste laisse la saisie libre : ce qui en sort part dans une + # commande distante, donc il est cité. + self.assertIn( + "'a b; rm -rf /'", self._pose({"timezone": "a b; rm -rf /"}) + ) + + def test_no_timezone_means_no_command(self): + self.assertEqual(self._pose({}), "") + + def test_the_prompt_path_still_gets_one(self): + # La voie par questions ne demande pas le fuseau ; sans défaut, elle + # laissait la VM en UTC alors que la voie libvirt reprend celui de + # l'hôte depuis toujours. + import re + from pathlib import Path + + src = Path("script/todo/proxmox_menu.py").read_text(encoding="utf-8") + bloc = src[src.index("def _pve_deploy_prompts") :] + bloc = bloc[: bloc.index("_pve_after_create")] + self.assertTrue( + re.search(r'"timezone":\s*self\._qemu_host_timezone\(\)', bloc), + "le spec des invites doit porter un fuseau", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2)