[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
This commit is contained in:
Mathieu Benoit 2026-09-03 03:54:01 -04:00
parent 612d943671
commit b474b6d65e
2 changed files with 149 additions and 6 deletions

View file

@ -1656,7 +1656,35 @@ def host_timezone() -> str:
return "UTC"
def user_groups(distro: str) -> str:
# Groupes qui donnent accès au GPU DANS L'INVITÉ. Le nœud de rendu y
# appartient à « root:render » en 0660, et le compte créé par cloud-init n'en
# fait pas partie : toute application GL retombe alors sur le rendu logiciel,
# alors même que la négociation VIRGL entre l'hôte et l'invité a réussi. Rien
# ne le signale — le matériel virtuel est bien accéléré, seul l'accès manque.
#
# En session graphique locale, logind pose une ACL sur le nœud pour
# l'utilisateur du siège actif et la question ne se pose pas. En SSH ou en
# tty — le cas d'une VM de ce parc — personne ne la pose.
GPU_GROUPS = ("render", "video")
def gpu_group_block() -> list[str]:
"""Bloc « groups: » qui CRÉE les groupes GPU avant leur usage.
« useradd -G » échoue sur un nom de groupe inconnu, et cloud-init ne crée
alors pas l'utilisateur du tout : ni mot de passe ni clé SSH, la VM démarre
et reste inaccessible. « render » est récent et manque des images les plus
anciennes, donc l'ajouter sans précaution rejouerait cette panne.
Le déclarer ici le rend certain d'exister : cloud-init crée les groupes
AVANT les comptes, et passe sans erreur sur ceux qui existent déjà. Le
groupe est retrouvé par NOM par les règles udev, donc un GID choisi par
cloud-init plutôt que par la distribution ne change rien.
"""
return ["groups:"] + [f" - {nom}" for nom in GPU_GROUPS]
def user_groups(distro: str, gpu: bool = False) -> str:
"""Groupes secondaires du compte créé par cloud-init.
Le nom du groupe d'administration change d'une famille à l'autre, et un
@ -1671,10 +1699,14 @@ def user_groups(distro: str) -> str:
garantit « wheel » sur une image Minimal-VM — dans le doute on s'abstient
plutôt que de risquer un compte non créé."""
if distro in ("ubuntu", "debian"):
return "users, sudo"
if distro == "opensuse":
return "users"
return "users, wheel"
noms = ["users", "sudo"]
elif distro == "opensuse":
noms = ["users"]
else:
noms = ["users", "wheel"]
if gpu:
noms += list(GPU_GROUPS)
return ", ".join(noms)
# --------------------------------------------------------------------------- #
@ -2288,6 +2320,13 @@ def build_cloud_config(
"""Construit le contenu #cloud-config (user-data)."""
lines: list[str] = ["#cloud-config", f"hostname: {args.hostname}"]
# « off » est le seul refus explicite ; « auto » laisse l'hôte décider
# s'il accélère, mais l'invité porte un virtio-gpu dans les deux cas et
# l'appartenance aux groupes ne coûte rien quand elle ne sert pas.
gpu = (getattr(args, "gpu", "auto") or "auto").lower() != "off"
if gpu:
lines += gpu_group_block()
user_block = [
"users:",
f" - name: {args.user}",
@ -2300,7 +2339,7 @@ def build_cloud_config(
# même écart, elles n'ont pas de groupe « sudo » mais « wheel ».
# Le privilège lui-même vient de la ligne « sudo: » ci-dessus, pas du
# groupe : celui-ci n'est qu'une commodité.
f" groups: {user_groups(args.distro)}",
f" groups: {user_groups(args.distro, gpu)}",
" shell: /bin/bash",
" lock_passwd: false" if pw_hash else " lock_passwd: true",
]
@ -2732,6 +2771,13 @@ def build_preseed(
f"echo '{user} ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/{user}",
f"chmod 440 /target/etc/sudoers.d/{user}",
]
# Accès au GPU, comme le cloud-config le donne aux autres distributions :
# sans ces groupes, toute application GL de l'invité retombe sur le rendu
# logiciel. « groupadd -f » ne fait rien si le groupe existe et ne rend
# jamais d'erreur, là où « usermod -aG » sur un nom inconnu échoue.
if (getattr(args, "gpu", "auto") or "auto").lower() != "off":
post += [f"in-target groupadd -f {nom}" for nom in GPU_GROUPS]
post.append(f"in-target usermod -aG {','.join(GPU_GROUPS)} {user}")
if ssh_keys:
post.append(f"mkdir -p /target/home/{user}/.ssh")
for key in ssh_keys:

View file

@ -184,3 +184,100 @@ class LAbiFigee(unittest.TestCase):
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)