[ADD] qemu deploy : case 3D à la création, même sans écran virtuel

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
This commit is contained in:
Mathieu Benoit 2026-09-03 00:50:48 -04:00
parent c8d76f1c33
commit f417a9ea86
7 changed files with 248 additions and 28 deletions

View file

@ -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.

View file

@ -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"],
}

View file

@ -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,
}

View file

@ -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"

View file

@ -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:",

View file

@ -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)

View file

@ -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()