[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:
parent
c8d76f1c33
commit
f417a9ea86
7 changed files with 248 additions and 28 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
116
test/test_qemu_gpu_option.py
Normal file
116
test/test_qemu_gpu_option.py
Normal 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()
|
||||
Loading…
Reference in a new issue