diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index badaec3..29adb62 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -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: diff --git a/test/test_qemu_gpu_option.py b/test/test_qemu_gpu_option.py index 1dca6f6..54a8fce 100644 --- a/test/test_qemu_gpu_option.py +++ b/test/test_qemu_gpu_option.py @@ -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)