184 lines
6.8 KiB
Python
184 lines
6.8 KiB
Python
|
|
#!/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: "<domain/>"
|
||
|
|
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()
|