erplibre/test/test_qemu_gpu_option.py
Mathieu Benoit b474b6d65e [FIX] qemu 3D : donner à l'invité l'accès au nœud de rendu
Le matériel virtuel accéléré ne suffisait pas. Dans l'invité, le nœud de
rendu appartient à « root:render » en 0660 et le compte créé n'y était
pas : toute application GL retombait sur le rendu logiciel alors que la
négociation VIRGL avait réussi, et rien ne le signalait. En session
graphique locale, logind pose une ACL pour l'utilisateur du siège ; en
SSH ou en tty, le cas d'une VM de ce parc, personne ne la pose.

Les groupes sont DÉCLARÉS avant d'être utilisés : « useradd -G » échoue
sur un nom inconnu, et cloud-init ne crée alors pas le compte du tout —
la VM démarre inaccessible. « render » manque des images anciennes. Même
parité côté preseed, par « groupadd -f ». « --gpu off » n'ajoute rien.

--- EN ---

Accelerated virtual hardware was not enough. In the guest the render
node is « root:render » at 0660 and the created account was not in it:
every GL application fell back to software rendering although VIRGL
negotiation had succeeded, with nothing to say so. On a local graphical
session logind sets an ACL for the seat's user; over SSH or on a tty,
which is what these VMs get, nobody sets one.

Groups are DECLARED before use: « useradd -G » fails on an unknown name
and cloud-init then creates no account at all — the VM boots
unreachable. « render » is missing from older images. The preseed keeps
parity via « groupadd -f ». « --gpu off » adds nothing.

Assisted-by: Claude Opus 5
2026-09-03 05:00:55 -04:00

