diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 7b9ee38..30b81b7 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -222,7 +222,18 @@ class QemuManageMixin: f"{action} {shlex.quote(real)}" ) print(f"\n{t('Will execute:')} {cmd}") - self.execute.exec_command_live(cmd, source_erplibre=False) + if action != "start": + self.execute.exec_command_live(cmd, source_erplibre=False) + continue + # Un démarrage est le seul moment où la 3D écrite dans la + # définition se met à l'épreuve : QEMU refuse le domaine si EGL + # n'y démarre pas, et la VM reste inutilisable jusqu'à ce que + # quelqu'un défasse le réglage. + code, lignes = self.execute.exec_command_live( + cmd, source_erplibre=False, return_status_and_output=True + ) + if code: + self._qemu_start_failed(real, cmd, "\n".join(lignes or [])) @staticmethod def _qemu_dumpxml(name, inactive=True): @@ -333,6 +344,121 @@ class QemuManageMixin: return [] return [ln.strip() for ln in res.stdout.splitlines() if ln.strip()] + # Ce que la 3D d'une VM exige de l'hôte, en DEUX briques que le même + # réglage met en jeu et qu'on confond souvent : + # + # « --graphics egl-headless » ouvre le nœud de rendu et crée un contexte + # EGL par GBM — c'est Mesa qui répond, et « eglInitialize failed » vient + # de là. Sans lui, le domaine ne démarre pas du tout. + # + # « accel3d=on » confie ensuite les commandes 3D de l'invité à + # virglrenderer, qui les traduit vers l'OpenGL de l'hôte. Il n'entre en + # jeu qu'APRÈS qu'EGL a démarré : l'installer ne répare donc pas un EGL + # qui refuse. + # + # Les fichiers plutôt que les paquets : leurs noms changent d'une + # distribution à l'autre, leur emplacement beaucoup moins. + _GPU_3D_PIECES = ( + ("qemu ui-egl-headless", "qemu/ui-egl-headless.so"), + ("qemu virtio-gpu-gl", "qemu/hw-display-virtio-gpu-gl.so"), + ("virglrenderer", "libvirglrenderer.so"), + ("mesa libgbm", "libgbm.so"), + ("mesa libEGL", "libEGL.so"), + ) + + @staticmethod + def _qemu_lib_present(motif): + """Chemin du fichier s'il existe dans les emplacements habituels des + bibliothèques et des modules QEMU, sinon la chaîne vide.""" + racines = ( + "/usr/lib", + "/usr/lib64", + "/usr/lib/x86_64-linux-gnu", + "/usr/local/lib", + ) + for racine in racines: + trouves = sorted(glob.glob(os.path.join(racine, motif + "*"))) + if trouves: + return trouves[0] + return "" + + def _qemu_gpu_3d_report(self): + """Dit ce qui manque à l'hôte pour que la 3D d'une VM démarre. + + Ne modifie rien. Un nœud de rendu présent ne prouve RIEN : c'est + l'initialisation d'EGL dessus qui décide, et elle ne se teste qu'en + essayant — d'où les deux commandes suggérées à la fin. + """ + print(f"\n── {t('3D on this host')} ──") + node = self._qemu_host_gpu_node() + print(f" {t('Render node:')} {node or t('none')}") + for nom, motif in self._GPU_3D_PIECES: + chemin = self._qemu_lib_present(motif) + # Absent : dire ce qui a été CHERCHÉ. Une case vide laisse sans + # prise, le motif dit quoi installer et où regarder. + print(f" {'✅' if chemin else '❌'} {nom:<24} {chemin or motif}") + print(f"\n {t('Check EGL itself on that node:')}") + print(" eglinfo -B 2>&1 | head -20") + print(" lspci -nnk | grep -A3 -iE 'vga|3d|display'") + + def _qemu_egl_failed(self, sortie): + """La sortie accuse-t-elle un EGL inutilisable ? + + La signature vit dans deploy_qemu.py, qui doit tenir seul en tant que + script : la lire là plutôt que la recopier ici évite deux vérités. + """ + try: + return bool(self._qemu_import_module().egl_failed(sortie)) + except Exception: + return False + + def _qemu_start_failed(self, name, cmd, sortie): + """Un démarrage refusé : proposer ce qui le débloque, s'il y a lieu. + + Le seul échec qui se rattrape ici est celui de la 3D : le nœud de + rendu existe, mais EGL n'y démarre pas, et rien ne permettait de le + savoir avant l'essai. Retirer l'accélération rend une VM qui démarre. + """ + if not self._qemu_egl_failed(sortie): + return + print(f"\n ⚠ {t('EGL does not start on this host GPU.')}") + print( + f" {t('The VM cannot boot while 3D stays in its definition.')}" + ) + self._qemu_gpu_3d_report() + if not self._is_yes_default_yes( + input(t("Remove the 3D and start again? (Y/n): ")) + ): + return + from script.todo import qemu_hardware as hw + + etat = hw.hw_state( + self._qemu_dumpxml(name), self._qemu_autostart(name) + ) + if not etat.get("name"): + print(f" ⚠ {t('Unreadable VM definition.')}") + return + # Seule la 3D change. build_want retombe sur l'état pour les vCPU, la + # RAM, les écrans, le CPU et le réseau — mais PAS pour « autostart », + # qu'il lit en booléen sec : y passer None éteindrait le démarrage + # automatique d'une VM qu'on voulait seulement débloquer. + want = hw.build_want(etat, None, None, False, etat.get("autostart")) + plan = hw.hw_plan(etat, want) + for entree in plan: + if "cmd" not in entree: + continue + retrait = sudo_prefix() + " ".join( + shlex.quote(c) for c in entree["cmd"] + ) + print(f"\n{t('Will execute:')} {retrait}") + self.execute.exec_command_live( + retrait, + source_erplibre=False, + new_env={"PATH": system_path()}, + ) + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + def _qemu_adjust_hardware(self, names): """Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8cc8d57..1ebf7f8 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5113,6 +5113,33 @@ TRANSLATIONS = { "fr": "Destination sur l'hôte", "en": "Destination on the host", }, + "EGL does not start on this host GPU.": { + "fr": "EGL ne démarre pas sur le GPU de cet hôte.", + "en": "EGL does not start on this host GPU.", + }, + "The VM cannot boot while 3D stays in its definition.": { + "fr": ( + "La VM ne démarrera pas tant que la 3D reste dans sa" + " définition." + ), + "en": "The VM cannot boot while 3D stays in its definition.", + }, + "Remove the 3D and start again? (Y/n): ": { + "fr": "Retirer la 3D et redémarrer ? (O/n) : ", + "en": "Remove the 3D and start again? (Y/n, default: yes): ", + }, + "3D on this host": { + "fr": "La 3D sur cet hôte", + "en": "3D on this host", + }, + "Render node:": { + "fr": "Nœud de rendu :", + "en": "Render node:", + }, + "Check EGL itself on that node:": { + "fr": "Vérifier EGL lui-même sur ce nœud :", + "en": "Check EGL itself on that node:", + }, "Full output:": { "fr": "Sortie complète :", "en": "Full output:", diff --git a/test/test_qemu_start_egl.py b/test/test_qemu_start_egl.py new file mode 100644 index 0000000..c72c564 --- /dev/null +++ b/test/test_qemu_start_egl.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Une 3D écrite dans la définition d'une VM se met à l'épreuve au démarrage. + +Le nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le +domaine, et la VM reste inutilisable tant que quelqu'un ne défait pas le +réglage. Rien ne permet de le savoir avant l'essai — la création a déjà son +repli, le réglage d'une VM existante avait besoin du sien. + +Ce que ces tests gardent : + +- l'échec EGL est reconnu au démarrage et le retrait est proposé ; +- le retrait ne touche QUE la 3D : passer « autostart » à None l'éteindrait, + et « heads » ferait naître une étape sans rapport ; +- un échec de démarrage qui n'est pas celui d'EGL n'est pas rattrapé. +""" + +import io +import sys +import unittest +from contextlib import redirect_stdout +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo import qemu_hardware as hw # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + +SORTIE_EGL = ( + "error: erreur interne : le processus s'est arrêté pendant la connexion" + " au moniteur: qemu-system-x86_64: egl: eglInitialize failed:" + " EGL_NOT_INITIALIZED\n" + "qemu-system-x86_64: egl: render node init failed" +) +SORTIE_AUTRE = ( + "error: Failed to start domain: internal error:" + " qemu unexpectedly closed" +) + +ETAT = { + "name": "vm-a", + "vcpus": 16, + "mem_mib": 32768, + "video": "virtio", + "accel3d": True, + "egl": True, + "render": "/dev/dri/renderD128", + "screen": True, + "heads": 1, + "cpu": "", + "net": "default", + "autostart": True, +} + + +class LaSignature(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_it_recognises_the_egl_failure(self): + self.assertTrue(self.todo._qemu_egl_failed(SORTIE_EGL)) + + def test_it_ignores_another_failure(self): + self.assertFalse(self.todo._qemu_egl_failed(SORTIE_AUTRE)) + + def test_an_unloadable_module_does_not_raise(self): + """Le menu ne doit pas tomber parce que la source de vérité manque.""" + self.todo._qemu_import_module = mock.Mock(side_effect=OSError("x")) + self.assertFalse(self.todo._qemu_egl_failed(SORTIE_EGL)) + + +class LeRetrait(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.lances = [] + self.todo.execute = mock.MagicMock() + self.todo.execute.exec_command_live.side_effect = ( + lambda cmd, **k: self.lances.append(cmd) or 0 + ) + self.todo._qemu_dumpxml = lambda n, **k: "" + self.todo._qemu_autostart = lambda n: True + self.todo._qemu_import_module = lambda: _FauxModule() + + def _lancer(self, sortie, reponse="o"): + with mock.patch.object(hw, "hw_state", return_value=dict(ETAT)): + with mock.patch("builtins.input", return_value=reponse): + with mock.patch("builtins.print"): + self.todo._qemu_start_failed( + "vm-a", "virsh start vm-a", sortie + ) + return self.lances + + def test_another_failure_is_left_alone(self): + self.assertEqual([], self._lancer(SORTIE_AUTRE)) + + def test_a_refusal_changes_nothing(self): + self.assertEqual([], self._lancer(SORTIE_EGL, reponse="n")) + + def test_it_removes_the_3d_then_starts_again(self): + lances = self._lancer(SORTIE_EGL) + joint = " ".join(lances) + self.assertIn("accel3d=off", joint) + self.assertIn("--remove-device --graphics type=egl-headless", joint) + # Le démarrage revient EN DERNIER : le retrait doit être écrit avant. + self.assertEqual("virsh start vm-a", lances[-1]) + + def test_it_touches_only_the_3d(self): + """Le test précédent passerait même si le retrait éteignait aussi le + démarrage automatique : c'est ce que fait « autostart » à None, et + seul un contrôle sur les commandes RÉELLEMENT lancées l'attrape.""" + lances = self._lancer(SORTIE_EGL) + # TOUT ce qui précède le démarrage, sans filtrer sur l'outil : la + # commande d'autostart passe par virsh et non par virt-xml, donc un + # filtre sur « virt-xml » laisserait justement passer la fautive. + retraits = lances[:-1] + self.assertEqual(2, len(retraits), retraits) + joint = " ".join(retraits) + self.assertNotIn("--disable", joint) + self.assertNotIn("autostart", joint) + self.assertNotIn("model.heads", joint) + + +class LeRapport3D(unittest.TestCase): + """Ce que l'hôte doit fournir pour qu'une 3D de VM démarre. + + Deux briques distinctes : « egl-headless » ouvre le nœud et crée le + contexte EGL par GBM — c'est Mesa qui répond — et virglrenderer ne sert + qu'ENSUITE. Les chercher par fichier plutôt que par paquet : les noms de + paquets changent d'une distribution à l'autre, pas les emplacements. + """ + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.todo._qemu_host_gpu_node = lambda: "/dev/dri/renderD128" + + def _rendu(self, presents): + self.todo._qemu_lib_present = lambda motif: ( + f"/usr/lib/{motif}.0" if motif in presents else "" + ) + buf = io.StringIO() + with redirect_stdout(buf): + self.todo._qemu_gpu_3d_report() + return buf.getvalue() + + def test_it_names_the_two_bricks(self): + """virglrenderer sans EGL ne sert à rien : les deux se lisent.""" + rendu = self._rendu(set()) + self.assertIn("virglrenderer", rendu) + self.assertIn("libEGL.so", rendu) + self.assertIn("ui-egl-headless.so", rendu) + + def test_a_missing_piece_is_marked(self): + rendu = self._rendu({"libgbm.so"}) + lignes = [l for l in rendu.splitlines() if "libgbm" in l] + self.assertTrue(lignes and lignes[0].strip().startswith("✅")) + manquant = [l for l in rendu.splitlines() if "virglrenderer" in l] + self.assertTrue(manquant[0].strip().startswith("❌")) + + def test_it_says_the_node_alone_proves_nothing(self): + """Le nœud existe dans le cas qui échoue : le rapport doit donc + proposer d'éprouver EGL, pas se contenter de le lister.""" + rendu = self._rendu(set()) + self.assertIn("/dev/dri/renderD128", rendu) + self.assertIn("eglinfo", rendu) + + def test_it_changes_nothing(self): + """Un rapport qui installerait quoi que ce soit ne serait plus un + rapport.""" + self.todo.execute = mock.MagicMock() + self._rendu(set()) + self.todo.execute.exec_command_live.assert_not_called() + + +class _FauxModule: + """deploy_qemu réduit à ce que le menu lui demande.""" + + @staticmethod + def egl_failed(sortie): + return "eglInitialize failed" in (sortie or "") + + +if __name__ == "__main__": + unittest.main()