erplibre/test/test_qemu_start_egl.py

620 lines
23 KiB
Python
Raw Normal View History

#!/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 os
import re
import shlex
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest import mock
sys.argv = ["todo.py"]
from script.todo import qemu_hardware as hw # noqa: E402
from script.todo import qemu_manage as qm # 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 LeDiagnostic(unittest.TestCase):
"""Un relevé destiné à quelqu'un qui n'a pas accès à la machine.
Il doit donc être complet, ne RIEN modifier, et dire à celui qui l'envoie
ce qu'il contient — un rapport de machine porte son nom, ses chemins de
compte et ses adresses.
"""
def setUp(self):
self.todo = TODO.__new__(TODO)
self.todo._qemu_host_gpu_node = lambda: "/dev/dri/renderD128"
self.todo._qemu_lib_present = lambda motif: ""
# La proposition d'outils a ses propres tests : ici elle ne ferait
# qu'attendre une réponse que personne ne donne.
self.todo._qemu_diag_offer_tools = lambda: False
self.lances = []
def _lancer(self, tmp):
def faux_run(cmd, **k):
self.lances.append(cmd)
class R:
stdout = f"sortie de {cmd}\n"
stderr = ""
return R()
with mock.patch.object(
__import__("script.todo.qemu_manage", fromlist=["x"]).subprocess,
"run",
side_effect=faux_run,
), mock.patch.object(
os.path, "expanduser", return_value=tmp
), mock.patch(
"builtins.print"
):
self.todo._qemu_diagnostics()
return sorted(os.listdir(tmp))
def test_it_writes_one_readable_report(self):
with tempfile.TemporaryDirectory() as tmp:
fichiers = self._lancer(tmp)
self.assertEqual(1, len(fichiers), fichiers)
self.assertTrue(fichiers[0].startswith("qemu-diagnostic-"))
contenu = Path(tmp, fichiers[0]).read_text(encoding="utf-8")
# Les quatre familles qui décident d'un problème QEMU : la machine,
# l'hyperviseur, le GPU, et l'interpréteur qui porte virt-xml.
for attendu in ("uname", "virsh", "dri", "virt-xml", "3D"):
self.assertIn(attendu, contenu)
# Programmes qui ne peuvent que LIRE. « command -v » cherche un outil
# sans le lancer : la présence du mot « virt-install » dans une sonde ne
# dit donc rien, seul le programme en tête de commande compte.
LECTURE_SEULE = {
"uname",
"cat",
"systemd-detect-virt",
"ls",
"lspci",
"eglinfo",
"command",
"head",
"df",
"id",
"true",
"grep",
"ps",
"sort",
"echo",
}
VIRSH_LECTURE = {"version", "list", "net-list"}
def test_every_probe_is_read_only(self):
"""Un rapport qui modifie l'hôte n'est plus un rapport."""
with tempfile.TemporaryDirectory() as tmp:
self._lancer(tmp)
operateurs = {";", "|", "||", "&&"}
for cmd in self.lances:
# shlex plutôt qu'un découpage sur « | » : le motif de grep en
# contient un, et le couper au milieu ferait juger « 3d » comme
# s'il était un programme.
morceau = []
for jeton in shlex.split(cmd) + [";"]:
if jeton not in operateurs:
morceau.append(jeton)
continue
if morceau:
self._juger(morceau, cmd)
morceau = []
def _juger(self, jetons, cmd):
"""Un morceau de commande ne doit que lire.
« sudo » n'est qu'un préfixe : l'admettre en tête ouvrirait la porte
à tout. On le retire et on juge le programme qu'il porte.
"""
while jetons and (
os.path.basename(jetons[0]) == "sudo" or jetons[0].startswith("-")
):
jetons = jetons[1:]
if not jetons:
return
tete = os.path.basename(jetons[0])
if tete == "python3":
return
if tete == "virsh":
sous = [
j
for j in jetons[1:]
if not j.startswith("-") and "://" not in j
]
self.assertIn(sous[0], self.VIRSH_LECTURE, cmd)
return
self.assertIn(tete, self.LECTURE_SEULE, cmd)
def test_a_hanging_probe_does_not_hold_the_report(self):
"""Une commande qui pend ne doit pas retenir le rapport : son absence
de réponse est elle-même une information."""
import subprocess as sp
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(
__import__(
"script.todo.qemu_manage", fromlist=["x"]
).subprocess,
"run",
side_effect=sp.TimeoutExpired("x", 30),
), mock.patch.object(
os.path, "expanduser", return_value=tmp
), mock.patch(
"builtins.print"
):
self.todo._qemu_diagnostics()
contenu = Path(tmp, os.listdir(tmp)[0]).read_text(encoding="utf-8")
self.assertIn("(timeout)", contenu)
class LesOutilsDuRapport(unittest.TestCase):
"""eglinfo est le seul qui ÉPROUVE EGL : sans lui le rapport dit ce qui
est installé, jamais si ça démarre — et c'est la question posée."""
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
)
def _proposer(self, presents, reponse="o"):
"""`presents` : les outils du rapport déjà installés.
UN SEUL patch de shutil.which : « qm.shutil » et le shutil de
qemu_privilege sont le MÊME objet, et deux patchs concurrents dessus
se recouvrent — l'hôte est donc simulé en une fois, gestionnaire de
paquets compris.
"""
connus = set(presents) | {"pacman"}
def faux_which(binaire):
return "/usr/bin/x" if binaire in connus else None
with mock.patch.object(
qm.shutil, "which", side_effect=faux_which
), mock.patch("builtins.input", return_value=reponse), mock.patch(
"builtins.print"
):
return self.todo._qemu_diag_offer_tools()
def test_nothing_is_offered_when_all_are_there(self):
self.assertFalse(self._proposer({"eglinfo", "lspci"}))
self.assertEqual([], self.lances)
def test_the_package_follows_the_distribution(self):
"""« mesa-utils » n'existe pas sur Fedora, qui livre « mesa-demos »."""
self._proposer(set())
joint = " ".join(self.lances)
self.assertIn("pacman", joint)
self.assertIn("mesa-utils", joint)
self.assertIn("pciutils", joint)
def test_the_command_is_announced_before_the_question(self):
"""« Les installer ? » ne dit ni ce qui sera lancé ni avec quels
droits : la commande entière, sudo compris, précède la question."""
connus = {"pacman"}
vus = []
with mock.patch.object(
qm.shutil,
"which",
side_effect=lambda b: "/usr/bin/x" if b in connus else None,
), mock.patch(
"builtins.input",
side_effect=lambda p="": (vus.append(("?", p)), "n")[1],
), mock.patch(
"builtins.print",
side_effect=lambda *a, **k: vus.append(
("!", " ".join(str(x) for x in a))
),
):
self.todo._qemu_diag_offer_tools()
avant = [txt for genre, txt in vus[: [g for g, _ in vus].index("?")]]
joint = "\n".join(avant)
self.assertIn("sudo pacman", joint)
self.assertIn("mesa-utils", joint)
def test_a_refusal_installs_nothing(self):
self.assertFalse(self._proposer(set(), reponse="n"))
self.assertEqual([], self.lances)
def test_without_a_terminal_it_installs_nothing(self):
"""Un lancement scripté n'a personne pour répondre : l'invite y lève
EOFError, et le rapport — déjà écrit — ne doit pas tomber avec."""
connus = {"pacman"}
with mock.patch.object(
qm.shutil,
"which",
side_effect=lambda b: "/usr/bin/x" if b in connus else None,
), mock.patch("builtins.input", side_effect=EOFError), mock.patch(
"builtins.print"
):
self.assertFalse(self.todo._qemu_diag_offer_tools())
self.assertEqual([], self.lances)
def test_only_the_missing_ones_are_proposed(self):
self._proposer({"lspci"})
self.assertEqual(1, len(self.lances), self.lances)
self.assertIn("mesa-utils", self.lances[0])
class LeConseilAcl(unittest.TestCase):
"""La liste de périphériques n'est proposée que si elle explique le
blocage : hôte à carte NVIDIA propriétaire, dont la liste ne nomme pas
ses nœuds. libvirt y ajoute le nœud de rendu quand le domaine le
déclare, jamais ceux de la carte, et la pile propriétaire ouvre les deux.
"""
def setUp(self):
self.todo = TODO.__new__(TODO)
self.todo._qemu_host_gpu_node = lambda: "/dev/dri/renderD128"
def _conseil(self, nodes, acl):
vus = []
with mock.patch.object(
self.todo, "_qemu_nvidia_nodes", return_value=nodes
), mock.patch.object(
self.todo, "_qemu_acl_active", return_value=acl
), mock.patch(
"builtins.print",
side_effect=lambda *a, **k: vus.append(
" ".join(str(x) for x in a)
),
):
parle = self.todo._qemu_nvidia_acl_advice()
return parle, "\n".join(vus)
def test_a_host_without_nvidia_hears_nothing(self):
parle, rendu = self._conseil([], "")
self.assertFalse(parle)
self.assertEqual("", rendu)
def test_a_list_that_already_names_them_is_left_alone(self):
"""Répéter un conseil déjà suivi le rend invisible quand il compte."""
nodes = ["/dev/nvidia0", "/dev/nvidiactl"]
parle, _ = self._conseil(
nodes, 'cgroup_device_acl = ["/dev/nvidia0", "/dev/nvidiactl"]'
)
self.assertFalse(parle)
def test_a_missing_node_brings_the_whole_list_back(self):
"""La clé REMPLACE le défaut au lieu de s'y ajouter : proposer
seulement les nœuds manquants ferait perdre /dev/kvm."""
parle, rendu = self._conseil(
["/dev/nvidia0", "/dev/nvidiactl"],
'cgroup_device_acl = ["/dev/nvidia0"]',
)
self.assertTrue(parle)
for attendu in ("/dev/kvm", "/dev/null", "/dev/dri/renderD128"):
self.assertIn(attendu, rendu)
self.assertIn("AJOUTER", rendu)
def test_an_unreadable_file_says_so_instead_of_concluding(self):
parle, rendu = self._conseil(["/dev/nvidia0"], None)
self.assertTrue(parle)
self.assertIn("grep -n cgroup_device_acl", rendu)
def test_unreadable_and_absent_are_not_the_same_answer(self):
"""Ne pas pouvoir lire n'est pas savoir qu'il n'y a rien : le premier
cas doit rendre None, pour que le conseil le DISE au lieu de conclure
qu'aucune liste n'existe."""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
vide = Path(tmp, "vide.conf")
vide.write_text("# tout en commentaire\n", encoding="utf-8")
with mock.patch.object(type(self.todo), "_QEMU_CONF", str(vide)):
self.assertEqual("", self.todo._qemu_acl_active())
with mock.patch.object(
type(self.todo), "_QEMU_CONF", str(Path(tmp, "absent.conf"))
):
self.assertIsNone(self.todo._qemu_acl_active())
regle = Path(tmp, "regle.conf")
regle.write_text(
'#cgroup_device_acl = ["/dev/vieux"]\n'
'cgroup_device_acl = ["/dev/nvidia0"]\n',
encoding="utf-8",
)
with mock.patch.object(type(self.todo), "_QEMU_CONF", str(regle)):
actif = self.todo._qemu_acl_active()
# La ligne en COMMENTAIRE ne compte pas : la lire ferait croire à un
# réglage que libvirt ignore.
self.assertIn("/dev/nvidia0", actif)
self.assertNotIn("/dev/vieux", actif)
def test_it_names_the_restart_and_the_vm_recreation(self):
"""Un redémarrage de l'invité ne suffit pas : la liste s'applique au
lancement de QEMU, donc il faut éteindre et rallumer."""
_, rendu = self._conseil(["/dev/nvidia0"], "")
self.assertIn("systemctl restart", rendu)
self.assertIn("is-active", rendu)
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()
class LaSondeVideo(unittest.TestCase):
"""La 3D d'une VM se lit sur la ligne de commande de son QEMU.
La définition dit ce qui est DEMANDÉ ; la ligne de commande dit ce qui
a été REÇU. Entre les deux, libvirt peut retirer l'accélération sans le
signaler, et « egl-headless » s'affiche dans les deux cas — chercher ce
seul mot fait conclure à tort que la 3D est en place. Le suffixe
« -gl » du device est la pièce qui tranche.
"""
ARGV = (
"/usr/bin/qemu-system-x86_64",
"-name",
"guest=une-vm,debug-threads=on",
"-device",
"%s,id=video0,max_outputs=1,bus=pcie.0",
"-display",
"egl-headless,rendernode=/dev/dri/renderD128",
"-audiodev",
'{"id":"audio1","driver":"none"}',
)
def _sonder(self, device, argv0=None, extra=()):
"""Sortie de la sonde devant un /proc bâti pour l'occasion."""
from script.todo.qemu_manage import _DIAG_VIDEO_PY
import subprocess
with tempfile.TemporaryDirectory() as tmp:
proc = Path(tmp) / "proc" / "4242"
proc.mkdir(parents=True)
argv = list(self.ARGV)
argv[0] = argv0 or argv[0]
argv[4] = argv[4] % device
argv.extend(extra)
(proc / "cmdline").write_bytes("\0".join(argv).encode() + b"\0")
programme = _DIAG_VIDEO_PY.replace(
"/proc/", str(Path(tmp) / "proc") + "/"
).replace('chemin.split("/")[2]', "chemin")
fini = subprocess.run(
[sys.executable, "-c", programme],
capture_output=True,
text=True,
)
self.assertEqual(fini.returncode, 0, fini.stderr)
return fini.stdout
def test_the_accelerated_device_is_reported(self):
sortie = self._sonder("virtio-vga-gl")
self.assertIn("une-vm", sortie)
self.assertIn("virtio-vga-gl", sortie)
def test_software_rendering_is_told_apart(self):
"""L'épreuve du rapport : les deux cas ne doivent PAS se lire pareil.
« egl-headless » est présent des deux côtés ; un rapport qui ne
montrerait que lui laisserait croire la 3D acquise.
"""
accelere = self._sonder("virtio-vga-gl")
logiciel = self._sonder("virtio-vga")
self.assertIn("egl-headless", accelere)
self.assertIn("egl-headless", logiciel)
self.assertNotEqual(accelere, logiciel)
self.assertNotIn("virtio-vga-gl", logiciel)
def test_a_process_merely_naming_qemu_is_ignored(self):
"""Le tri se fait sur argv[0], et non sur la ligne entière.
Un processus quelconque peut porter « qemu-system » dans ses
arguments — un pager ouvert sur un journal, et la sonde elle-même,
dont le programme contient le mot. Les compter ferait naître des
VM qui n'existent pas. Le contre-exemple porte donc le motif
AILLEURS qu'en tête, sans quoi il ne départage rien.
"""
argv = ["/var/log/qemu-system-x86_64.log"]
self.assertTrue(any("qemu-system" in a for a in argv))
self.assertEqual(
self._sonder("virtio-vga-gl", argv0="/usr/bin/less", extra=argv),
"",
)
def test_the_diagnostic_carries_the_probe(self):
from script.todo.qemu_manage import QemuManageMixin
sondes = dict(QemuManageMixin._DIAG_PROBES)
self.assertIn("video du qemu en cours", sondes)
class LesPieces3D(unittest.TestCase):
"""Le rapport 3D cherche les DEUX devices accélérés, non un seul.
« virtio-vga-gl » et « virtio-gpu-gl » sont des modules distincts, et
les distributions qui découpent QEMU les empaquettent séparément.
Celui qui sert dépend du type de vidéo demandé ; n'en chercher qu'un
rend un rapport tout vert à côté de la pièce absente.
"""
def test_both_accelerated_devices_are_looked_for(self):
from script.todo.qemu_manage import QemuManageMixin
motifs = [m for _, m in QemuManageMixin._GPU_3D_PIECES]
for module in (
"qemu/hw-display-virtio-vga-gl.so",
"qemu/hw-display-virtio-gpu-gl.so",
):
self.assertIn(module, motifs)