283 lines
11 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La 3D se coche à la création, même sur une VM sans écran.
« auto » s'abstient sans écran virtuel : une VM serveur n'a pas demandé de
périphérique vidéo, et lui en poser un d'office changerait son matériel. Mais
une VM sans console peut vouloir un virtio-gpu accéléré — rendu hors écran,
émulateur qui tourne dedans. La case le demande explicitement.
Ce que ces tests gardent :
- la case atteint vraiment la commande, à travers la spec ;
- sans elle, rien ne change pour une VM serveur ;
- « --graphics none » et « egl-headless » ne coexistent jamais : le premier
dit « aucun affichage », le second EST un affichage.
"""
import importlib.util
import sys
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
from script.todo.deploy_form_lib import build_spec # noqa: E402
from script.todo.todo import TODO # noqa: E402
RACINE = Path(__file__).resolve().parents[1]
NODE = "/dev/dri/renderD128"
def _deploy_qemu():
path = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
class LaDecision(unittest.TestCase):
def test_auto_still_abstains_without_a_screen(self):
"""Le défaut ne doit pas changer : une VM serveur reste sans vidéo."""
on, msg = DQ.gpu_decision("auto", NODE, False)
self.assertFalse(on)
self.assertEqual("", msg)
def test_on_now_enables_3d_without_a_screen(self):
on, msg = DQ.gpu_decision("on", NODE, False)
self.assertTrue(on)
self.assertIn("sans écran", msg)
def test_off_wins_over_everything(self):
self.assertEqual((False, ""), DQ.gpu_decision("off", NODE, False))
def test_without_a_host_node_it_still_refuses(self):
on, msg = DQ.gpu_decision("on", "", False)
self.assertFalse(on)
self.assertTrue(msg)
class LaCaseAtteintLaCommande(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
def _parts(self, gpu3d):
vm = {
"name": "vm-a",
"distro": "ubuntu",
"version": "24.04",
"arch": "amd64",
"ram": 4096,
"vcpus": 2,
"disk": "20G",
}
spec = {
"vms": [vm],
"gpu3d": gpu3d,
"install": None,
"ssh_key": "",
"vm_tools": (),
}
return self.todo._qemu_deploy_parts_for(vm, spec, dry_run=True)
def test_the_box_adds_the_flag(self):
self.assertIn("--gpu", self._parts(True))
parts = self._parts(True)
self.assertEqual("on", parts[parts.index("--gpu") + 1])
def test_without_the_box_nothing_is_added(self):
self.assertNotIn("--gpu", self._parts(False))
class LaSpecLaTransporte(unittest.TestCase):
def _spec(self, form_extra):
form = {
"res_label": "x1",
"ssh_key": "",
"install": None,
"add_ssh_config": True,
"parallelism": 1,
}
form.update(form_extra)
return build_spec([{"name": "vm-a"}], [], form)
def test_the_checkbox_reaches_the_spec(self):
self.assertTrue(self._spec({"gpu3d": True})["gpu3d"])
def test_its_default_is_off(self):
self.assertFalse(self._spec({})["gpu3d"])
class LAbiFigee(unittest.TestCase):
"""Une 3D demandée que l'ABI figée annule doit se voir.
Depuis libvirt 12.5.0, le <model> vidéo porte un attribut « device »
qui grave le device QEMU retenu, pour tenir l'ABI de l'invité stable
d'un démarrage à l'autre. Il l'emporte sur « accel3d » : une VM
démarrée une première fois sans 3D garde le device sans GL, et cocher
la 3D ensuite ne change rien à ce que QEMU reçoit. La définition et
la ligne de commande se contredisent alors en silence.
"""
XML = (
"<domain><name>vm</name><vcpu>8</vcpu>"
"<memory unit='KiB'>33554432</memory><devices>"
"<video><model type='virtio' heads='1' {attr}>"
"<acceleration accel3d='{accel}'/></model></video>"
"<graphics type='egl-headless'>"
"<gl rendernode='/dev/dri/renderD128'/></graphics>"
"</devices></domain>"
)
def _etat(self, device="", accel="yes"):
from script.todo.qemu_hardware import hw_state
attr = f"device='{device}'" if device else ""
return hw_state(self.XML.format(attr=attr, accel=accel), False)
def test_the_pinned_device_is_read(self):
self.assertEqual(
self._etat("virtio-vga")["video_device"], "virtio-vga"
)
self.assertEqual(self._etat()["video_device"], "")
def test_a_non_gl_pin_defeats_the_requested_3d(self):
from script.todo.qemu_hardware import pin_defeats_3d
self.assertTrue(pin_defeats_3d(self._etat("virtio-vga")))
def test_a_gl_pin_does_not(self):
"""Le suffixe « -gl » est ce qui distingue les deux devices.
Sans cette lecture, tout épinglage passerait pour une panne et la
VM correctement accélérée porterait un avertissement à tort.
"""
from script.todo.qemu_hardware import pin_defeats_3d
self.assertFalse(pin_defeats_3d(self._etat("virtio-vga-gl")))
self.assertFalse(pin_defeats_3d(self._etat("virtio-gpu-gl")))
def test_no_pin_and_no_3d_are_not_flagged(self):
"""Deux cas voisins qu'un test trop large confondrait : libvirt
antérieur à 12.5.0 n'écrit pas l'attribut, et une VM sans 3D
demandée n'a rien à signaler même si son device est figé."""
from script.todo.qemu_hardware import pin_defeats_3d
self.assertFalse(pin_defeats_3d(self._etat()))
self.assertFalse(pin_defeats_3d(self._etat("virtio-vga", accel="no")))
def test_the_summary_says_so(self):
"""La ligne de résumé est celle sur laquelle on décide : elle ne
doit pas afficher « 3D » tout court quand la 3D ne tourne pas."""
from script.todo.qemu_hardware import hw_summary
figee = hw_summary(self._etat("virtio-vga"))
vivante = hw_summary(self._etat("virtio-vga-gl"))
self.assertIn("virtio-vga", figee)
self.assertIn("⚠", figee)
self.assertNotIn("⚠", vivante)
if __name__ == "__main__":
unittest.main()
try:
import yaml
except ImportError:
yaml = None
class LesGroupesGpuDeLInvite(unittest.TestCase):
"""Le compte de l'invité doit pouvoir OUVRIR le nœud de rendu.
Le matériel virtuel accéléré ne suffit pas : dans l'invité, le nœud
appartient à « root:render » en 0660, et un compte hors de ce groupe
retombe sur le rendu logiciel alors que la négociation VIRGL a réussi.
Rien ne le signale. En session graphique locale logind pose une ACL
pour l'utilisateur du siège ; en SSH ou en tty, personne ne la pose.
"""
def _args(self, distro="ubuntu", gpu="auto"):
return DQ.build_parser().parse_args(
["--distro", distro, "--gpu", gpu, "--hostname", "vm"]
)
def _cc(self, **kw):
return DQ.build_cloud_config(self._args(**kw), None, [])
def test_the_account_joins_the_gpu_groups(self):
"""Sur les groupes DU COMPTE, et non sur le texte du document : le
bloc qui déclare les groupes y porte déjà les deux mots, si bien
qu'y chercher « render » passerait sans que personne n'y entre."""
noms = DQ.user_groups("ubuntu", gpu=True).split(", ")
self.assertIn("render", noms)
self.assertIn("video", noms)
self.assertNotIn("render", DQ.user_groups("ubuntu").split(", "))
def test_gpu_off_leaves_the_account_alone(self):
"""« off » est un refus explicite : ne rien ajouter alors."""
cc = self._cc(gpu="off")
self.assertNotIn("render", cc)
self.assertNotIn("video", cc)
def test_the_admin_group_survives_every_distro(self):
"""Le groupe d'administration ne doit pas être perdu en chemin :
sans lui la commodité disparaît, et un nom inconnu ferait bien
pire — cloud-init ne créerait pas le compte du tout."""
for distro, attendu in (
("ubuntu", "sudo"),
("debian", "sudo"),
("arch", "wheel"),
("almalinux", "wheel"),
):
with self.subTest(distro=distro):
self.assertIn(
attendu, DQ.user_groups(distro, gpu=True).split(", ")
)
def test_opensuse_still_gets_no_admin_group(self):
"""Son cloud-init n'en met pas et « wheel » n'y est pas garanti :
en ajouter un risquerait un compte jamais créé."""
noms = DQ.user_groups("opensuse", gpu=True).split(", ")
self.assertNotIn("wheel", noms)
self.assertNotIn("sudo", noms)
self.assertIn("render", noms)
@unittest.skipIf(yaml is None, "PyYAML absent")
def test_the_groups_are_declared_before_being_used(self):
"""« useradd -G » échoue sur un nom de groupe INCONNU, et cloud-init
ne crée alors pas le compte : ni mot de passe ni clé SSH, la VM
démarre inaccessible. « render » manque des images anciennes, donc
le déclarer n'est pas une précaution de style."""
doc = yaml.safe_load(self._cc())
self.assertEqual(["render", "video"], doc["groups"])
for nom in ("render", "video"):
self.assertIn(nom, doc["users"][0]["groups"].split(", "))
@unittest.skipIf(yaml is None, "PyYAML absent")
def test_the_document_stays_parsable_everywhere(self):
for distro in ("ubuntu", "debian", "arch", "almalinux", "opensuse"):
for gpu in ("auto", "on", "off"):
with self.subTest(distro=distro, gpu=gpu):
doc = yaml.safe_load(self._cc(distro=distro, gpu=gpu))
self.assertEqual("vm", doc["hostname"])
def test_the_preseed_creates_the_groups_before_using_them(self):
"""Debian passe par le preseed et non par cloud-init : la parité
promise par build_preseed s'y perdrait sans cela. L'ORDRE compte —
« usermod -aG » sur un groupe absent échoue."""
pre = DQ.build_preseed(self._args(distro="debian"), "$6$x$y", [])
i_add = pre.index("groupadd -f render")
i_use = pre.index("usermod -aG render,video")
self.assertLess(i_add, i_use)
def test_the_preseed_says_nothing_when_gpu_is_off(self):
pre = DQ.build_preseed(
self._args(distro="debian", gpu="off"), "$6$x$y", []
)
self.assertNotIn("groupadd", pre)