From f417a9ea866f33cb727865399e1f3624871bbc05 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 3 Sep 2026 00:50:48 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20qemu=20deploy=20:=20case=203D=20=C3=A0?= =?UTF-8?q?=20la=20cr=C3=A9ation,=20m=C3=AAme=20sans=20=C3=A9cran=20virtue?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une VM sans console peut vouloir un virtio-gpu accéléré — rendu hors écran, ou émulateur qui tourne dedans — et « auto » ne l'accorde jamais : il s'abstient sans écran, pour ne pas poser un périphérique vidéo que personne n'a demandé. La case envoie donc « --gpu on », qui l'accorde désormais. « --graphics none » est alors écarté : il dit « aucun affichage », et egl-headless EST un affichage. Le repli le rend quand la 3D tombe, sinon la VM repartirait sur le défaut de virt-install. Vérifié : 8 tests neufs et 2 sur le repli, rougis par trois mutations. Le rendu de la case dans le terminal plein écran n'est pas testé. --- EN --- A VM without a console may want an accelerated virtio-gpu — offscreen rendering, or an emulator running inside — and « auto » never grants it: it abstains without a screen, so as not to add a video device nobody asked for. The box therefore sends « --gpu on », which now grants it. « --graphics none » is then dropped: it means « no display », and egl-headless IS a display. The fallback gives it back when 3D fails, otherwise the VM would restart on virt-install's default. Checked: 8 new tests and 2 on the fallback, turned red by three mutations. The box's rendering in the full-screen terminal is not tested. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 57 ++++++++++------ script/todo/deploy_form_lib.py | 3 + script/todo/qemu_deploy.py | 15 +++++ script/todo/qemu_deploy_form.py | 10 +++ script/todo/todo_i18n.py | 11 +++ test/test_qemu_egl_fallback.py | 64 ++++++++++++++++-- test/test_qemu_gpu_option.py | 116 ++++++++++++++++++++++++++++++++ 7 files changed, 248 insertions(+), 28 deletions(-) create mode 100644 test/test_qemu_gpu_option.py diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index da89f5b..badaec3 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -327,14 +327,10 @@ def gpu_decision(mode: str, node: str, screen: bool) -> tuple[bool, str]: mode = (mode or "auto").lower() if mode == "off": return False, "" - if not screen: - # Sans écran virtuel, la 3D n'a rien à accélérer. Le dire seulement - # quand elle a été demandée explicitement. - if mode == "on": - return ( - False, - " GPU : pas d'écran virtuel sur cette VM, 3D ignorée.", - ) + if not screen and mode != "on": + # Sans écran virtuel, « auto » s'abstient : une VM serveur n'a pas + # demandé de périphérique vidéo, et lui en poser un d'office change + # son matériel sans qu'on l'ait voulu. return False, "" if not node: if mode == "on": @@ -348,6 +344,16 @@ def gpu_decision(mode: str, node: str, screen: bool) -> tuple[bool, str]: False, " GPU : aucun sur l'hôte, rendu logiciel (virgl absent).", ) + if not screen: + # 3D demandée sur une VM sans console : le virtio-gpu est POSÉ quand + # même, et « egl-headless » n'ouvre aucun port — l'invité reçoit un + # périphérique DRM accéléré sans écran à regarder. C'est ce qui sert + # au rendu hors écran et à un émulateur qui tourne dans la VM. + return ( + True, + f" GPU : 3D activée par {node} sans écran virtuel" + " (virtio-gpu accéléré, aucun port ouvert).", + ) return True, f" GPU : 3D activée par {node} (virtio-gpu + egl-headless)." @@ -3086,8 +3092,6 @@ def virt_install( osinfo, "--network", args.network, - "--graphics", - graphics, "--console", # Journal de console pour la voie installateur. Une console « pty » # seule ne gardE rien : quand d-i échoue, il l'écrit à l'écran d'une @@ -3105,6 +3109,12 @@ def virt_install( "--channel", "unix,target.type=virtio,target.name=org.qemu.guest_agent.0", ] + # « --graphics none » dit « aucun affichage » : le poser à côté d'un + # « egl-headless », qui EST un affichage, se contredit. Sur une VM sans + # console dont la 3D est demandée, egl-headless reste donc le seul. + graphics_omis = bool(gpu_args) and graphics == "none" + if not graphics_omis: + cmd += ["--graphics", graphics] i_gpu = len(cmd) cmd += video + gpu_args if args.arch == "s390x": @@ -3185,20 +3195,25 @@ def virt_install( f"\nÉchec de la commande (code {code}) :\n" f" {' '.join(log_env + cmd)}" ) - if (args.gpu or "auto").lower() == "on": - # « --gpu on » est une exigence, pas une préférence : la trahir en - # silence donnerait une VM qui n'est pas celle qu'on a demandée. - sys.exit( - "\nErreur : --gpu on demandé, mais EGL ne démarre pas sur" - f" {gpu_node}.\n Relancer avec --gpu off pour une VM en rendu" - " logiciel." - ) + # Le repli vaut AUSSI pour « --gpu on ». Une VM qu'on n'a pas est pire + # qu'une VM sans 3D, et le repli n'est pas silencieux : il le dit, en + # nommant ce qui a été demandé et ce qui a été obtenu. + demande = (args.gpu or "auto").lower() == "on" print( - f"\n ⚠ EGL ne démarre pas sur {gpu_node} : la 3D est retirée et la" - "\n VM recréée en rendu logiciel. « --gpu off » évite cet essai." + f"\n ⚠ EGL ne démarre pas sur {gpu_node} :" + f" {'la 3D DEMANDÉE est retirée' if demande else 'la 3D est retirée'}" + "\n et la VM recréée en rendu logiciel." + " « --gpu off » évite cet essai." ) + # Rendre l'affichage écarté plus haut : sans lui, la VM repartirait sans + # « --graphics none », donc avec le défaut de virt-install, qui n'est pas + # ce qu'on avait demandé. + rendu = [] if not graphics_omis else ["--graphics", graphics] cmd_sans_3d = ( - cmd[:i_gpu] + video_sans_3d + cmd[i_gpu + len(video) + len(gpu_args) :] + cmd[:i_gpu] + + rendu + + video_sans_3d + + cmd[i_gpu + len(video) + len(gpu_args) :] ) # Le domaine défini par l'essai raté doit partir : sans quoi virt-install # refuse le nom, et la VM resterait celle qui ne démarre pas. diff --git a/script/todo/deploy_form_lib.py b/script/todo/deploy_form_lib.py index 793c52a..02b5b7a 100644 --- a/script/todo/deploy_form_lib.py +++ b/script/todo/deploy_form_lib.py @@ -393,6 +393,9 @@ def build_spec(vms, domains, form): # ERPLibre se suit aussi (cloud-init, puis relevé système). Absent de # cette assemblée, le choix du formulaire n'atteignait jamais la spec. "monitor": form.get("monitor", True), + # Au niveau du déploiement : la 3D est une propriété du matériel de la + # VM, pas de ce qu'on installe dedans. + "gpu3d": form.get("gpu3d", False), "add_ssh_config": form["add_ssh_config"], "parallelism": form["parallelism"], } diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 695e0b1..c14132a 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -975,6 +975,7 @@ class QemuDeployMixin: prod=False, install_cmd="", vm_tools=(), + gpu3d=False, ): """Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu dry-run ET le déploiement réel).""" @@ -1010,6 +1011,10 @@ class QemuDeployMixin: parts += ["--locale", locale] if desktop: parts.append("--desktop") + if gpu3d: + # « on » et non « auto » : auto s'abstient sur une VM sans écran, + # or c'est précisément ce que la case permet de demander. + parts += ["--gpu", "on"] # Guide affiché à la connexion SSH de la VM : dans la langue du menu, et # avec la section ERPLibre seulement là où ERPLibre sera installé — une # VM déployée nue n'annonce pas un dépôt qui n'existe pas. @@ -1081,6 +1086,7 @@ class QemuDeployMixin: vm.get("install_cmd") or (install or {}).get("cmd") or "" ), vm_tools=spec.get("vm_tools") or (), + gpu3d=bool(spec.get("gpu3d")), ) def _qemu_arches_for(self, distro, arch): @@ -1845,6 +1851,14 @@ class QemuDeployMixin: input(f"{t('Watch the VMs start (no install)')} ? (O/n) : ") ) + # Posée même sans bureau : une VM sans console peut vouloir un + # virtio-gpu accéléré, et c'est ce que « auto » n'accorde jamais. + gpu3d = self._is_yes( + input( + t("3D acceleration (host GPU), even without a screen? (y/N): ") + ) + ) + add_ssh_config = self._is_yes_default_yes( input(t("Add each VM to ~/.ssh/config? (Y/n): ")) ) @@ -1907,6 +1921,7 @@ class QemuDeployMixin: # Au niveau du déploiement : le suivi survit à une installation # décochée (voir _qemu_run_spec). "monitor": monitor, + "gpu3d": gpu3d, "add_ssh_config": add_ssh_config, "parallelism": parallelism, } diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 15fd899..6a8e996 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -339,6 +339,15 @@ def run_deploy_form(ctx, run_app: bool = True): value=defaults.get("monitor", True), id="f_monitor", ) + # Indépendante du type de VM : une machine sans console + # peut vouloir un virtio-gpu accéléré — rendu hors écran, + # ou émulateur qui tourne dedans. Sans écran, + # « egl-headless » n'ouvre aucun port. + yield Checkbox( + t("3D acceleration (host GPU), even without a screen"), + value=defaults.get("gpu3d", False), + id="f_gpu3d", + ) # Le parallélisme reste dans « Déploiement » : c'est le # nombre de VM menées de front, pas une option # d'installation. @@ -911,6 +920,7 @@ def run_deploy_form(ctx, run_app: bool = True): # l'installation : décocher ERPLibre emportait la case avec # elle, et le tableau de bord ne s'ouvrait plus du tout. "monitor": self.query_one("#f_monitor", Checkbox).value, + "gpu3d": self.query_one("#f_gpu3d", Checkbox).value, "res_label": ( t("custom") if self.profile == "custom" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index e91a4f5..9309156 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4993,6 +4993,17 @@ TRANSLATIONS = { "fr": "Conservés — le déploiement de ces VM ÉCHOUERA :", "en": "Kept - the deployment of these VMs will FAIL:", }, + "3D acceleration (host GPU), even without a screen": { + "fr": "Accélération 3D (GPU de l'hôte), même sans écran", + "en": "3D acceleration (host GPU), even without a screen", + }, + "3D acceleration (host GPU), even without a screen? (y/N): ": { + "fr": "Accélération 3D (GPU de l'hôte), même sans écran ? (o/N) : ", + "en": ( + "3D acceleration (host GPU), even without a screen?" + " (y/N, default: no): " + ), + }, "Full output:": { "fr": "Sortie complète :", "en": "Full output:", diff --git a/test/test_qemu_egl_fallback.py b/test/test_qemu_egl_fallback.py index 01d8d9d..349fda0 100644 --- a/test/test_qemu_egl_fallback.py +++ b/test/test_qemu_egl_fallback.py @@ -137,19 +137,69 @@ class Repli(unittest.TestCase): self.assertIn("vm-a", milieu) self.assertLess(1, len(lances) - 1) - def test_gpu_on_is_not_silently_downgraded(self): - """« --gpu on » est une exigence : la trahir donnerait une VM qui - n'est pas celle qu'on a demandée.""" - lances, exc, _ = self._lancer(gpu="on") - self.assertIsInstance(exc, SystemExit) - self.assertIn("--gpu off", str(exc)) - self.assertEqual(1, len(lances)) + def test_gpu_on_also_falls_back_but_says_so(self): + """Une VM qu'on n'a pas est pire qu'une VM sans 3D. Le repli vaut donc + aussi pour une 3D demandée — à condition de nommer ce qui manque, sans + quoi l'utilisateur croirait avoir obtenu ce qu'il a coché.""" + lances, exc, rendu = self._lancer(gpu="on") + self.assertIsNone(exc, rendu) + self.assertIn("DEMANDÉE", rendu) + self.assertNotIn("accel3d", " ".join(lances[-1])) def test_another_failure_is_not_retried(self): lances, exc, _ = self._lancer(sortie=SORTIE_AUTRE) self.assertIsInstance(exc, SystemExit) self.assertEqual(1, len(lances)) + def _sans_ecran(self, resultat): + """virt_install sur une VM sans console dont la 3D est demandée. + + `resultat` est ce que rend le runner en mode capture. Rend les + commandes lancées.""" + args = self._args(gpu="on") + args.desktop = False + lances = [] + + class FauxRunner: + dry_run = False + use_sudo = False + + def run(self, cmd, *, privileged=False, check=True, capture=False): + lances.append(list(cmd)) + return resultat if capture else None + + with mock.patch.object( + DQ, "host_arch", return_value="amd64" + ), mock.patch.object( + DQ, "kvm_available", return_value=True + ), mock.patch.object( + DQ, "os" + ) as faux_os: + faux_os.getuid.return_value = 1000 + with redirect_stdout(io.StringIO()): + DQ.virt_install( + args, + Path("/tmp/d.qcow2"), + Path("/tmp/s.iso"), + "ubuntu26.04", + FauxRunner(), + ) + return lances + + def test_a_screenless_3d_vm_keeps_egl_as_its_only_display(self): + """« --graphics none » dit « aucun affichage » : le poser à côté d'un + egl-headless, qui EST un affichage, se contredit.""" + rendu = " ".join(self._sans_ecran((0, ""))[0]) + self.assertIn("egl-headless", rendu) + self.assertNotIn("--graphics none", rendu) + + def test_dropping_the_3d_gives_the_display_back(self): + """Le repli doit rendre le « --graphics none » écarté pour la 3D, + sinon la VM repart sur le défaut de virt-install.""" + dernier = " ".join(self._sans_ecran((1, SORTIE_EGL))[-1]) + self.assertIn("--graphics none", dernier) + self.assertNotIn("egl-headless", dernier) + def test_a_success_never_retries(self): lances, exc, _ = self._lancer(sortie="", code=0) self.assertIsNone(exc) diff --git a/test/test_qemu_gpu_option.py b/test/test_qemu_gpu_option.py new file mode 100644 index 0000000..f749e01 --- /dev/null +++ b/test/test_qemu_gpu_option.py @@ -0,0 +1,116 @@ +#!/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"]) + + +if __name__ == "__main__": + unittest.main()