diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index ab49347..06518e7 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -963,17 +963,25 @@ def create_cmds(vmid: int, spec: dict) -> list: nom = spec["name"] stockage = spec["storage"] image = f"{spec.get('image_dir', IMAGE_DIR)}/{spec['image']}" + # L'écran de la VM. « serial0 » fait de la console série l'affichage : + # c'est ce que « qm terminal » attend, et c'est le défaut d'une machine + # de serveur. La 3D demande un vrai périphérique vidéo — « virtio-gl » + # pose un virtio-gpu que le VIRGL de l'hôte accélère. Le port série reste + # posé dans les deux cas, donc la console série ne se perd jamais ; seule + # la nature de l'écran change. + vga = "virtio-gl" if spec.get("gpu3d") else "serial0" cmds = [ # 1. La coquille : processeur, mémoire, réseau, contrôleur, agent. "qm create {id} --name {nom} --memory {mem} --cores {cpu}" " --cpu host --ostype l26 --scsihw virtio-scsi-single" " --net0 virtio,bridge={pont} --agent enabled=1" - " --serial0 socket --vga serial0".format( + " --serial0 socket --vga {vga}".format( id=vmid, nom=shlex.quote(nom), mem=int(spec["memory"]), cpu=int(spec["vcpus"]), pont=spec["bridge"], + vga=vga, ), # 2. Le disque, importé DEPUIS l'image cloud. « import-from » (PVE 8+) # remplace l'ancien « qm importdisk » en une seule étape et attache diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index bfe6175..dcf9d62 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -115,6 +115,8 @@ def build_spec(vms, existants, form): # La coupure d'amont demandée pour TOUT le déploiement, installation # comprise. Absente de la spec, le déploiement part en ligne. "offline": bool(form.get("offline")), + # L'écran accéléré, posé à la création de chaque VM. + "gpu3d": bool(form.get("gpu3d")), } @@ -158,6 +160,15 @@ def run_proxmox_form(ctx, run_app: bool = True): # un effet : l'hôte Proxmox est alors une VM de notre pont, et le menu # l'a établi avant d'ouvrir cet écran. cache_offert = bool(ctx.get("cache_offert")) + # La 3D ne s'offre que là où l'hôte distant peut la rendre : nœud de + # rendu et VIRGL. Le menu l'a sondé avant d'ouvrir cet écran. + gpu_offert = bool(ctx.get("gpu_offert")) + # Ce qui manque à l'hôte, nommé par la sonde : sans lui, la case + # disparaîtrait sans que rien ne dise pourquoi. + gpu_manque = str(ctx.get("gpu_manque") or "") + # Ce qui s'INSTALLE là-dedans : le nœud de rendu vient du matériel ou + # d'un GPU transmis, et « apt install noeud » enverrait dans le mur. + paquets_gpu = " ".join(p for p in gpu_manque.split() if p != "noeud") # {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 @@ -319,6 +330,40 @@ def run_proxmox_form(ctx, run_app: bool = True): placeholder="100", id="f_vmid", ) + # L'écran de la VM se choisit à la CRÉATION : « qm » pose + # « --vga virtio-gl » au lieu de la console série, et le + # changer ensuite demande d'éteindre la machine. La + # console série reste posée dans les deux cas. + if gpu_offert: + yield Checkbox( + t( + "3D acceleration (host GPU), even without a" + " screen" + ), + value=False, + id="f_gpu3d", + ) + elif gpu_manque: + # Une case qui disparaît sans un mot se lit comme une + # régression : on dit ce qui manque, et le paquet à + # poser sur l'HÔTE, pas dans la VM. + yield Static( + f" {t('No 3D: the host lacks')} {gpu_manque}", + id="t_gpu_manque", + ) + if paquets_gpu: + yield Static( + f" sudo apt install {paquets_gpu}", + id="t_gpu_geste", + ) + # Le bouton n'existe que si le menu a fourni de + # quoi poser : un bouton sans effet vaut moins + # qu'une commande à recopier. + if ctx.get("installer_gpu"): + yield Button( + t("Install on the host"), + id="f_gpu_poser", + ) yield Static(t("Access"), classes="grouptitle") yield Static(f" {t('SSH public key')}") yield Input( @@ -899,9 +944,50 @@ def run_proxmox_form(ctx, run_app: bool = True): self._set_override(index, champ, event.value) self._refresh_after() + def _poser_gpu(self) -> None: + """Pose ce qui manque sur l'hôte, terminal rendu, puis RELIT. + + « suspend() » rend le clavier à sudo, qui peut demander un mot de + passe, et laisse apt s'afficher : la moitié de la confiance tient + à voir le travail se faire. + + L'hôte est SONDÉ de nouveau au retour. Croire apt sur parole + offrirait une case que Proxmox refuserait ensuite — et il ne la + refuse qu'après avoir écrit le disque de la VM. + """ + poser = ctx.get("installer_gpu") + if not poser: + return + with self.suspend(): + poser(paquets_gpu) + sonder = ctx.get("sonder_gpu") + possible, reste = sonder() if sonder else (False, gpu_manque) + if not possible: + self.notify( + f"{t('Still missing on the host:')} {reste or gpu_manque}", + severity="warning", + ) + return + # Monter la case AVANT de retirer les lignes : le bloc n'est + # jamais vide, et l'œil suit ce qui remplace quoi. + self.query_one("#fields").mount( + Checkbox( + t("3D acceleration (host GPU), even without a screen"), + value=True, + id="f_gpu3d", + ), + after=self.query_one("#t_gpu_manque"), + ) + for sel in ("#t_gpu_manque", "#t_gpu_geste", "#f_gpu_poser"): + for widget in self.query(sel): + widget.remove() + self.notify(t("3D is now available: the box is here.")) + def on_button_pressed(self, event) -> None: ident = event.button.id or "" - if ident == "go": + if ident == "f_gpu_poser": + self._poser_gpu() + elif ident == "go": self.action_deploy() elif ident == "no": self.action_cancel() @@ -950,6 +1036,12 @@ def run_proxmox_form(ctx, run_app: bool = True): self.query("#f_offline") and self.query_one("#f_offline", Checkbox).value ) + # Même règle que pour la coupure : la case n'existe que là où + # elle a un effet, et son absence vaut « non ». + gpu3d = bool( + self.query("#f_gpu3d") + and self.query_one("#f_gpu3d", Checkbox).value + ) return { "host": ctx["host"], "storage": self._storage(), @@ -964,6 +1056,7 @@ def run_proxmox_form(ctx, run_app: bool = True): # Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans # ERPLibre se suit aussi (cloud-init, puis relevé système). "offline": offline, + "gpu3d": gpu3d, # Hors ligne, le suivi est d'office : seule sa voie confie la # levée de la coupure au guet, qui la tient jusqu'à la fin de # la dernière installation. diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index b4f2e7e..c675a9c 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -361,6 +361,55 @@ class ProxmoxMenuMixin: print(f" ⚠ {t('exit code')} {code}") return code, out + # Ce que « --vga virtio-gl » exige de l'hôte : le chemin qui le prouve, + # et le nom qui sert à le poser. Les noms sont ceux des paquets, car + # c'est ce qu'un opérateur tape ; « noeud » n'en est pas un, le nœud de + # rendu venant du matériel ou d'un GPU transmis. + _PVE_GPU_PIECES = ( + ("/dev/dri/renderD*", "noeud"), + ("/usr/lib/*/libvirglrenderer.so.*", "libvirglrenderer1"), + ("/usr/lib/*/libGL.so.1", "libgl1"), + ("/usr/lib/*/libEGL.so.1", "libegl1"), + ) + + def _pve_gpu_dispo(self): + """Ce qui MANQUE à l'hôte Proxmox pour donner de la 3D à ses invités. + + Rend (possible, manque) : « manque » nomme les pièces absentes et + vaut "" quand tout est là. Une case qui disparaît sans un mot ne se + devine pas — le formulaire s'en sert pour DIRE pourquoi la 3D n'est + pas offerte, et quoi installer. + + Une sonde qui n'aboutit pas rend (False, "") : on ne promet rien, et + on n'accuse rien non plus. Le jeton « FIN » distingue une sonde qui a + tout trouvé — donc muette — d'une sonde qui n'a pas tourné. + + Quatre pièces, et il les faut TOUTES. Un NŒUD DE RENDU + (« /dev/dri/renderD* ») : un hôte sans GPU, ou lui-même virtualisé + sans GPU transmis, n'en expose aucun. Puis les trois bibliothèques + que Proxmox charge pour « --vga virtio-gl » : VIRGL, GL et EGL. Il + les réclame nommément — « missing libraries for 'virtio-gl' + detected! Please install 'libgl1' and 'libegl1' » — et refuse de + démarrer la machine, une fois son disque écrit et sa configuration + posée. La case promettrait alors une accélération que rien ne + fournit, et le déploiement échouerait à la dernière étape. + + Un hôte peut en porter une sans l'autre : VIRGL et GL viennent avec + d'autres paquets, EGL non. + """ + sonde = "; ".join( + f"ls {chemin} >/dev/null 2>&1 || echo {jeton}" + for chemin, jeton in self._PVE_GPU_PIECES + ) + code, sortie = self._pve_show(f"{sonde}; echo FIN", quiet=True) + dites = [l.strip() for l in (sortie or "").splitlines() if l.strip()] + if code or "FIN" not in dites: + return False, "" + manque = [ + jeton for _c, jeton in self._PVE_GPU_PIECES if jeton in dites + ] + return (not manque), " ".join(manque) + def _pve_vms(self): """[{vmid, name, status, …}] des VM de l'hôte, ou [].""" from script.proxmox import proxmox_deploy as pve @@ -1169,6 +1218,38 @@ class ProxmoxMenuMixin: """Les commandes qui seraient lancées pour CETTE VM.""" return self._pve_vm_commands(mod, vm, spec) + # UNE sonde, deux réponses : ce que l'hôte peut faire, et ce qui lui + # manque pour le faire. Sondé deux fois, l'écran pourrait offrir la + # case et nommer en même temps ce qui l'empêche. + gpu_possible, gpu_manque = self._pve_gpu_dispo() + + def poser_gpu(paquets): + """Pose les paquets manquants SUR L'HÔTE. Rend True si apt a fini. + + Interactive à dessein : le terminal est rendu par l'écran avant + l'appel, « ssh -t » ouvre un vrai terminal distant, et sudo peut + donc demander son mot de passe. Rien n'est capturé — l'opérateur + voit apt travailler, ce qui est la moitié de la confiance. + + Le nœud de rendu n'est pas un paquet et ne s'installe pas : il + est écarté, et une liste qui n'en contient pas d'autre ne lance + rien plutôt que d'appeler « apt install » les mains vides. + """ + noms = [ + p + for p in str(paquets or "").split() + if p != "noeud" and re.fullmatch(r"[A-Za-z0-9.+_-]+", p) + ] + if not noms: + return False + remote = pve.wrap_privilege( + "apt-get update && apt-get install -y " + " ".join(noms), + host.get("sudo") or "", + ) + argv = pve.ssh_argv(host, remote, tty=True) + print("\n" + " ".join(shlex.quote(a) for a in argv) + "\n") + return subprocess.call(argv) == 0 + return { "host": dict(host, label=self._pve_label(host)), "node": self._pve_node_name(), @@ -1235,6 +1316,17 @@ class ProxmoxMenuMixin: # vit pas ici ne traverse rien qu'on sache couper, et la case y # promettrait un hors-ligne que personne ne tient. "cache_offert": bool(self._pve_cache_ca(host)), + # Lu ICI, terminal encore à nous : la sonde passe par ssh, et une + # invite de mot de passe pendant que l'écran affiche le casserait. + "gpu_offert": gpu_possible, + # Ce qui manque, nommé : une case qui disparaît sans un mot se + # lit comme une régression, et l'opérateur n'a rien à corriger. + "gpu_manque": gpu_manque, + # De quoi poser ce qui manque sans quitter l'écran, et de quoi + # RELIRE l'hôte ensuite : sans la seconde, l'écran croirait sur + # parole qu'apt a réussi. + "installer_gpu": poser_gpu, + "sonder_gpu": self._pve_gpu_dispo, } def _pve_capacity(self): @@ -1320,6 +1412,10 @@ class ProxmoxMenuMixin: # Le DNS de l'hôte : « --ipconfig0 » ne le porte pas, et une VM # en adresse fixe se retrouvait sans résolveur. "nameservers": spec.get("nameservers") or (), + # L'accélération 3D se décide à la CRÉATION : l'écran d'une VM + # Proxmox est un choix de « qm create », et le changer ensuite + # demande de l'éteindre. + "gpu3d": bool(spec.get("gpu3d")), } if spec.get("sshkey_path"): detail["sshkey_path"] = spec["sshkey_path"] @@ -1712,6 +1808,36 @@ class ProxmoxMenuMixin: print(f" ✓ {t('apt mirror pinned')} : {miroir}") return True + def _pve_set_gpu_groups(self, cible, utilisateur, mod=None): + """Met le compte de la VM dans les groupes du GPU, par ssh. + + Le nœud de rendu appartient à « root:render » en 0660 : un compte qui + n'y est pas retombe en rendu logiciel alors même que la négociation + VIRGL entre l'hôte et l'invité a réussi, et rien ne le signale — le + matériel virtuel est bien accéléré, seul l'accès manque. + + La voie libvirt pose ces groupes par le cloud-config ; « qm set » ne + sait écrire aucun fichier, d'où ce passage par ssh. Les groupes sont + CRÉÉS au besoin : « render » manque des images les plus anciennes, et + « usermod -aG » sur un groupe inconnu échoue. + + Les appartenances ne valent qu'à la PROCHAINE session : celle qui + tourne garde les siennes, ce que l'installation qui suit ne subit pas, + chacune de ses commandes ouvrant sa propre session. + """ + groupes = list(getattr(mod, "GPU_GROUPS", ()) or ("render", "video")) + gestes = [f"sudo groupadd -f {g}" for g in groupes] + gestes.append( + f"sudo usermod -aG {','.join(groupes)}" + f" {shlex.quote(utilisateur)}" + ) + code, _o = self._pve_ssh(cible, " && ".join(gestes), timeout=60) + if code: + print(f" ⚠ {t('GPU groups not set')} ({code})") + return False + print(f" ✓ {t('GPU groups set')} : {', '.join(groupes)}") + return True + def _pve_write_guide(self, cible, vm, spec, mod): """Pose le guide de connexion et l'identité git DANS la VM. @@ -1997,6 +2123,15 @@ class ProxmoxMenuMixin: # réclame un autre ne retrouve rien de ce qui est gardé. self._pve_set_apt_mirror(vm["alias"], vm, mod_qemu) self._pve_set_cache_ca(vm["alias"], vm, ca_cache) + # Après la création, qui a posé l'écran accéléré : l'accès au + # nœud de rendu est une affaire de COMPTE, et il se donne + # dans l'invité. + if spec.get("gpu3d"): + self._pve_set_gpu_groups( + vm["alias"], + spec.get("user") or "erplibre", + mod_qemu, + ) joignables.append(vm) install = spec.get("install") # Rendu à l'appelant pour son sommaire : lui seul sait ce qui a été diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6e19c46..0b7599b 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6663,6 +6663,10 @@ TRANSLATIONS = { "fr": "Pas encore de journal d'accès :", "en": "No access log yet:", }, + "GPU groups set": { + "fr": "groupes du GPU posés", + "en": "GPU groups set", + }, "apt mirror pinned": { "fr": "miroir apt fixé", "en": "apt mirror pinned", @@ -6703,10 +6707,30 @@ TRANSLATIONS = { "fr": "Pas de réponse ssh : l'invité reste tel qu'il est né.", "en": "No ssh answer: guest left as created.", }, + "No 3D: the host lacks": { + "fr": "Pas de 3D : il manque à l'hôte", + "en": "No 3D: the host lacks", + }, + "Install on the host": { + "fr": "Installer sur l'hôte", + "en": "Install on the host", + }, + "3D is now available: the box is here.": { + "fr": "La 3D est maintenant possible : la case est là.", + "en": "3D is now available: the box is here.", + }, + "Still missing on the host:": { + "fr": "Il manque encore à l'hôte :", + "en": "Still missing on the host:", + }, "apt mirror not pinned": { "fr": "miroir apt non fixé", "en": "apt mirror not pinned", }, + "GPU groups not set": { + "fr": "groupes du GPU non posés", + "en": "GPU groups not set", + }, " Entry 10 follows it live: a request that goes out to the": { "fr": " L'entrée 10 le suit en direct : une requête qui sort sur", "en": " Entry 10 follows it live: a request that goes out to the", @@ -6835,58 +6859,6 @@ TRANSLATIONS = { "fr": "La lever par :", "en": "Lift it with:", }, - "cache holds nothing for": { - "fr": "le cache ne détient rien pour", - "en": "cache holds nothing for", - }, - "an offline VM will fail": { - "fr": "une VM hors ligne échouera", - "en": "an offline VM will fail", - }, - "The cut hits every user of the cache:": { - "fr": "La coupure vaut pour tous les usagers du cache :", - "en": "The cut hits every user of the cache:", - }, - "a deployment run from another terminal": { - "fr": "un déploiement mené depuis un autre terminal", - "en": "a deployment run from another terminal", - }, - "goes offline too, without asking for it.": { - "fr": "sera hors ligne aussi, sans l'avoir demandé.", - "en": "goes offline too, without asking for it.", - }, - "Cache upstream cut for this deployment.": { - "fr": "Amont du cache coupé pour ce déploiement.", - "en": "Cache upstream cut for this deployment.", - }, - "Cache upstream restored.": { - "fr": "Amont du cache rebranché.", - "en": "Cache upstream restored.", - }, - "Upstream not cut: nothing deployed.": { - "fr": "Amont non coupé : rien n'est déployé.", - "en": "Upstream not cut: nothing deployed.", - }, - "The result would look offline without being so.": { - "fr": "Le résultat se lirait comme hors ligne sans l'être.", - "en": "The result would look offline without being so.", - }, - "dnsmasq is missing on the host: names cannot": { - "fr": "dnsmasq manque sur l'hôte : les noms ne peuvent", - "en": "dnsmasq is missing on the host: names cannot", - }, - "be cut. Install the dnsmasq package, then F5.": { - "fr": "pas être coupés. Installer le paquet dnsmasq, puis F5.", - "en": "be cut. Install the dnsmasq package, then F5.", - }, - "download cache authority installed": { - "fr": "autorité du cache de téléchargement posée", - "en": "download cache authority installed", - }, - "download cache authority not installed": { - "fr": "autorité du cache de téléchargement non posée", - "en": "download cache authority not installed", - }, "waiting for the guest agent install (apt lock)": { "fr": "attente de la pose de l'agent invité (verrou apt)", "en": "waiting for the guest agent install (apt lock)", @@ -7087,6 +7059,58 @@ TRANSLATIONS = { "fr": "Les VM n'ont plus de sortie directe non plus : seul l'hôte leur répond.", "en": "The VMs have no direct way out either: only the host answers them.", }, + "cache holds nothing for": { + "fr": "le cache ne détient rien pour", + "en": "cache holds nothing for", + }, + "an offline VM will fail": { + "fr": "une VM hors ligne échouera", + "en": "an offline VM will fail", + }, + "The cut hits every user of the cache:": { + "fr": "La coupure vaut pour tous les usagers du cache :", + "en": "The cut hits every user of the cache:", + }, + "a deployment run from another terminal": { + "fr": "un déploiement mené depuis un autre terminal", + "en": "a deployment run from another terminal", + }, + "goes offline too, without asking for it.": { + "fr": "sera hors ligne aussi, sans l'avoir demandé.", + "en": "goes offline too, without asking for it.", + }, + "Cache upstream cut for this deployment.": { + "fr": "Amont du cache coupé pour ce déploiement.", + "en": "Cache upstream cut for this deployment.", + }, + "Cache upstream restored.": { + "fr": "Amont du cache rebranché.", + "en": "Cache upstream restored.", + }, + "Upstream not cut: nothing deployed.": { + "fr": "Amont non coupé : rien n'est déployé.", + "en": "Upstream not cut: nothing deployed.", + }, + "The result would look offline without being so.": { + "fr": "Le résultat se lirait comme hors ligne sans l'être.", + "en": "The result would look offline without being so.", + }, + "dnsmasq is missing on the host: names cannot": { + "fr": "dnsmasq manque sur l'hôte : les noms ne peuvent", + "en": "dnsmasq is missing on the host: names cannot", + }, + "be cut. Install the dnsmasq package, then F5.": { + "fr": "pas être coupés. Installer le paquet dnsmasq, puis F5.", + "en": "be cut. Install the dnsmasq package, then F5.", + }, + "download cache authority installed": { + "fr": "autorité du cache de téléchargement posée", + "en": "download cache authority installed", + }, + "download cache authority not installed": { + "fr": "autorité du cache de téléchargement non posée", + "en": "download cache authority not installed", + }, "Timezone": { "fr": "Fuseau horaire", "en": "Timezone", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 86489cb..b4065bb 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -356,6 +356,27 @@ class TestLesCommandes(unittest.TestCase): self.assertIn("qm resize 100 scsi0 12G", joint) self.assertTrue(cmds[-1].endswith("qm start 100")) + def test_without_3d_the_screen_stays_the_serial_console(self): + """Le défaut d'une machine de serveur : « qm terminal » en dépend, et + poser un périphérique vidéo d'office changerait son matériel.""" + creation = pve.create_cmds(100, self._spec())[0] + self.assertIn("--vga serial0", creation) + self.assertIn("--serial0 socket", creation) + + def test_3d_asks_for_an_accelerated_screen(self): + """« virtio-gl » donne à l'invité un virtio-gpu que le VIRGL de + l'hôte accélère.""" + creation = pve.create_cmds(100, self._spec(gpu3d=True))[0] + self.assertIn("--vga virtio-gl", creation) + self.assertNotIn("--vga serial0", creation) + + def test_the_serial_port_survives_the_accelerated_screen(self): + """Le port série reste posé : sans lui, « qm terminal » n'a plus rien + où s'attacher, et une VM qui ne démarre pas devient muette.""" + self.assertIn( + "--serial0 socket", pve.create_cmds(100, self._spec(gpu3d=True))[0] + ) + def test_the_agent_and_the_serial_console_are_asked_for(self): """Sans agent, aucune adresse ; sans serial0, « qm terminal » est inutilisable et il ne reste que l'interface web.""" diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 6b3fef4..a519495 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -192,6 +192,9 @@ def contexte(): # L'hôte d'essai est une VM de notre pont : la case « Sans connexion # internet » est donc offerte, comme sur un Proxmox imbriqué. "cache_offert": True, + # L'hôte d'essai a un nœud de rendu et le VIRGL : la case 3D est + # donc offerte, comme sur un hôte Proxmox capable. + "gpu_offert": True, "ipconfig": lambda pont, vmid: f"ip=10.10.10.{50 + vmid % 200}/24", "build_command": lambda vm, spec: [f"qm create {vm['vmid']}"], "branches": ["develop", "master"], @@ -888,6 +891,281 @@ class TestLeHorsLigneSurProxmox(unittest.TestCase): self.assertTrue(vu["suivi_fige"]) +class TestLaTroisDSurProxmox(unittest.TestCase): + """L'accélération 3D, offerte sur Proxmox VE comme sur QEMU/KVM. + + Deux moitiés, et l'une sans l'autre ne donne rien. L'ÉCRAN se pose à la + création (« --vga virtio-gl ») ; l'ACCÈS au nœud de rendu est une affaire + de groupes DANS l'invité, que « qm set » ne sait pas écrire. Sans les + groupes, toute application GL retombe en rendu logiciel alors que la + négociation VIRGL a réussi — et rien ne le signale. + """ + + def _todo(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO.__new__(TODO) + + def test_les_groupes_sont_crees_avant_detre_donnes(self): + """« usermod -aG » sur un groupe inconnu échoue, et « render » manque + des images les plus anciennes.""" + todo = self._todo() + vu = {} + + def faux_ssh(cible, cmd, timeout=120): + vu["cible"], vu["cmd"] = cible, cmd + return 0, "" + + todo._pve_ssh = faux_ssh + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + self.assertTrue(todo._pve_set_gpu_groups("pve+vm-a", "erplibre")) + self.assertEqual(vu["cible"], "pve+vm-a") + self.assertLess( + vu["cmd"].index("groupadd -f render"), + vu["cmd"].index("usermod -aG"), + ) + self.assertIn("groupadd -f video", vu["cmd"]) + self.assertIn("usermod -aG render,video erplibre", vu["cmd"]) + + def test_un_echec_est_dit_et_non_tu(self): + todo = self._todo() + todo._pve_ssh = lambda *a, **k: (255, "") + import contextlib + import io + + sortie = io.StringIO() + with contextlib.redirect_stdout(sortie): + self.assertFalse(todo._pve_set_gpu_groups("pve+vm-a", "erplibre")) + self.assertIn("255", sortie.getvalue()) + + def test_la_sonde_exige_le_noeud_ET_les_trois_bibliotheques(self): + """Un hôte sans GPU n'expose aucun nœud de rendu ; sans VIRGL, GL ou + EGL, Proxmox refuse de démarrer la machine — « missing libraries for + 'virtio-gl' detected! Please install 'libgl1' and 'libegl1' » —, et + il le refuse APRÈS avoir écrit le disque. Un hôte peut porter GL sans + EGL : les exiger ensemble est le seul contrôle qui vaille.""" + todo = self._todo() + vus = [] + + def faux_show(remote, timeout=120, quiet=False): + vus.append(remote) + # Rien de manquant : la sonde ne dit que son jeton final. + return 0, "FIN\n" + + todo._pve_show = faux_show + # Tout est là : la sonde ne dit que « FIN ». + self.assertEqual(todo._pve_gpu_dispo(), (True, "")) + for attendu in ( + "/dev/dri/renderD*", + "libvirglrenderer.so.*", + "libGL.so.1", + "libEGL.so.1", + ): + self.assertIn(attendu, vus[0]) + + def test_la_sonde_nomme_ce_qui_manque(self): + """Le cas vécu : un hôte porte GL mais pas EGL, et Proxmox refuse de + démarrer la machine APRÈS avoir écrit son disque. La case ne doit pas + disparaître en silence — ce qui manque se nomme.""" + todo = self._todo() + todo._pve_show = lambda *a, **k: (0, "libegl1\nFIN\n") + self.assertEqual(todo._pve_gpu_dispo(), (False, "libegl1")) + todo._pve_show = lambda *a, **k: (0, "noeud\nlibgl1\nlibegl1\nFIN\n") + self.assertEqual( + todo._pve_gpu_dispo(), (False, "noeud libgl1 libegl1") + ) + + def test_une_sonde_qui_naboutit_pas_naccuse_rien(self): + """Sans le jeton final, une sortie vide voudrait dire « tout est + là » : c'est ssh qui a échoué, et on ne promet rien.""" + todo = self._todo() + todo._pve_show = lambda *a, **k: (0, "") + self.assertEqual(todo._pve_gpu_dispo(), (False, "")) + todo._pve_show = lambda *a, **k: (255, "FIN") + self.assertEqual(todo._pve_gpu_dispo(), (False, "")) + + def test_le_choix_atteint_la_commande_de_creation(self): + from pathlib import Path + + racine = Path(__file__).resolve().parent.parent + src = (racine / "script" / "todo" / "proxmox_menu.py").read_text( + encoding="utf-8" + ) + self.assertIn('"gpu3d": bool(spec.get("gpu3d"))', src) + # UNE sonde, deux clés : l'écran ne doit pas pouvoir offrir la case + # et nommer en même temps ce qui l'empêche. + self.assertIn("gpu_possible, gpu_manque = self._pve_gpu_dispo()", src) + self.assertIn('"gpu_offert": gpu_possible', src) + self.assertIn('"gpu_manque": gpu_manque', src) + + def test_la_case_atteint_la_spec(self): + form = { + "host": {"target": "pve1"}, + "storage": "local-lvm", + "bridge": "vmbr0", + "res_label": "x1", + "ssh_key": "", + "start": True, + "add_ssh_config": True, + "install": None, + "monitor": True, + "parallelism": 1, + "gpu3d": True, + } + self.assertTrue(build_spec([{"name": "a"}], [], form)["gpu3d"]) + form["gpu3d"] = False + self.assertFalse(build_spec([{"name": "a"}], [], form)["gpu3d"]) + + def _ecran(self, gpu_offert, cocher=False, manque=""): + """Monte l'écran avec — ou sans — la 3D possible sur l'hôte.""" + from textual.widgets import Checkbox, Static + + ctx = contexte() + ctx["gpu_offert"] = gpu_offert + ctx["gpu_manque"] = manque + vu = {} + + async def scenario(): + app = run_proxmox_form(ctx, run_app=False) + async with app.run_test(size=(200, 50)) as pilote: + await pilote.pause() + cases = app.query("#f_gpu3d") + vu["offerte"] = bool(cases) + if cocher and cases: + cases.first(Checkbox).value = True + await pilote.pause() + vu["valeur"] = app._form_values()["gpu3d"] + # Relevé DANS le contexte : « run_test » démonte les widgets + # en sortant, et le texte n'existerait plus après. + vu["explication"] = " ".join( + str(getattr(w, "_content", "") or w.render()) + for w in app.query("#t_gpu_manque") + ) + vu["geste"] = " ".join( + str(getattr(w, "_content", "") or w.render()) + for w in app.query("#t_gpu_geste") + ) + assert Static # l'import sert au typage de la requête + + asyncio.run(scenario()) + return vu + + def test_sans_gpu_sur_lhote_aucune_case(self): + """Une case qui promettrait une accélération que l'hôte ne peut pas + rendre vaut moins que pas de case.""" + vu = self._ecran(False) + self.assertFalse(vu["offerte"]) + self.assertFalse(vu["valeur"]) + + def test_ce_qui_manque_est_nomme_avec_son_paquet(self): + """Le cas vécu : la case avait disparu après correction de la sonde, + sans que rien ne dise pourquoi. Elle nomme désormais la pièce ET la + commande qui la pose — sur l'HÔTE, pas dans la VM.""" + vu = self._ecran(False, manque="libegl1") + self.assertFalse(vu["offerte"]) + self.assertIn("libegl1", vu["explication"]) + self.assertIn("apt install libegl1", vu["geste"]) + + def test_un_noeud_de_rendu_absent_ne_propose_aucun_paquet(self): + """Le nœud vient du matériel ou d'un GPU transmis : « apt install + noeud » enverrait l'opérateur dans le mur.""" + vu = self._ecran(False, manque="noeud") + self.assertIn("noeud", vu["explication"]) + self.assertEqual(vu["geste"], "") + + def test_rien_nest_dit_quand_la_sonde_na_pas_abouti(self): + """Sonde muette : on ne promet rien, et on n'accuse rien non plus.""" + vu = self._ecran(False) + self.assertEqual(vu["explication"], "") + self.assertEqual(vu["geste"], "") + + def test_avec_un_gpu_la_case_est_la_et_decochee(self): + vu = self._ecran(True) + self.assertTrue(vu["offerte"]) + self.assertFalse(vu["valeur"]) + + def test_cocher_porte_le_choix_jusqua_la_spec(self): + self.assertTrue(self._ecran(True, cocher=True)["valeur"]) + + def _bouton(self, manque="libegl1", apres=(True, ""), moyen=True): + """Monte l'écran, presse « Installer sur l'hôte », relève la suite. + + « suspend() » est remplacé : un écran monté sans terminal ne peut pas + le rendre, et ce n'est pas lui qu'on éprouve. Ce qu'on éprouve, c'est + que le paquet parte, que l'hôte soit RELU, et que l'écran suive. + """ + import contextlib + + from textual.widgets import Button + + ctx = contexte() + ctx["gpu_offert"] = False + ctx["gpu_manque"] = manque + vu = {"recu": None, "notes": []} + if moyen: + ctx["installer_gpu"] = ( + lambda paquets: vu.update(recu=paquets) or True + ) + ctx["sonder_gpu"] = lambda: apres + + async def scenario(): + app = run_proxmox_form(ctx, run_app=False) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + app.suspend = lambda: contextlib.nullcontext() + app.notify = lambda m, **k: vu["notes"].append(str(m)) + boutons = app.query("#f_gpu_poser") + vu["bouton"] = bool(boutons) + if boutons: + boutons.first(Button).press() + await pilote.pause() + await pilote.pause() + vu["case"] = bool(app.query("#f_gpu3d")) + vu["lignes"] = bool(app.query("#t_gpu_manque")) + + asyncio.run(scenario()) + return vu + + def test_le_bouton_pose_le_paquet_et_la_case_apparait(self): + """Ce que l'opérateur demande : ne pas quitter l'écran pour une + commande que l'écran vient de lui montrer.""" + vu = self._bouton() + self.assertTrue(vu["bouton"]) + self.assertEqual(vu["recu"], "libegl1") + self.assertTrue(vu["case"], "la case n'est pas apparue") + self.assertFalse(vu["lignes"], "le message est resté sous la case") + + def test_seuls_les_paquets_partent_a_linstallation(self): + """Le nœud de rendu ne s'installe pas : l'envoyer à apt ferait + échouer la pose des paquets qui, eux, existent.""" + self.assertEqual( + self._bouton(manque="noeud libegl1")["recu"], "libegl1" + ) + + def test_un_noeud_seul_ne_donne_aucun_bouton(self): + vu = self._bouton(manque="noeud") + self.assertFalse(vu["bouton"]) + self.assertFalse(vu["case"]) + + def test_sans_moyen_de_poser_aucun_bouton(self): + """Un bouton sans effet vaut moins qu'une commande à recopier.""" + self.assertFalse(self._bouton(moyen=False)["bouton"]) + + def test_lhote_est_relu_et_la_case_ne_vient_pas_sur_parole(self): + """Croire apt sur parole offrirait une case que Proxmox refuserait + ensuite — après avoir écrit le disque de la VM.""" + vu = self._bouton(apres=(False, "libgl1")) + self.assertFalse(vu["case"]) + self.assertTrue(vu["lignes"], "le message a disparu pour rien") + self.assertTrue(any("libgl1" in n for n in vu["notes"]), vu["notes"]) + + class TestLeMiroirAptDesVmProxmox(unittest.TestCase): """Une VM Proxmox tire du miroir que le cache a rempli.